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 22:30:54 +00:00
|
|
|
const { WellKnownLocations } = require('../web');
|
2023-01-03 22:10:39 +00:00
|
|
|
const { buildSelfUrl } = require('../../../activitypub_util');
|
2022-12-31 05:39:39 +00:00
|
|
|
|
|
|
|
const _ = require('lodash');
|
2022-12-31 07:38:09 +00:00
|
|
|
const User = require('../../../user');
|
2023-01-01 17:07:33 +00:00
|
|
|
const UserProps = require('../../../user_property');
|
2022-12-31 07:38:09 +00:00
|
|
|
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');
|
2023-01-02 04:15:43 +00:00
|
|
|
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',
|
2023-01-03 22:10:39 +00:00
|
|
|
packageName: 'codes.l33t.enigma.web.handler.webfinger',
|
2022-12-31 05:39:39 +00:00
|
|
|
};
|
|
|
|
|
2023-01-02 23:55:36 +00:00
|
|
|
// :TODO: more info in default
|
|
|
|
const DefaultProfileTemplate = `
|
|
|
|
User information for: %USERNAME%
|
|
|
|
|
|
|
|
Real Name: %REAL_NAME%
|
|
|
|
Login Count: %LOGIN_COUNT%
|
|
|
|
Affiliations: %AFFILIATIONS%
|
|
|
|
Achievement Points: %ACHIEVEMENT_POINTS%
|
|
|
|
`;
|
|
|
|
|
2023-01-01 00:51:03 +00:00
|
|
|
//
|
|
|
|
// WebFinger: https://www.rfc-editor.org/rfc/rfc7033
|
|
|
|
//
|
2023-01-03 05:25:32 +00:00
|
|
|
exports.getModule = class WebFingerWebHandler 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();
|
|
|
|
|
2022-12-31 05:39:39 +00:00
|
|
|
// we rely on the web server
|
2023-01-03 05:25:32 +00:00
|
|
|
this.webServer = WebHandlerModule.getWebServer();
|
|
|
|
if (!this.webServer || !this.webServer.isEnabled()) {
|
2022-12-31 07:38:09 +00:00
|
|
|
return cb(Errors.UnexpectedState('Cannot access web server!'));
|
2022-12-31 05:39:39 +00:00
|
|
|
}
|
|
|
|
|
2023-01-03 05:25:32 +00:00
|
|
|
const domain = this.webServer.getDomain();
|
2022-12-31 22:30:54 +00:00
|
|
|
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/
|
2023-01-03 05:25:32 +00:00
|
|
|
new RegExp(
|
|
|
|
`^${this.webServer.buildUrl(WellKnownLocations.Internal + '/wf/@')}(.+)$`
|
|
|
|
),
|
2022-12-31 22:30:54 +00:00
|
|
|
// self URL
|
|
|
|
new RegExp(
|
2023-01-03 05:25:32 +00:00
|
|
|
`^${this.webServer.buildUrl(
|
|
|
|
WellKnownLocations.Internal + '/ap/users/'
|
|
|
|
)}(.+)$`
|
2022-12-31 22:30:54 +00:00
|
|
|
),
|
|
|
|
];
|
|
|
|
|
2023-01-03 05:25:32 +00:00
|
|
|
this.webServer.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),
|
|
|
|
});
|
|
|
|
|
2023-01-03 05:25:32 +00:00
|
|
|
this.webServer.addRoute({
|
2023-01-01 16:47:59 +00:00
|
|
|
method: 'GET',
|
2023-01-02 02:19:51 +00:00
|
|
|
path: /^\/_enig\/wf\/@.+$/,
|
2023-01-01 16:47:59 +00:00
|
|
|
handler: this._profileRequestHandler.bind(this),
|
|
|
|
});
|
|
|
|
|
2022-12-31 05:39:39 +00:00
|
|
|
return cb(null);
|
|
|
|
}
|
|
|
|
|
2023-01-01 16:47:59 +00:00
|
|
|
_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"'
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
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) => {
|
2023-01-02 04:15:43 +00:00
|
|
|
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'),
|
2023-01-02 04:15:43 +00:00
|
|
|
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-01 17:07:33 +00:00
|
|
|
|
2023-01-02 02:19:51 +00:00
|
|
|
_getProfileTemplate(cb) {
|
|
|
|
let templateFile = _.get(
|
|
|
|
Config(),
|
|
|
|
'contentServers.web.handlers.webFinger.profileTemplate'
|
|
|
|
);
|
|
|
|
if (templateFile) {
|
2023-01-03 05:25:32 +00:00
|
|
|
templateFile = this.webServer.resolveTemplatePath(templateFile);
|
2023-01-02 02:19:51 +00:00
|
|
|
}
|
|
|
|
fs.readFile(templateFile || '', 'utf8', (err, data) => {
|
|
|
|
if (err) {
|
|
|
|
if (templateFile) {
|
|
|
|
Log.warn(
|
|
|
|
{ error: err.message },
|
|
|
|
`Failed to load profile template "${templateFile}"`
|
|
|
|
);
|
|
|
|
}
|
2023-01-01 16:47:59 +00:00
|
|
|
|
2023-01-02 23:55:36 +00:00
|
|
|
return cb(DefaultProfileTemplate, 'text/plain');
|
2023-01-02 02:19:51 +00:00
|
|
|
}
|
|
|
|
return cb(data, mimeTypes.contentType(paths.basename(templateFile)));
|
2023-01-01 16:47:59 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2022-12-31 05:39:39 +00:00
|
|
|
_webFingerRequestHandler(req, resp) {
|
2022-12-31 07:38:09 +00:00
|
|
|
const url = new URL(req.url, `https://${req.headers.host}`);
|
|
|
|
|
|
|
|
const resource = url.searchParams.get('resource');
|
|
|
|
if (!resource) {
|
2023-01-03 05:25:32 +00:00
|
|
|
return this.webServer.respondWithError(
|
2022-12-31 07:38:09 +00:00
|
|
|
resp,
|
|
|
|
400,
|
|
|
|
'"resource" is required',
|
|
|
|
'Missing "resource"'
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-01-01 16:47:59 +00:00
|
|
|
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) => {
|
2022-12-31 07:38:09 +00:00
|
|
|
if (err) {
|
|
|
|
// |resp| already written to
|
|
|
|
return Log.warn({ error: err.message }, `WebFinger failed: ${req.url}`);
|
|
|
|
}
|
|
|
|
|
2023-01-03 05:25:32 +00:00
|
|
|
const domain = this.webServer.getDomain();
|
2022-12-31 22:30:54 +00:00
|
|
|
|
2022-12-31 07:38:09 +00:00
|
|
|
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(),
|
|
|
|
],
|
2022-12-31 07:38:09 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
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) {
|
2023-01-03 05:25:32 +00:00
|
|
|
return this.webServer.buildUrl(
|
2022-12-31 22:30:54 +00:00
|
|
|
WellKnownLocations.Internal + `/wf/@${user.username}`
|
|
|
|
);
|
2022-12-31 19:05:59 +00:00
|
|
|
}
|
|
|
|
|
2022-12-31 07:38:09 +00:00
|
|
|
_profilePageLink(user) {
|
2022-12-31 19:05:59 +00:00
|
|
|
const href = this._profileUrl(user);
|
2022-12-31 07:38:09 +00:00
|
|
|
return {
|
|
|
|
rel: 'http://webfinger.net/rel/profile-page',
|
|
|
|
type: 'text/plain',
|
|
|
|
href,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2022-12-31 19:05:59 +00:00
|
|
|
_selfUrl(user) {
|
2023-01-03 22:10:39 +00:00
|
|
|
return buildSelfUrl(this.webServer, user, '/ap/users/');
|
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',
|
2023-01-03 05:25:32 +00:00
|
|
|
template: this.webServer.buildUrl(
|
2022-12-31 22:30:54 +00:00
|
|
|
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];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2022-12-31 07:38:09 +00:00
|
|
|
|
2023-01-01 16:47:59 +00:00
|
|
|
_notFound(resp) {
|
2023-01-03 05:25:32 +00:00
|
|
|
this.webServer.respondWithError(
|
2023-01-01 16:47:59 +00:00
|
|
|
resp,
|
|
|
|
404,
|
|
|
|
'Resource not found',
|
|
|
|
'Resource Not Found'
|
|
|
|
);
|
|
|
|
}
|
2022-12-31 07:38:09 +00:00
|
|
|
|
2023-01-01 16:47:59 +00:00
|
|
|
_getUser(accountName, resp, cb) {
|
2022-12-31 22:30:54 +00:00
|
|
|
User.getUserIdAndName(accountName, (err, userId) => {
|
2022-12-31 07:38:09 +00:00
|
|
|
if (err) {
|
2023-01-01 16:47:59 +00:00
|
|
|
this._notFound(resp);
|
2022-12-31 07:38:09 +00:00
|
|
|
return cb(err);
|
|
|
|
}
|
|
|
|
|
|
|
|
User.getUser(userId, (err, user) => {
|
|
|
|
if (err) {
|
2023-01-01 16:47:59 +00:00
|
|
|
this._notFound(resp);
|
2022-12-31 07:38:09 +00:00
|
|
|
return cb(err);
|
|
|
|
}
|
|
|
|
|
2023-01-02 03:54:19 +00:00
|
|
|
const accountStatus = user.getPropertyAsNumber(UserProps.AccountStatus);
|
2023-01-02 23:55:36 +00:00
|
|
|
if (
|
|
|
|
User.AccountStatus.disabled == accountStatus ||
|
|
|
|
User.AccountStatus.inactive == accountStatus
|
|
|
|
) {
|
2023-01-02 03:54:19 +00:00
|
|
|
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 07:38:09 +00:00
|
|
|
});
|
|
|
|
});
|
2022-12-31 05:39:39 +00:00
|
|
|
}
|
|
|
|
};
|