Retro style default profile, constant cleanup, some DRY, etc.
This commit is contained in:
parent
2a75d55b42
commit
3bdce81bdb
|
@ -1,5 +1,11 @@
|
|||
const { localActorId } = require('./util');
|
||||
const { WellKnownActivityTypes } = require('./const');
|
||||
const {
|
||||
ActivityStreamMediaType,
|
||||
WellKnownActivityTypes,
|
||||
WellKnownActivity,
|
||||
WellKnownRecipientFields,
|
||||
HttpSignatureSignHeaders,
|
||||
} = require('./const');
|
||||
const ActivityPubObject = require('./object');
|
||||
const { Errors } = require('../enig_error');
|
||||
const UserProps = require('../user_property');
|
||||
|
@ -26,7 +32,7 @@ module.exports = class Activity extends ActivityPubObject {
|
|||
static makeFollow(webServer, localActor, remoteActor) {
|
||||
return new Activity({
|
||||
id: Activity.activityObjectId(webServer),
|
||||
type: 'Follow',
|
||||
type: WellKnownActivity.Follow,
|
||||
actor: localActor,
|
||||
object: remoteActor.id,
|
||||
});
|
||||
|
@ -36,7 +42,7 @@ module.exports = class Activity extends ActivityPubObject {
|
|||
static makeAccept(webServer, localActor, followRequest) {
|
||||
return new Activity({
|
||||
id: Activity.activityObjectId(webServer),
|
||||
type: 'Accept',
|
||||
type: WellKnownActivity.Accept,
|
||||
actor: localActor,
|
||||
object: followRequest, // previous request Activity
|
||||
});
|
||||
|
@ -45,7 +51,7 @@ module.exports = class Activity extends ActivityPubObject {
|
|||
static makeCreate(webServer, actor, obj) {
|
||||
return new Activity({
|
||||
id: Activity.activityObjectId(webServer),
|
||||
type: 'Create',
|
||||
type: WellKnownActivity.Create,
|
||||
actor,
|
||||
object: obj,
|
||||
});
|
||||
|
@ -55,7 +61,7 @@ module.exports = class Activity extends ActivityPubObject {
|
|||
const deleted = getISOTimestampString();
|
||||
return new Activity({
|
||||
id: obj.id,
|
||||
type: 'Tombstone',
|
||||
type: WellKnownActivity.Tombstone,
|
||||
deleted,
|
||||
published: deleted,
|
||||
updated: deleted,
|
||||
|
@ -74,14 +80,13 @@ module.exports = class Activity extends ActivityPubObject {
|
|||
|
||||
const reqOpts = {
|
||||
headers: {
|
||||
'Content-Type': 'application/activity+json',
|
||||
'Content-Type': ActivityStreamMediaType,
|
||||
},
|
||||
sign: {
|
||||
// :TODO: Make a helper for this
|
||||
key: privateKey,
|
||||
keyId: localActorId(webServer, fromUser) + '#main-key',
|
||||
authorizationHeaderName: 'Signature',
|
||||
headers: ['(request-target)', 'host', 'date', 'digest', 'content-type'],
|
||||
headers: HttpSignatureSignHeaders,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -92,8 +97,7 @@ module.exports = class Activity extends ActivityPubObject {
|
|||
recipientIds() {
|
||||
const ids = [];
|
||||
|
||||
// :TODO: bto, bcc?
|
||||
['to', 'cc', 'audience'].forEach(field => {
|
||||
WellKnownRecipientFields.forEach(field => {
|
||||
let v = this[field];
|
||||
if (v) {
|
||||
if (!Array.isArray(v)) {
|
||||
|
@ -103,7 +107,7 @@ module.exports = class Activity extends ActivityPubObject {
|
|||
}
|
||||
});
|
||||
|
||||
return ids;
|
||||
return Array.from(new Set(ids));
|
||||
}
|
||||
|
||||
static activityObjectId(webServer) {
|
||||
|
|
|
@ -17,6 +17,7 @@ const { queryWebFinger } = require('../webfinger');
|
|||
const EnigAssert = require('../enigma_assert');
|
||||
const ActivityPubSettings = require('./settings');
|
||||
const ActivityPubObject = require('./object');
|
||||
const { ActivityStreamMediaType } = require('./const');
|
||||
const apDb = require('../database').dbs.activitypub;
|
||||
|
||||
// deps
|
||||
|
@ -96,6 +97,12 @@ module.exports = class Actor extends ActivityPubObject {
|
|||
'@context': [
|
||||
ActivityStreamsContext,
|
||||
'https://w3id.org/security/v1', // :TODO: add support
|
||||
{
|
||||
bbsPublicStats: {
|
||||
'@id': 'bbs:bbsPublicStats',
|
||||
'@type': '@id',
|
||||
},
|
||||
},
|
||||
],
|
||||
id: userActorId,
|
||||
type: 'Person',
|
||||
|
@ -122,6 +129,25 @@ module.exports = class Actor extends ActivityPubObject {
|
|||
// value: 'Mateo@21:1/121',
|
||||
// },
|
||||
// ],
|
||||
bbsPublicStats: {
|
||||
affiliations: user.getProperty(UserProps.Affiliations) || '',
|
||||
lastLogin: user.getProperty(UserProps.LastLoginTs),
|
||||
loginCount: user.getPropertyAsNumber(UserProps.LoginCount),
|
||||
joined: user.getProperty(UserProps.AccountCreated),
|
||||
postCount: user.getPropertyAsNumber(UserProps.MessagePostCount),
|
||||
doorCount: user.getPropertyAsNumber(UserProps.DoorRunTotalCount),
|
||||
doorMinute: user.getPropertyAsNumber(UserProps.DoorRunTotalMinutes),
|
||||
achievementCount: user.getPropertyAsNumber(
|
||||
UserProps.AchievementTotalCount
|
||||
),
|
||||
achievementPoints: user.getPropertyAsNumber(
|
||||
UserProps.AchievementTotalPoints
|
||||
),
|
||||
uploadCount: user.getPropertyAsNumber(UserProps.FileUlTotalCount),
|
||||
downloadCount: user.getPropertyAsNumber(UserProps.FileDlTotalCount),
|
||||
uploadBytes: user.getPropertyAsNumber(UserProps.FileUlTotalBytes),
|
||||
downloadBytes: user.getPropertyAsNumber(UserProps.FileDlTotalBytes),
|
||||
},
|
||||
};
|
||||
|
||||
addImage(obj, 'icon');
|
||||
|
@ -190,7 +216,7 @@ module.exports = class Actor extends ActivityPubObject {
|
|||
|
||||
static _fromRemoteQuery(id, cb) {
|
||||
const headers = {
|
||||
Accept: 'application/activity+json',
|
||||
Accept: ActivityStreamMediaType,
|
||||
};
|
||||
|
||||
getJson(id, { headers }, (err, actor) => {
|
||||
|
@ -268,7 +294,7 @@ module.exports = class Actor extends ActivityPubObject {
|
|||
}
|
||||
|
||||
const activityLink = links.find(l => {
|
||||
return l.type === 'application/activity+json' && l.href?.length > 0;
|
||||
return l.type === ActivityStreamMediaType && l.href?.length > 0;
|
||||
});
|
||||
|
||||
if (!activityLink) {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
exports.ActivityStreamsContext = 'https://www.w3.org/ns/activitystreams';
|
||||
exports.PublicCollectionId = 'https://www.w3.org/ns/activitystreams#Public';
|
||||
exports.ActivityStreamMediaType = 'application/activity+json';
|
||||
|
||||
const WellKnownActivity = {
|
||||
Create: 'Create',
|
||||
|
@ -13,8 +14,20 @@ const WellKnownActivity = {
|
|||
Like: 'Like',
|
||||
Announce: 'Announce',
|
||||
Undo: 'Undo',
|
||||
Tombstone: 'Tombstone',
|
||||
};
|
||||
exports.WellKnownActivity = WellKnownActivity;
|
||||
|
||||
const WellKnownActivityTypes = Object.values(WellKnownActivity);
|
||||
exports.WellKnownActivityTypes = WellKnownActivityTypes;
|
||||
|
||||
exports.WellKnownRecipientFields = ['audience', 'bcc', 'bto', 'cc', 'to'];
|
||||
|
||||
// Signatures utilized in HTTP signature generation
|
||||
exports.HttpSignatureSignHeaders = [
|
||||
'(request-target)',
|
||||
'host',
|
||||
'date',
|
||||
'digest',
|
||||
'content-type',
|
||||
];
|
||||
|
|
|
@ -14,6 +14,7 @@ const paths = require('path');
|
|||
const moment = require('moment');
|
||||
const { striptags } = require('striptags');
|
||||
const { encode, decode } = require('html-entities');
|
||||
const { isString } = require('lodash');
|
||||
|
||||
exports.ActivityStreamsContext = 'https://www.w3.org/ns/activitystreams';
|
||||
exports.isValidLink = isValidLink;
|
||||
|
@ -32,9 +33,9 @@ exports.userNameFromSubject = userNameFromSubject;
|
|||
// profiles and 'self' requests without the
|
||||
// Accept: application/activity+json headers present
|
||||
exports.DefaultProfileTemplate = `
|
||||
User information for: %USERNAME%
|
||||
User information for: %PREFERRED_USERNAME%
|
||||
|
||||
Real Name: %REAL_NAME%
|
||||
Name: %NAME%
|
||||
Login Count: %LOGIN_COUNT%
|
||||
Affiliations: %AFFILIATIONS%
|
||||
Achievement Points: %ACHIEVEMENT_POINTS%
|
||||
|
@ -102,8 +103,10 @@ function userFromActorId(actorId, cb) {
|
|||
}
|
||||
|
||||
function getUserProfileTemplatedBody(
|
||||
webServer,
|
||||
templateFile,
|
||||
user,
|
||||
userAsActor,
|
||||
defaultTemplate,
|
||||
defaultContentType,
|
||||
cb
|
||||
|
@ -130,36 +133,53 @@ function getUserProfileTemplatedBody(
|
|||
return callback(null, template, contentType);
|
||||
},
|
||||
(template, contentType, callback) => {
|
||||
const up = (p, na = 'N/A') => {
|
||||
return user.getProperty(p) || na;
|
||||
const val = v => {
|
||||
if (isString(v)) {
|
||||
return v ? encode(v) : '';
|
||||
} else {
|
||||
return v ? v : 0;
|
||||
}
|
||||
};
|
||||
|
||||
let birthDate = up(UserProps.Birthdate);
|
||||
let birthDate = val(user.getProperty(UserProps.Birthdate));
|
||||
if (moment.isDate(birthDate)) {
|
||||
birthDate = moment(birthDate);
|
||||
}
|
||||
|
||||
const varMap = {
|
||||
USERNAME: user.username,
|
||||
REAL_NAME: user.getSanitizedName('real'),
|
||||
SEX: up(UserProps.Sex),
|
||||
ACTOR_OBJ: JSON.stringify(userAsActor),
|
||||
SUBJECT: `@${user.username}@${webServer.getDomain()}`,
|
||||
INBOX: userAsActor.inbox,
|
||||
SHARED_INBOX: userAsActor.endpoints.sharedInbox,
|
||||
OUTBOX: userAsActor.outbox,
|
||||
FOLLOWERS: userAsActor.followers,
|
||||
FOLLOWING: userAsActor.following,
|
||||
USER_ICON: userAsActor.icon.url,
|
||||
USER_IMAGE: userAsActor.image.url,
|
||||
PREFERRED_USERNAME: userAsActor.preferredUsername,
|
||||
NAME: userAsActor.name,
|
||||
SEX: user.getProperty(UserProps.Sex),
|
||||
BIRTHDATE: birthDate,
|
||||
AGE: user.getAge(),
|
||||
LOCATION: up(UserProps.Location),
|
||||
AFFILIATIONS: up(UserProps.Affiliations),
|
||||
EMAIL: up(UserProps.EmailAddress),
|
||||
WEB_ADDRESS: up(UserProps.WebAddress),
|
||||
LOCATION: user.getProperty(UserProps.Location),
|
||||
AFFILIATIONS: user.getProperty(UserProps.Affiliations),
|
||||
EMAIL: user.getProperty(UserProps.EmailAddress),
|
||||
WEB_ADDRESS: user.getProperty(UserProps.WebAddress),
|
||||
ACCOUNT_CREATED: moment(user.getProperty(UserProps.AccountCreated)),
|
||||
LAST_LOGIN: moment(user.getProperty(UserProps.LastLoginTs)),
|
||||
LOGIN_COUNT: up(UserProps.LoginCount),
|
||||
ACHIEVEMENT_COUNT: up(UserProps.AchievementTotalCount, '0'),
|
||||
ACHIEVEMENT_POINTS: up(UserProps.AchievementTotalPoints, '0'),
|
||||
LOGIN_COUNT: user.getPropertyAsNumber(UserProps.LoginCount),
|
||||
ACHIEVEMENT_COUNT: user.getPropertyAsNumber(
|
||||
UserProps.AchievementTotalCount
|
||||
),
|
||||
ACHIEVEMENT_POINTS: user.getProperty(
|
||||
UserProps.AchievementTotalPoints
|
||||
),
|
||||
BOARDNAME: Config().general.boardName,
|
||||
};
|
||||
|
||||
let body = template;
|
||||
_.each(varMap, (val, varName) => {
|
||||
body = body.replace(new RegExp(`%${varName}%`, 'g'), val);
|
||||
_.each(varMap, (v, varName) => {
|
||||
body = body.replace(new RegExp(`%${varName}%`, 'g'), val(v));
|
||||
});
|
||||
|
||||
return callback(null, body, contentType);
|
||||
|
|
|
@ -324,6 +324,11 @@ exports.getModule = class WebServerModule extends ServerModule {
|
|||
});
|
||||
}
|
||||
|
||||
created(resp, body = '', headers = { 'Content-Type:': 'text/html' }) {
|
||||
resp.writeHead(201, 'Created', body ? headers : null);
|
||||
return resp.end(body);
|
||||
}
|
||||
|
||||
accepted(resp, body = '', headers = { 'Content-Type:': 'text/html' }) {
|
||||
resp.writeHead(202, 'Accepted', body ? headers : null);
|
||||
return resp.end(body);
|
||||
|
|
|
@ -6,6 +6,7 @@ const {
|
|||
makeUserUrl,
|
||||
localActorId,
|
||||
} = require('../../../activitypub/util');
|
||||
const { ActivityStreamMediaType } = require('../../../activitypub/const');
|
||||
const Config = require('../../../config').get;
|
||||
const Activity = require('../../../activitypub/activity');
|
||||
const ActivityPubSettings = require('../../../activitypub/settings');
|
||||
|
@ -31,8 +32,6 @@ exports.moduleInfo = {
|
|||
packageName: 'codes.l33t.enigma.web.handler.activitypub',
|
||||
};
|
||||
|
||||
const ActivityJsonMime = 'application/activity+json';
|
||||
|
||||
exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
|
||||
constructor() {
|
||||
super();
|
||||
|
@ -149,7 +148,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
|
|||
// Additionally, serve activity JSON if the proper 'Accept' header was sent
|
||||
const accept = req.headers['accept'].split(',').map(v => v.trim()) || ['*/*'];
|
||||
const headerValues = [
|
||||
ActivityJsonMime,
|
||||
ActivityStreamMediaType,
|
||||
'application/ld+json',
|
||||
'application/json',
|
||||
];
|
||||
|
@ -166,11 +165,17 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
|
|||
return this.webServer.resourceNotFound(resp);
|
||||
}
|
||||
|
||||
if (sendActor) {
|
||||
return this._selfAsActorHandler(localUser, req, resp);
|
||||
} else {
|
||||
return this._standardSelfHandler(localUser, req, resp);
|
||||
Actor.fromLocalUser(localUser, this.webServer, (err, localActor) => {
|
||||
if (err) {
|
||||
return this.webServer.internalServerError(resp, err);
|
||||
}
|
||||
|
||||
if (sendActor) {
|
||||
return this._selfAsActorHandler(localUser, localActor, req, resp);
|
||||
} else {
|
||||
return this._standardSelfHandler(localUser, localActor, req, resp);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -341,7 +346,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
|
|||
return this.webServer.internalServerError(resp, err);
|
||||
}
|
||||
|
||||
return this.webServer.accepted(resp);
|
||||
return this.webServer.created(resp);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -409,7 +414,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
|
|||
|
||||
const body = JSON.stringify(collection);
|
||||
const headers = {
|
||||
'Content-Type': ActivityJsonMime,
|
||||
'Content-Type': ActivityStreamMediaType,
|
||||
'Content-Length': body.length,
|
||||
};
|
||||
|
||||
|
@ -700,30 +705,24 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
|
|||
console.log(resp);
|
||||
}
|
||||
|
||||
_selfAsActorHandler(user, req, resp) {
|
||||
_selfAsActorHandler(localUser, localActor, req, resp) {
|
||||
this.log.trace(
|
||||
{ username: user.username },
|
||||
`Serving ActivityPub Actor for "${user.username}"`
|
||||
{ username: localUser.username },
|
||||
`Serving ActivityPub Actor for "${localUser.username}"`
|
||||
);
|
||||
|
||||
Actor.fromLocalUser(user, this.webServer, (err, actor) => {
|
||||
if (err) {
|
||||
return this.webServer.internalServerError(resp, err);
|
||||
}
|
||||
|
||||
const body = JSON.stringify(actor);
|
||||
const body = JSON.stringify(localActor);
|
||||
|
||||
const headers = {
|
||||
'Content-Type': ActivityJsonMime,
|
||||
'Content-Type': ActivityStreamMediaType,
|
||||
'Content-Length': body.length,
|
||||
};
|
||||
|
||||
resp.writeHead(200, headers);
|
||||
return resp.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
_standardSelfHandler(user, req, resp) {
|
||||
_standardSelfHandler(localUser, localActor, req, resp) {
|
||||
let templateFile = _.get(
|
||||
Config(),
|
||||
'contentServers.web.handlers.activityPub.selfTemplate'
|
||||
|
@ -734,8 +733,10 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
|
|||
|
||||
// we'll fall back to the same default profile info as the WebFinger profile
|
||||
getUserProfileTemplatedBody(
|
||||
this.webServer,
|
||||
templateFile,
|
||||
user,
|
||||
localUser,
|
||||
localActor,
|
||||
DefaultProfileTemplate,
|
||||
'text/plain',
|
||||
(err, body, contentType) => {
|
||||
|
|
|
@ -15,6 +15,7 @@ const ActivityPubSettings = require('../../../activitypub/settings');
|
|||
|
||||
// deps
|
||||
const _ = require('lodash');
|
||||
const Actor = require('../../../activitypub/actor');
|
||||
|
||||
exports.moduleInfo = {
|
||||
name: 'WebFinger',
|
||||
|
@ -104,9 +105,16 @@ exports.getModule = class WebFingerWebHandler extends WebHandlerModule {
|
|||
templateFile = this.webServer.resolveTemplatePath(templateFile);
|
||||
}
|
||||
|
||||
Actor.fromLocalUser(localUser, this.webServer, (err, localActor) => {
|
||||
if (err) {
|
||||
return this.webServer.internalServerError(resp, err);
|
||||
}
|
||||
|
||||
getUserProfileTemplatedBody(
|
||||
this.webServer,
|
||||
templateFile,
|
||||
localUser,
|
||||
localActor,
|
||||
DefaultProfileTemplate,
|
||||
'text/plain',
|
||||
(err, body, contentType) => {
|
||||
|
@ -124,6 +132,7 @@ exports.getModule = class WebFingerWebHandler extends WebHandlerModule {
|
|||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_webFingerRequestHandler(req, resp) {
|
||||
|
|
|
@ -1,19 +1,118 @@
|
|||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html class="no-js" lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>%USERNAME%</title>
|
||||
<meta name="description" content="Profile of %USERNAME% of %BOARDNAME%">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>%NAME%</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<meta name="description" content="Profile of %PREFERRED_USERNAME% of %BOARDNAME%">
|
||||
</head>
|
||||
<style>
|
||||
body {
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.left {
|
||||
left: 25px;
|
||||
}
|
||||
|
||||
.right {
|
||||
right: 25px;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: absolute;
|
||||
bottom: 25px;
|
||||
}
|
||||
|
||||
#gradient {
|
||||
background: #999955;
|
||||
background-image: linear-gradient(#aa5500 20%, #aa0000 20%, #aa0000 40%, #00aa00 40%, #00aa00 60%, #00aaaa 60%, #00aaaa 80%, #aa00aa 80%);
|
||||
margin: 0 auto;
|
||||
margin-top: 100px;
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
#gradient:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: #555555;
|
||||
left: 50%;
|
||||
margin-top: -67.5px;
|
||||
margin-left: -270px;
|
||||
padding-left: 20px;
|
||||
border-radius: 5px;
|
||||
width: 520px;
|
||||
height: 275px;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
#card {
|
||||
position: absolute;
|
||||
width: 450px;
|
||||
height: 225px;
|
||||
padding: 25px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
left: 50%;
|
||||
top: 67.5px;
|
||||
margin-left: -250px;
|
||||
background: #aaaaaa;
|
||||
box-shadow: -20px 0 35px -25px black, 20px 0 35px -25px black;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
#card img {
|
||||
width: 150px;
|
||||
float: left;
|
||||
border-radius: 5px;
|
||||
margin-right: 20px;
|
||||
-webkit-filter: sepia(1);
|
||||
-moz-filter: sepia(1);
|
||||
filter: sepia(1);
|
||||
}
|
||||
|
||||
#card h2 {
|
||||
font-family: courier;
|
||||
color: #ff55ff;
|
||||
background: #aa00aa;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
font-size: 15pt;
|
||||
}
|
||||
|
||||
#card p {
|
||||
font-family: courier;
|
||||
color: #000000;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#card span {
|
||||
font-family: courier;
|
||||
}
|
||||
</style>
|
||||
<body>
|
||||
<h1>%USERNAME% of %BOARDNAME%:</h1>
|
||||
<div id="gradient"></div>
|
||||
<div id="card">
|
||||
<img src="%USER_ICON%" />
|
||||
<h2>%NAME%</h2>
|
||||
<p>
|
||||
<b>Real Name</b>: %REAL_NAME%<br>
|
||||
<b>Name</b>: %NAME% <br>
|
||||
<b>Location</b>: %LOCATION% <br>
|
||||
<b>Login Count</b> %LOGIN_COUNT%<br>
|
||||
<b>Login Count</b>: %LOGIN_COUNT% <br>
|
||||
<b>Last Login <b>: %LAST_LOGIN% <br>
|
||||
<b>Affils</b>: %AFFILIATIONS% <br>
|
||||
<b>Account Since</b>: %ACCOUNT_CREATED% <br>
|
||||
</p>
|
||||
<span class="left bottom">%SUBJECT%</span>
|
||||
<span class="right bottom">%BOARDNAME%!</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in New Issue