enigma-bbs/core/servers/content/web_handlers/webfinger.js

332 lines
10 KiB
JavaScript
Raw Normal View History

2022-12-31 22:48:51 +00:00
const WebHandlerModule = require('../../../web_handler_module');
2022-12-31 05:39:39 +00:00
const Config = require('../../../config').get;
2023-01-02 03:54:19 +00:00
const { Errors, ErrorReasons } = require('../../../enig_error');
2022-12-31 05:39:39 +00:00
const WebServerPackageName = require('../web').moduleInfo.packageName;
2022-12-31 22:30:54 +00:00
const { WellKnownLocations } = require('../web');
2022-12-31 05:39:39 +00:00
const _ = require('lodash');
const User = require('../../../user');
const UserProps = require('../../../user_property');
const Log = require('../../../logger').log;
2023-01-02 02:19:51 +00:00
const mimeTypes = require('mime-types');
const fs = require('graceful-fs');
const paths = require('path');
const moment = require('moment');
2022-12-31 05:39:39 +00:00
exports.moduleInfo = {
name: 'WebFinger',
2023-01-01 00:51:03 +00:00
desc: 'A simple WebFinger Handler.',
2022-12-31 22:39:54 +00:00
author: 'NuSkooler, CognitiveGears',
packageName: 'codes.l33t.enigma.web.handler.finger',
2022-12-31 05:39:39 +00:00
};
2023-01-01 00:51:03 +00:00
//
// WebFinger: https://www.rfc-editor.org/rfc/rfc7033
//
2022-12-31 22:48:51 +00:00
exports.getModule = class WebFingerServerModule extends WebHandlerModule {
2022-12-31 05:39:39 +00:00
constructor() {
super();
}
init(cb) {
2022-12-31 22:30:54 +00:00
const config = Config();
if (!_.get(config, 'contentServers.web.handlers.webFinger.enabled')) {
2022-12-31 05:39:39 +00:00
return cb(null);
}
const { getServer } = require('../../../listening_server');
// we rely on the web server
this.webServer = getServer(WebServerPackageName);
2022-12-31 22:30:54 +00:00
const ws = this._webServer();
if (!ws || !ws.isEnabled()) {
return cb(Errors.UnexpectedState('Cannot access web server!'));
2022-12-31 05:39:39 +00:00
}
2022-12-31 22:30:54 +00:00
const domain = ws.getDomain();
if (!domain) {
return cb(Errors.UnexpectedState('Web server does not have "domain" set'));
}
this.acceptedResourceRegExps = [
// acct:NAME@our.domain.tld
2023-01-01 00:51:03 +00:00
// https://www.rfc-editor.org/rfc/rfc7565
2022-12-31 22:30:54 +00:00
new RegExp(`^acct:(.+)@${domain}$`),
2023-01-01 00:51:03 +00:00
// profile page
// https://webfinger.net/rel/profile-page/
2022-12-31 22:30:54 +00:00
new RegExp(`^${ws.buildUrl(WellKnownLocations.Internal + '/wf/@')}(.+)$`),
// self URL
new RegExp(
`^${ws.buildUrl(WellKnownLocations.Internal + '/ap/users/')}(.+)$`
),
];
ws.addRoute({
2022-12-31 05:39:39 +00:00
method: 'GET',
2022-12-31 22:30:54 +00:00
// https://www.rfc-editor.org/rfc/rfc7033.html#section-10.1
2022-12-31 05:39:39 +00:00
path: /^\/\.well-known\/webfinger\/?\?/,
handler: this._webFingerRequestHandler.bind(this),
});
ws.addRoute({
method: 'GET',
2023-01-02 02:19:51 +00:00
path: /^\/_enig\/wf\/@.+$/,
handler: this._profileRequestHandler.bind(this),
});
2022-12-31 05:39:39 +00:00
return cb(null);
}
2022-12-31 22:30:54 +00:00
_webServer() {
return this.webServer.instance;
}
_profileRequestHandler(req, resp) {
const url = new URL(req.url, `https://${req.headers.host}`);
const resource = url.pathname;
if (_.isEmpty(resource)) {
return this.webServer.instance.respondWithError(
resp,
400,
'pathname is required',
'Missing "resource"'
);
}
// TODO: Handle URL escaped @ sign as well
const userPosition = resource.indexOf('@');
if (-1 == userPosition || userPosition == resource.length - 1) {
this._notFound(resp);
return Errors.DoesNotExist('"@username" missing from path');
}
const accountName = resource.substring(userPosition + 1);
this._getUser(accountName, resp, (err, user) => {
if (err) {
// |resp| already written to
return Log.warn(
{ error: err.message },
`Profile request failed: ${req.url}`
);
}
2023-01-02 02:19:51 +00:00
this._getProfileTemplate((template, mimeType) => {
const up = (p, na = 'N/A') => {
return user.getProperty(p) || na;
};
let birthDate = up(UserProps.Birthdate);
if (moment.isDate(birthDate)) {
birthDate = moment(birthDate);
}
2023-01-02 02:19:51 +00:00
const varMap = {
USERNAME: user.username,
REAL_NAME: user.getSanitizedName('real'),
SEX: up(UserProps.Sex),
BIRTHDATE: birthDate,
AGE: user.getAge(),
LOCATION: up(UserProps.Location),
AFFILIATIONS: up(UserProps.Affiliations),
EMAIL: up(UserProps.EmailAddress),
WEB_ADDRESS: up(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'),
BOARDNAME: Config().general.boardName,
2023-01-02 02:19:51 +00:00
};
let body = template;
_.each(varMap, (val, varName) => {
body = body.replace(new RegExp(`%${varName}%`, 'g'), val);
});
const headers = {
'Content-Type': mimeType,
'Content-Length': body.length,
};
resp.writeHead(200, headers);
return resp.end(body);
});
});
}
2023-01-02 02:19:51 +00:00
_getProfileTemplate(cb) {
let templateFile = _.get(
Config(),
'contentServers.web.handlers.webFinger.profileTemplate'
);
if (templateFile) {
templateFile = this._webServer().resolveTemplatePath(templateFile);
}
fs.readFile(templateFile || '', 'utf8', (err, data) => {
if (err) {
if (templateFile) {
Log.warn(
{ error: err.message },
`Failed to load profile template "${templateFile}"`
);
}
2023-01-02 02:19:51 +00:00
// :TODO: more info in default
return cb(
`
User information for: %USERNAME%
2023-01-02 02:19:51 +00:00
Real Name: %REAL_NAME%
Login Count: %LOGIN_COUNT%
Affiliations: %AFFILIATIONS%
Achievement Points: %ACHIEVEMENT_POINTS%`,
'text/plain'
);
}
return cb(data, mimeTypes.contentType(paths.basename(templateFile)));
});
}
2022-12-31 05:39:39 +00:00
_webFingerRequestHandler(req, resp) {
const url = new URL(req.url, `https://${req.headers.host}`);
const resource = url.searchParams.get('resource');
if (!resource) {
2022-12-31 22:30:54 +00:00
return this._webServer().respondWithError(
resp,
400,
'"resource" is required',
'Missing "resource"'
);
}
const accountName = this._getAccountName(resource);
if (!accountName || accountName.length < 1) {
this._notFound(resp);
return Errors.DoesNotExist(
`Failed to parse "account name" for resource: ${resource}`
);
}
this._getUser(accountName, resp, (err, user) => {
if (err) {
// |resp| already written to
return Log.warn({ error: err.message }, `WebFinger failed: ${req.url}`);
}
2022-12-31 22:30:54 +00:00
const domain = this._webServer().getDomain();
const body = JSON.stringify({
2022-12-31 22:30:54 +00:00
subject: `acct:${user.username}@${domain}`,
2022-12-31 19:05:59 +00:00
aliases: [this._profileUrl(user), this._selfUrl(user)],
2022-12-31 22:30:54 +00:00
links: [
this._profilePageLink(user),
this._selfLink(user),
this._subscribeLink(),
],
});
const headers = {
'Content-Type': 'application/jrd+json',
'Content-Length': body.length,
};
resp.writeHead(200, headers);
return resp.end(body);
});
}
2022-12-31 19:05:59 +00:00
_profileUrl(user) {
2022-12-31 22:30:54 +00:00
return this._webServer().buildUrl(
WellKnownLocations.Internal + `/wf/@${user.username}`
);
2022-12-31 19:05:59 +00:00
}
_profilePageLink(user) {
2022-12-31 19:05:59 +00:00
const href = this._profileUrl(user);
return {
rel: 'http://webfinger.net/rel/profile-page',
type: 'text/plain',
href,
};
}
2022-12-31 19:05:59 +00:00
_selfUrl(user) {
2022-12-31 22:30:54 +00:00
return this._webServer().buildUrl(
WellKnownLocations.Internal + `/ap/users/${user.username}`
);
2022-12-31 19:05:59 +00:00
}
2022-12-31 22:30:54 +00:00
// :TODO: only if ActivityPub is enabled
2022-12-31 19:05:59 +00:00
_selfLink(user) {
const href = this._selfUrl(user);
return {
rel: 'self',
type: 'application/activity+json',
href,
};
}
2022-12-31 22:30:54 +00:00
// :TODO: only if ActivityPub is enabled
2022-12-31 19:05:59 +00:00
_subscribeLink() {
return {
rel: 'http://ostatus.org/schema/1.0/subscribe',
2022-12-31 22:30:54 +00:00
template: this._webServer().buildUrl(
WellKnownLocations.Internal + '/ap/authorize_interaction?uri={uri}'
),
2022-12-31 19:05:59 +00:00
};
}
2022-12-31 22:30:54 +00:00
_getAccountName(resource) {
for (const re of this.acceptedResourceRegExps) {
const m = resource.match(re);
if (m && m.length === 2) {
return m[1];
}
}
}
_notFound(resp) {
this._webServer().respondWithError(
resp,
404,
'Resource not found',
'Resource Not Found'
);
}
_getUser(accountName, resp, cb) {
2022-12-31 22:30:54 +00:00
User.getUserIdAndName(accountName, (err, userId) => {
if (err) {
this._notFound(resp);
return cb(err);
}
User.getUser(userId, (err, user) => {
if (err) {
this._notFound(resp);
return cb(err);
}
2023-01-02 03:54:19 +00:00
const accountStatus = user.getPropertyAsNumber(UserProps.AccountStatus);
if (
User.AccountStatus.disabled == accountStatus &&
User.AccountStatus.inactive == accountStatus
) {
this._notFound(resp);
return cb(
Errors.AccessDenied('Account disabled', ErrorReasons.Disabled)
);
}
2022-12-31 22:30:54 +00:00
return cb(null, user);
});
});
2022-12-31 05:39:39 +00:00
}
};