forked from FoundKeyGang/FoundKey
Merge tag 'v13.0.0-preview6' into snug.moe
This commit is contained in:
commit
2cf288a9fb
94 changed files with 1457 additions and 1090 deletions
|
@ -153,3 +153,9 @@ redis:
|
|||
# info: /twemoji/1f440.svg
|
||||
# notFound: /twemoji/2049.svg
|
||||
# error: /twemoji/1f480.svg
|
||||
|
||||
# Whether it should be allowed to fetch content in ActivityPub form without HTTP signatures.
|
||||
# It is recommended to leave this as default to improve the effectiveness of instance blocks.s
|
||||
# However, note that while this prevents fetching in ActivityPub form, it could still be scraped
|
||||
# from the API or other representations if the other side is determined to do so.
|
||||
#allowUnsignedFetches: false
|
||||
|
|
2
.mailmap
2
.mailmap
|
@ -6,7 +6,7 @@ Chloe Kudryavtsev <code@toast.bunkerlabs.net> <toast+git@toast.cafe>
|
|||
Chloe Kudryavtsev <code@toast.bunkerlabs.net> <toast@toast.cafe>
|
||||
Dr. Gutfuck LLC <40531868+gutfuckllc@users.noreply.github.com>
|
||||
Ehsan Javadynia <31900907+ehsanjavadynia@users.noreply.github.com> <ehsan.javadynia@gmail.com>
|
||||
Francis Dinh <normandy@biribiri.dev>
|
||||
Norm <normandy@biribiri.dev>
|
||||
Hakaba Hitoyo <tsukadayoshio@gmail.com> Hakaba Hitoyo <example@example.com>
|
||||
Johann150 <johann.galle@protonmail.com> <johann@qwertqwefsday.eu>
|
||||
Michcio <public+git@meekchopp.es> <michcio@noreply.akkoma>
|
||||
|
|
27
CHANGELOG.md
27
CHANGELOG.md
|
@ -11,6 +11,33 @@ Unreleased changes should not be listed in this file.
|
|||
Instead, run `git shortlog --format='%h %s' --group=trailer:changelog <last tag>..` to see unreleased changes; replace `<last tag>` with the tag you wish to compare from.
|
||||
If you are a contributor, please read [CONTRIBUTING.md, section "Changelog Trailer"](./CONTRIBUTING.md#changelog-trailer) on what to do instead.
|
||||
|
||||
## 13.0.0-preview6 - 2023-07-02
|
||||
|
||||
## Added
|
||||
- **BREAKING** activitypub: validate fetch signatures
|
||||
Fetching the ActivityPub representation of something now requires a valid HTTP signature.
|
||||
- client: add MFM functions `position`, `scale`, `fg`, `bg`
|
||||
- server: add webhook stat to nodeinfo
|
||||
- activitypub: handle incoming Update Note activities
|
||||
|
||||
## Changed
|
||||
- client: change followers only icon to closed lock
|
||||
- client: disable sound for received note by default
|
||||
- client: always forbid MFM overflow
|
||||
- make mutes case insensitive
|
||||
- activitypub: improve JSON-LD context
|
||||
The context now properly notes the `@type`s of defined attributes.
|
||||
- docker: only publish port on localhost
|
||||
|
||||
## Fixed
|
||||
- server: fix internal download in emoji import
|
||||
- server: replace unzipper with decompress
|
||||
|
||||
## Removed
|
||||
- migrate note favorites to clips
|
||||
If you previously had favorites they will now be in a clip called "⭐".
|
||||
If you want to add a note as a "favorite" you can use the menu item "Clip".
|
||||
|
||||
## 13.0.0-preview5 - 2023-05-23
|
||||
This release contains 6 breaking changes and 1 security update.
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ services:
|
|||
- redis
|
||||
# - es
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "127.0.0.1:3000:3000"
|
||||
networks:
|
||||
- internal_network
|
||||
- external_network
|
||||
|
|
|
@ -1309,6 +1309,7 @@ _notification:
|
|||
groupInvited: "Erhaltene Gruppeneinladungen"
|
||||
app: "Benachrichtigungen von Apps"
|
||||
move: Account-Umzüge
|
||||
updated: Beobachtete Notiz wurde bearbeitet
|
||||
_actions:
|
||||
followBack: "folgt dir nun auch"
|
||||
reply: "Antworten"
|
||||
|
|
|
@ -1304,6 +1304,7 @@ _notification:
|
|||
reaction: "Reactions"
|
||||
pollVote: "Votes on polls"
|
||||
pollEnded: "Polls ending"
|
||||
updated: "Watched Note was updated"
|
||||
receiveFollowRequest: "Received follow requests"
|
||||
followRequestAccepted: "Accepted follow requests"
|
||||
groupInvited: "Group invitations"
|
||||
|
|
|
@ -748,6 +748,7 @@ _ffVisibility:
|
|||
followers: "フォロワーだけに公開"
|
||||
private: "非公開"
|
||||
|
||||
nobody: 誰にも見せない (あなたにさえも)
|
||||
_signup:
|
||||
almostThere: "ほとんど完了です"
|
||||
emailAddressInfo: "あなたが使っているメールアドレスを入力してください。メールアドレスが公開されることはありません。"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "foundkey",
|
||||
"version": "13.0.0-preview5",
|
||||
"version": "13.0.0-preview6",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://akkoma.dev/FoundKeyGang/FoundKey.git"
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
{
|
||||
"extension": ["ts","js","cjs","mjs"],
|
||||
"node-option": [
|
||||
"experimental-specifier-resolution=node",
|
||||
"loader=./test/loader.js"
|
||||
"experimental-specifier-resolution=node"
|
||||
],
|
||||
"slow": 1000,
|
||||
"timeout": 30000,
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
export class noteEditing1685997617959 {
|
||||
name = 'noteEditing1685997617959';
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "note" ADD "updatedAt" TIMESTAMP WITH TIME ZONE`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note"."updatedAt" IS 'The updated date of the Note.'`);
|
||||
|
||||
await queryRunner.query(`ALTER TYPE "public"."notification_type_enum" RENAME TO "notification_type_enum_old"`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'move', 'app', 'updated')`);
|
||||
await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum" USING "type"::"text"::"public"."notification_type_enum"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."notification_type_enum_old"`);
|
||||
await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum" RENAME TO "user_profile_mutingnotificationtypes_enum_old"`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'move', 'app', 'updated')`);
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`);
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum"[]`);
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`);
|
||||
await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum_old"`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" AS ENUM('move', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'pollEnded')`);
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`);
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum_old"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum_old"[]`);
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`);
|
||||
await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum"`);
|
||||
await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum_old" RENAME TO "user_profile_mutingnotificationtypes_enum"`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('move', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`);
|
||||
await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum_old" USING "type"::"text"::"public"."notification_type_enum_old"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`);
|
||||
await queryRunner.query(`ALTER TYPE "public"."notification_type_enum_old" RENAME TO "notification_type_enum"`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "updatedAt"`);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "backend",
|
||||
"version": "13.0.0-preview5",
|
||||
"version": "13.0.0-preview6",
|
||||
"main": "./index.js",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
@ -8,10 +8,10 @@
|
|||
"build": "tsc -p tsconfig.json || echo done. && tsc-alias -p tsconfig.json",
|
||||
"watch": "node watch.mjs",
|
||||
"lint": "tsc --noEmit --skipLibCheck && eslint src --ext .ts",
|
||||
"mocha": "cross-env NODE_ENV=test TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT=\"./test/tsconfig.json\" mocha",
|
||||
"mocha": "NODE_ENV=test mocha",
|
||||
"migrate": "npx typeorm migration:run -d ormconfig.js",
|
||||
"start": "node --experimental-json-modules ./built/index.js",
|
||||
"start:test": "cross-env NODE_ENV=test node --experimental-json-modules ./built/index.js",
|
||||
"start:test": "NODE_ENV=test node --experimental-json-modules ./built/index.js",
|
||||
"test": "npm run mocha"
|
||||
},
|
||||
"dependencies": {
|
||||
|
@ -41,6 +41,7 @@
|
|||
"color-convert": "2.0.1",
|
||||
"content-disposition": "0.5.4",
|
||||
"date-fns": "2.28.0",
|
||||
"decompress": "4.2.1",
|
||||
"deep-email-validator": "0.1.21",
|
||||
"escape-regexp": "0.0.1",
|
||||
"feed": "4.2.2",
|
||||
|
@ -109,7 +110,6 @@
|
|||
"tsconfig-paths": "4.1.0",
|
||||
"twemoji-parser": "14.0.0",
|
||||
"typeorm": "0.3.7",
|
||||
"unzipper": "0.10.11",
|
||||
"uuid": "8.3.2",
|
||||
"web-push": "3.5.0",
|
||||
"ws": "8.8.0",
|
||||
|
@ -164,7 +164,6 @@
|
|||
"@types/ws": "8.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.46.1",
|
||||
"@typescript-eslint/parser": "^5.46.1",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "^8.29.0",
|
||||
"eslint-plugin-foundkey-custom-rules": "file:../shared/custom-rules",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
|
|
|
@ -61,6 +61,7 @@ export function loadConfig(): Config {
|
|||
proxyRemoteFiles: false,
|
||||
maxFileSize: 262144000, // 250 MiB
|
||||
maxNoteTextLength: 3000,
|
||||
allowUnsignedFetches: false,
|
||||
}, config);
|
||||
|
||||
mixin.version = meta.version;
|
||||
|
|
|
@ -68,6 +68,8 @@ export type Source = {
|
|||
notFound?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
allowUnsignedFetches?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -11,7 +11,7 @@ export function isSelfHost(host: string | null): boolean {
|
|||
return toPuny(config.host) === toPuny(host);
|
||||
}
|
||||
|
||||
export function extractDbHost(uri: string): string {
|
||||
export function extractPunyHost(uri: string): string {
|
||||
const url = new URL(uri);
|
||||
return toPuny(url.hostname);
|
||||
}
|
||||
|
|
|
@ -19,6 +19,12 @@ export class Note {
|
|||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
nullable: true,
|
||||
comment: 'The updated date of the Note.',
|
||||
})
|
||||
public updatedAt: Date | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
|
|
|
@ -169,6 +169,7 @@ export const NoteRepository = db.getRepository(Note).extend({
|
|||
const packed: Packed<'Note'> = await awaitAll({
|
||||
id: note.id,
|
||||
createdAt: note.createdAt.toISOString(),
|
||||
updatedAt: note.updatedAt?.toISOString() ?? null,
|
||||
userId: note.userId,
|
||||
user: Users.pack(note.user ?? note.userId, me, {
|
||||
detail: false,
|
||||
|
|
|
@ -12,6 +12,11 @@ export const packedNoteSchema = {
|
|||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
format: 'date-time',
|
||||
},
|
||||
text: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
|
|
|
@ -32,11 +32,6 @@ export const packedUserLiteSchema = {
|
|||
type: 'any',
|
||||
nullable: true, optional: false,
|
||||
},
|
||||
avatarColor: {
|
||||
type: 'any',
|
||||
nullable: true, optional: false,
|
||||
default: null,
|
||||
},
|
||||
isAdmin: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: true,
|
||||
|
@ -122,11 +117,6 @@ export const packedUserDetailedNotMeOnlySchema = {
|
|||
type: 'any',
|
||||
nullable: true, optional: false,
|
||||
},
|
||||
bannerColor: {
|
||||
type: 'any',
|
||||
nullable: true, optional: false,
|
||||
default: null,
|
||||
},
|
||||
isLocked: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import * as fs from 'node:fs';
|
||||
import Bull from 'bull';
|
||||
import unzipper from 'unzipper';
|
||||
import decompress from 'decompress';
|
||||
|
||||
import { db } from '@/db/postgre.js';
|
||||
import { createTempDir } from '@/misc/create-temp.js';
|
||||
import { downloadUrl } from '@/misc/download-url.js';
|
||||
import { genId } from '@/misc/gen-id.js';
|
||||
import { DriveFiles, Emojis } from '@/models/index.js';
|
||||
import { DbUserImportJobData } from '@/queue/types.js';
|
||||
import { queueLogger } from '@/queue/logger.js';
|
||||
import { addFile } from '@/services/drive/add-file.js';
|
||||
import { copyFileTo } from '@/services/drive/read-file.js';
|
||||
|
||||
const logger = queueLogger.createSubLogger('import-custom-emojis');
|
||||
|
||||
|
@ -33,7 +33,7 @@ export async function importCustomEmojis(job: Bull.Job<DbUserImportJobData>, don
|
|||
|
||||
try {
|
||||
fs.writeFileSync(destPath, '', 'binary');
|
||||
await downloadUrl(file.url, destPath);
|
||||
await copyFileTo(file, destPath);
|
||||
} catch (e) { // TODO: 何度か再試行
|
||||
if (e instanceof Error || typeof e === 'string') {
|
||||
logger.error(e);
|
||||
|
@ -42,44 +42,41 @@ export async function importCustomEmojis(job: Bull.Job<DbUserImportJobData>, don
|
|||
}
|
||||
|
||||
const outputPath = path + '/emojis';
|
||||
const unzipStream = fs.createReadStream(destPath);
|
||||
const extractor = unzipper.Extract({ path: outputPath });
|
||||
extractor.on('close', async () => {
|
||||
const metaRaw = fs.readFileSync(outputPath + '/meta.json', 'utf-8');
|
||||
const meta = JSON.parse(metaRaw);
|
||||
|
||||
for (const record of meta.emojis) {
|
||||
if (!record.downloaded) continue;
|
||||
if (!/^[a-zA-Z0-9_]+?([a-zA-Z0-9\.]+)?$/.test(record.fileName)) {
|
||||
this.logger.error(`invalid filename: ${record.fileName}, skipping in emoji import`);
|
||||
continue;
|
||||
}
|
||||
const emojiInfo = record.emoji;
|
||||
const emojiPath = outputPath + '/' + record.fileName;
|
||||
await Emojis.delete({
|
||||
name: emojiInfo.name,
|
||||
});
|
||||
const driveFile = await addFile({ user: null, path: emojiPath, name: record.fileName, force: true });
|
||||
await Emojis.insert({
|
||||
id: genId(),
|
||||
updatedAt: new Date(),
|
||||
name: emojiInfo.name,
|
||||
category: emojiInfo.category,
|
||||
host: null,
|
||||
aliases: emojiInfo.aliases,
|
||||
originalUrl: driveFile.url,
|
||||
publicUrl: driveFile.webpublicUrl ?? driveFile.url,
|
||||
type: driveFile.webpublicType ?? driveFile.type,
|
||||
});
|
||||
}
|
||||
|
||||
await db.queryResultCache!.remove(['meta_emojis']);
|
||||
|
||||
cleanup();
|
||||
|
||||
logger.succ('Imported');
|
||||
done();
|
||||
});
|
||||
unzipStream.pipe(extractor);
|
||||
logger.succ(`Unzipping to ${outputPath}`);
|
||||
await decompress(destPath, outputPath);
|
||||
|
||||
const metaRaw = fs.readFileSync(outputPath + '/meta.json', 'utf-8');
|
||||
const meta = JSON.parse(metaRaw);
|
||||
|
||||
for (const record of meta.emojis) {
|
||||
if (!record.downloaded) continue;
|
||||
if (!/^[a-zA-Z0-9_]+?([a-zA-Z0-9\.]+)?$/.test(record.fileName)) {
|
||||
this.logger.error(`invalid filename: ${record.fileName}, skipping in emoji import`);
|
||||
continue;
|
||||
}
|
||||
const emojiInfo = record.emoji;
|
||||
const emojiPath = outputPath + '/' + record.fileName;
|
||||
await Emojis.delete({
|
||||
name: emojiInfo.name,
|
||||
});
|
||||
const driveFile = await addFile({ user: null, path: emojiPath, name: record.fileName, force: true });
|
||||
await Emojis.insert({
|
||||
id: genId(),
|
||||
updatedAt: new Date(),
|
||||
name: emojiInfo.name,
|
||||
category: emojiInfo.category,
|
||||
host: null,
|
||||
aliases: emojiInfo.aliases,
|
||||
originalUrl: driveFile.url,
|
||||
publicUrl: driveFile.webpublicUrl ?? driveFile.url,
|
||||
type: driveFile.webpublicType ?? driveFile.type,
|
||||
});
|
||||
}
|
||||
|
||||
await db.queryResultCache!.remove(['meta_emojis']);
|
||||
|
||||
cleanup();
|
||||
|
||||
logger.succ('Imported');
|
||||
done();
|
||||
}
|
||||
|
|
|
@ -1,27 +1,27 @@
|
|||
import { URL } from 'node:url';
|
||||
import Bull from 'bull';
|
||||
import httpSignature from '@peertube/http-signature';
|
||||
import { perform } from '@/remote/activitypub/perform.js';
|
||||
import Logger from '@/services/logger.js';
|
||||
import { registerOrFetchInstanceDoc } from '@/services/register-or-fetch-instance-doc.js';
|
||||
import { Instances } from '@/models/index.js';
|
||||
import { apRequestChart, federationChart, instanceChart } from '@/services/chart/index.js';
|
||||
import { toPuny, extractDbHost } from '@/misc/convert-host.js';
|
||||
import { extractPunyHost } from '@/misc/convert-host.js';
|
||||
import { getApId } from '@/remote/activitypub/type.js';
|
||||
import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js';
|
||||
import { Resolver } from '@/remote/activitypub/resolver.js';
|
||||
import { LdSignature } from '@/remote/activitypub/misc/ld-signature.js';
|
||||
import { getAuthUser } from '@/remote/activitypub/misc/auth-user.js';
|
||||
import { StatusError } from '@/misc/fetch.js';
|
||||
import { AuthUser, getAuthUser } from '@/remote/activitypub/misc/auth-user.js';
|
||||
import { InboxJobData } from '@/queue/types.js';
|
||||
import { shouldBlockInstance } from '@/misc/should-block-instance.js';
|
||||
import { verifyHttpSignature } from '@/remote/http-signature.js';
|
||||
|
||||
const logger = new Logger('inbox');
|
||||
|
||||
// ユーザーのinboxにアクティビティが届いた時の処理
|
||||
// Processing when an activity arrives in the user's inbox
|
||||
export default async (job: Bull.Job<InboxJobData>): Promise<string> => {
|
||||
const signature = job.data.signature; // HTTP-signature
|
||||
const activity = job.data.activity;
|
||||
const resolver = new Resolver();
|
||||
|
||||
//#region Log
|
||||
const info = Object.assign({}, activity) as any;
|
||||
|
@ -29,46 +29,12 @@ export default async (job: Bull.Job<InboxJobData>): Promise<string> => {
|
|||
logger.debug(JSON.stringify(info, null, 2));
|
||||
//#endregion
|
||||
|
||||
const keyIdLower = signature.keyId.toLowerCase();
|
||||
if (keyIdLower.startsWith('acct:')) {
|
||||
return `Old keyId is no longer supported. ${keyIdLower}`;
|
||||
}
|
||||
|
||||
const host = toPuny(new URL(keyIdLower).hostname);
|
||||
|
||||
// Stop if the host is blocked.
|
||||
if (await shouldBlockInstance(host)) {
|
||||
return `Blocked request: ${host}`;
|
||||
}
|
||||
|
||||
const resolver = new Resolver();
|
||||
|
||||
let authUser;
|
||||
try {
|
||||
authUser = await getAuthUser(signature.keyId, getApId(activity.actor), resolver);
|
||||
} catch (e) {
|
||||
if (e instanceof StatusError) {
|
||||
if (e.isClientError) {
|
||||
return `skip: Ignored deleted actors on both ends ${activity.actor} - ${e.statusCode}`;
|
||||
} else {
|
||||
throw new Error(`Error in actor ${activity.actor} - ${e.statusCode || e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (authUser == null) {
|
||||
// Key not found? Unacceptable!
|
||||
return 'skip: failed to resolve user';
|
||||
} else {
|
||||
// Found key!
|
||||
}
|
||||
|
||||
// verify the HTTP Signature
|
||||
const httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem);
|
||||
const validated = await verifyHttpSignature(signature, resolver, getApId(activity.actor));
|
||||
let authUser = validated.authUser;
|
||||
|
||||
// The signature must be valid.
|
||||
// The signature must also match the actor otherwise anyone could sign any activity.
|
||||
if (!httpSignatureValidated || authUser.user.uri !== activity.actor) {
|
||||
if (validated.status !== 'valid' || validated.authUser.user.uri !== activity.actor) {
|
||||
// Last resort: LD-Signature
|
||||
if (activity.signature) {
|
||||
if (activity.signature.type !== 'RsaSignature2017') {
|
||||
|
@ -98,7 +64,7 @@ export default async (job: Bull.Job<InboxJobData>): Promise<string> => {
|
|||
}
|
||||
|
||||
// Stop if the host is blocked.
|
||||
const ldHost = extractDbHost(authUser.user.uri);
|
||||
const ldHost = extractPunyHost(authUser.user.uri);
|
||||
if (await shouldBlockInstance(ldHost)) {
|
||||
return `Blocked request: ${ldHost}`;
|
||||
}
|
||||
|
@ -107,15 +73,20 @@ export default async (job: Bull.Job<InboxJobData>): Promise<string> => {
|
|||
}
|
||||
}
|
||||
|
||||
// authUser cannot be null at this point:
|
||||
// either it was already not null because the HTTP signature was valid
|
||||
// or, if the LD signature was not verified, this function will already have returned.
|
||||
authUser = authUser as AuthUser;
|
||||
|
||||
// Verify that the actor's host is not blocked
|
||||
const signerHost = extractDbHost(authUser.user.uri!);
|
||||
const signerHost = extractPunyHost(authUser.user.uri!);
|
||||
if (await shouldBlockInstance(signerHost)) {
|
||||
return `Blocked request: ${signerHost}`;
|
||||
}
|
||||
|
||||
if (typeof activity.id === 'string') {
|
||||
// Verify that activity and actor are from the same host.
|
||||
const activityIdHost = extractDbHost(activity.id);
|
||||
const activityIdHost = extractPunyHost(activity.id);
|
||||
if (signerHost !== activityIdHost) {
|
||||
return `skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import post from '@/services/note/create.js';
|
||||
import { IRemoteUser } from '@/models/entities/user.js';
|
||||
import { extractDbHost } from '@/misc/convert-host.js';
|
||||
import { extractPunyHost } from '@/misc/convert-host.js';
|
||||
import { getApLock } from '@/misc/app-lock.js';
|
||||
import { StatusError } from '@/misc/fetch.js';
|
||||
import { Notes } from '@/models/index.js';
|
||||
|
@ -15,7 +15,7 @@ export default async function(resolver: Resolver, actor: IRemoteUser, activity:
|
|||
const uri = getApId(activity);
|
||||
|
||||
// Cancel if the announced from host is blocked.
|
||||
if (await shouldBlockInstance(extractDbHost(uri))) return;
|
||||
if (await shouldBlockInstance(extractPunyHost(uri))) return;
|
||||
|
||||
const unlock = await getApLock(uri);
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { IRemoteUser } from '@/models/entities/user.js';
|
||||
import { getApLock } from '@/misc/app-lock.js';
|
||||
import { extractDbHost } from '@/misc/convert-host.js';
|
||||
import { extractPunyHost } from '@/misc/convert-host.js';
|
||||
import { StatusError } from '@/misc/fetch.js';
|
||||
import { Resolver } from '@/remote/activitypub/resolver.js';
|
||||
import { createNote, fetchNote } from '@/remote/activitypub/models/note.js';
|
||||
|
@ -18,7 +18,7 @@ export default async function(resolver: Resolver, actor: IRemoteUser, note: IObj
|
|||
}
|
||||
|
||||
if (typeof note.id === 'string') {
|
||||
if (extractDbHost(actor.uri) !== extractDbHost(note.id)) {
|
||||
if (extractPunyHost(actor.uri) !== extractPunyHost(note.id)) {
|
||||
return 'skip: host in actor.uri !== note.id';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { IRemoteUser } from '@/models/entities/user.js';
|
||||
import { toArray } from '@/prelude/array.js';
|
||||
import { Resolver } from '@/remote/activitypub/resolver.js';
|
||||
import { extractDbHost } from '@/misc/convert-host.js';
|
||||
import { extractPunyHost } from '@/misc/convert-host.js';
|
||||
import { shouldBlockInstance } from '@/misc/should-block-instance.js';
|
||||
import { apLogger } from '../logger.js';
|
||||
import { IObject, isCreate, isDelete, isUpdate, isRead, isFollow, isAccept, isReject, isAdd, isRemove, isAnnounce, isLike, isUndo, isBlock, isCollectionOrOrderedCollection, isCollection, isFlag, isMove, getApId } from '../type.js';
|
||||
|
@ -42,7 +42,7 @@ async function performOneActivity(actor: IRemoteUser, activity: IObject, resolve
|
|||
if (actor.isSuspended) return;
|
||||
|
||||
if (typeof activity.id !== 'undefined') {
|
||||
const host = extractDbHost(getApId(activity));
|
||||
const host = extractPunyHost(getApId(activity));
|
||||
if (await shouldBlockInstance(host)) return;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { IRemoteUser } from '@/models/entities/user.js';
|
||||
import { isSelfHost, extractDbHost } from '@/misc/convert-host.js';
|
||||
import { isSelfHost, extractPunyHost } from '@/misc/convert-host.js';
|
||||
import { MessagingMessages } from '@/models/index.js';
|
||||
import { readUserMessagingMessage } from '@/server/api/common/read-messaging-message.js';
|
||||
import { IRead, getApId } from '../type.js';
|
||||
|
@ -7,7 +7,7 @@ import { IRead, getApId } from '../type.js';
|
|||
export const performReadActivity = async (actor: IRemoteUser, activity: IRead): Promise<string> => {
|
||||
const id = await getApId(activity.object);
|
||||
|
||||
if (!isSelfHost(extractDbHost(id))) {
|
||||
if (!isSelfHost(extractPunyHost(id))) {
|
||||
return `skip: Read to foreign host (${id})`;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { IRemoteUser } from '@/models/entities/user.js';
|
||||
import { getApId, getApType, IUpdate, isActor } from '@/remote/activitypub/type.js';
|
||||
import { getApId, getOneApId, getApType, IUpdate, isActor, isPost } from '@/remote/activitypub/type.js';
|
||||
import { apLogger } from '@/remote/activitypub/logger.js';
|
||||
import { updateQuestion } from '@/remote/activitypub/models/question.js';
|
||||
import { Resolver } from '@/remote/activitypub/resolver.js';
|
||||
import { updatePerson } from '@/remote/activitypub/models/person.js';
|
||||
import { update as updateNote } from '@/remote/activitypub/kernel/update/note.js';
|
||||
|
||||
/**
|
||||
* Updateアクティビティを捌きます
|
||||
|
@ -30,6 +31,8 @@ export default async (actor: IRemoteUser, activity: IUpdate, resolver: Resolver)
|
|||
} else if (getApType(object) === 'Question') {
|
||||
await updateQuestion(object, resolver).catch(e => console.log(e));
|
||||
return 'ok: Question updated';
|
||||
} else if (isPost(object)) {
|
||||
return await updateNote(actor, object, resolver);
|
||||
} else {
|
||||
return `skip: Unknown type: ${getApType(object)}`;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
import { IRemoteUser } from '@/models/entities/user.js';
|
||||
import { getApId } from '@/remote/activitypub/type.js';
|
||||
import { Resolver } from '@/remote/activitypub/resolver.js';
|
||||
import { Notes } from '@/models/index.js';
|
||||
import createNote from '@/remote/activitypub/kernel/create/note.js';
|
||||
import { getApLock } from '@/misc/app-lock.js';
|
||||
import { updateNote } from '@/remote/activitypub/models/note.js';
|
||||
|
||||
export async function update(actor: IRemoteUser, note: IObject, resolver: Resolver): Promise<string> {
|
||||
// check whether note exists
|
||||
const uri = getApId(note);
|
||||
const exists = await Notes.findOneBy({ uri });
|
||||
|
||||
if (exists == null) {
|
||||
// does not yet exist, handle as if this was a create activity
|
||||
// and since this is not a direct creation, handle it silently
|
||||
createNote(resolver, actor, note, true);
|
||||
|
||||
const unlock = await getApLock(uri);
|
||||
try {
|
||||
// if creating was successful...
|
||||
const existsNow = await Notes.findOneByOrFail({ uri });
|
||||
// set the updatedAt timestamp since the note was changed
|
||||
await Notes.update(existsNow.id, { updatedAt: new Date() });
|
||||
return 'ok: unknown note created and marked as updated';
|
||||
} catch (e) {
|
||||
return `skip: updated note unknown and creating rejected: ${e.message}`;
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
} else {
|
||||
// check that actor is authorized to update this note
|
||||
if (actor.id !== exists.userId) {
|
||||
return 'skip: actor not authorized to update Note';
|
||||
}
|
||||
// this does not redo the checks from the Create Note kernel
|
||||
// since if the note made it into the database, we assume
|
||||
// those checks must have been passed before.
|
||||
|
||||
const unlock = await getApLock(uri);
|
||||
try {
|
||||
await updateNote(note, actor, resolver);
|
||||
return 'ok: note updated';
|
||||
} catch (e) {
|
||||
return `skip: update note rejected: ${e.message}`;
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ import { IRemoteUser } from '@/models/entities/user.js';
|
|||
import { UserPublickey } from '@/models/entities/user-publickey.js';
|
||||
import { uriPersonCache, userByIdCache } from '@/services/user-cache.js';
|
||||
import { createPerson } from '@/remote/activitypub/models/person.js';
|
||||
import { Resolver } from '@/remote/activitypub/resolver.js';
|
||||
|
||||
export type AuthUser = {
|
||||
user: IRemoteUser;
|
||||
|
@ -29,8 +30,8 @@ function authUserFromApId(uri: string): Promise<AuthUser | null> {
|
|||
});
|
||||
}
|
||||
|
||||
export async function getAuthUser(keyId: string, actorUri: string, resolver: Resolver): Promise<AuthUser | null> {
|
||||
let authUser = await publicKeyCache.fetch(keyId)
|
||||
export async function authUserFromKeyId(keyId: string): Promise<AuthUser | null> {
|
||||
return await publicKeyCache.fetch(keyId)
|
||||
.then(async key => {
|
||||
if (!key) return null;
|
||||
else return {
|
||||
|
@ -38,6 +39,10 @@ export async function getAuthUser(keyId: string, actorUri: string, resolver: Res
|
|||
key,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAuthUser(keyId: string, actorUri: string, resolver: Resolver): Promise<AuthUser | null> {
|
||||
let authUser = await authUserFromKeyId(keyId);
|
||||
if (authUser != null) return authUser;
|
||||
|
||||
authUser = await authUserFromApId(actorUri);
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import promiseLimit from 'promise-limit';
|
||||
|
||||
import * as foundkey from 'foundkey-js';
|
||||
import config from '@/config/index.js';
|
||||
import post from '@/services/note/create.js';
|
||||
import { IRemoteUser } from '@/models/entities/user.js';
|
||||
import { User, IRemoteUser } from '@/models/entities/user.js';
|
||||
import { unique, toArray, toSingle } from '@/prelude/array.js';
|
||||
import { vote } from '@/services/note/polls/vote.js';
|
||||
import { DriveFile } from '@/models/entities/drive-file.js';
|
||||
import { deliverQuestionUpdate } from '@/services/note/polls/update.js';
|
||||
import { extractDbHost } from '@/misc/convert-host.js';
|
||||
import { Polls, MessagingMessages } from '@/models/index.js';
|
||||
import { extractPunyHost } from '@/misc/convert-host.js';
|
||||
import { Polls, MessagingMessages, Notes } from '@/models/index.js';
|
||||
import { Note } from '@/models/entities/note.js';
|
||||
import { Emoji } from '@/models/entities/emoji.js';
|
||||
import { genId } from '@/misc/gen-id.js';
|
||||
|
@ -27,6 +27,8 @@ import { resolveImage } from './image.js';
|
|||
import { extractApHashtags, extractQuoteUrl, extractEmojis } from './tag.js';
|
||||
import { extractPollFromQuestion } from './question.js';
|
||||
import { extractApMentions } from './mention.js';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import { sideEffects } from '@/services/note/side-effects.js';
|
||||
|
||||
export function validateNote(object: IObject): Error | null {
|
||||
if (object == null) {
|
||||
|
@ -45,9 +47,9 @@ export function validateNote(object: IObject): Error | null {
|
|||
}
|
||||
|
||||
// Check that the server is authorized to act on behalf of this author.
|
||||
const expectHost = extractDbHost(id);
|
||||
const expectHost = extractPunyHost(id);
|
||||
const attributedToHost = object.attributedTo
|
||||
? extractDbHost(getOneApId(object.attributedTo))
|
||||
? extractPunyHost(getOneApId(object.attributedTo))
|
||||
: null;
|
||||
if (attributedToHost !== expectHost) {
|
||||
return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${attributedToHost}`);
|
||||
|
@ -93,7 +95,7 @@ async function processContent(actor: IRemoteUser, note: IPost, quoteUri: string
|
|||
text = fromHtml(note.content, quoteUri);
|
||||
}
|
||||
|
||||
const emojis = await extractEmojis(note.tag || [], extractDbHost(getApId(note))).catch(e => {
|
||||
const emojis = await extractEmojis(note.tag || [], extractPunyHost(getApId(note))).catch(e => {
|
||||
apLogger.info(`extractEmojis: ${e}`);
|
||||
return [] as Emoji[];
|
||||
});
|
||||
|
@ -299,7 +301,7 @@ export async function resolveNote(value: string | IObject, resolver: Resolver):
|
|||
if (uri == null) throw new Error('missing uri');
|
||||
|
||||
// Interrupt if blocked.
|
||||
if (await shouldBlockInstance(extractDbHost(uri))) throw new StatusError('host blocked', 451, `host ${extractDbHost(uri)} is blocked`);
|
||||
if (await shouldBlockInstance(extractPunyHost(uri))) throw new StatusError('host blocked', 451, `host ${extractPunyHost(uri)} is blocked`);
|
||||
|
||||
const unlock = await getApLock(uri);
|
||||
|
||||
|
@ -324,3 +326,45 @@ export async function resolveNote(value: string | IObject, resolver: Resolver):
|
|||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a note.
|
||||
*
|
||||
* If the target Note is not registered, it will be ignored.
|
||||
*/
|
||||
export async function updateNote(value: IPost, actor: User, resolver: Resolver): Promise<Note | null> {
|
||||
const err = validateNote(value);
|
||||
if (err) {
|
||||
apLogger.error(`${err.message}`);
|
||||
throw new Error('invalid updated note');
|
||||
}
|
||||
|
||||
const uri = getApId(value);
|
||||
const exists = await Notes.findOneBy({ uri });
|
||||
if (exists == null) return null;
|
||||
|
||||
let quoteUri = null;
|
||||
if (exists.renoteId && !foundkey.entities.isPureRenote(exists)) {
|
||||
const quote = await Notes.findOneBy({ id: exists.renoteId });
|
||||
quoteUri = quote.uri;
|
||||
}
|
||||
|
||||
// process content and update attached files (e.g. also image descriptions)
|
||||
const processedContent = await processContent(actor, value, quoteUri, resolver);
|
||||
|
||||
// update note content itself
|
||||
await Notes.update(exists.id, {
|
||||
updatedAt: new Date(),
|
||||
|
||||
cw: processedContent.cw,
|
||||
fileIds: processedContent.files.map(file => file.id),
|
||||
attachedFileTypes: processedContent.files.map(file => file.type),
|
||||
text: processedContent.text,
|
||||
emojis: processedContent.apEmoji,
|
||||
tags: processedContent.apHashtags.map(tag => normalizeForSearch(tag)),
|
||||
url: processedContent.url,
|
||||
name: processedContent.name,
|
||||
});
|
||||
|
||||
await sideEffects(actor, await Notes.findOneByOrFail({ id: exists.id }), false, false);
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ import { genId } from '@/misc/gen-id.js';
|
|||
import { instanceChart, usersChart } from '@/services/chart/index.js';
|
||||
import { UserPublickey } from '@/models/entities/user-publickey.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import { extractDbHost } from '@/misc/convert-host.js';
|
||||
import { extractPunyHost } from '@/misc/convert-host.js';
|
||||
import { UserProfile } from '@/models/entities/user-profile.js';
|
||||
import { toArray } from '@/prelude/array.js';
|
||||
import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js';
|
||||
|
@ -57,7 +57,7 @@ async function validateActor(x: IObject, resolver: Resolver): Promise<IActor> {
|
|||
|
||||
// This check is security critical.
|
||||
// Without this check, an entry could be inserted into UserPublickey for a local user.
|
||||
if (extractDbHost(uri) === extractDbHost(config.url)) {
|
||||
if (extractPunyHost(uri) === extractPunyHost(config.url)) {
|
||||
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
|
||||
}
|
||||
|
||||
|
@ -108,7 +108,7 @@ async function validateActor(x: IObject, resolver: Resolver): Promise<IActor> {
|
|||
|
||||
// This is a security critical check to not insert or change an entry of
|
||||
// UserPublickey to point to a local key id.
|
||||
if (extractDbHost(uri) !== extractDbHost(x.publicKey.id)) {
|
||||
if (extractPunyHost(uri) !== extractPunyHost(x.publicKey.id)) {
|
||||
throw new Error('invalid Actor: publicKey.id has different host');
|
||||
}
|
||||
}
|
||||
|
@ -157,7 +157,7 @@ export async function createPerson(value: string | IObject, resolver: Resolver):
|
|||
|
||||
apLogger.info(`Creating the Person: ${person.id}`);
|
||||
|
||||
const host = extractDbHost(object.id);
|
||||
const host = extractPunyHost(object.id);
|
||||
|
||||
const { fields } = analyzeAttachments(person.attachment || []);
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { ILocalUser } from '@/models/entities/user.js';
|
||||
import { getInstanceActor } from '@/services/instance-actor.js';
|
||||
import { extractDbHost, isSelfHost } from '@/misc/convert-host.js';
|
||||
import { extractPunyHost, isSelfHost } from '@/misc/convert-host.js';
|
||||
import { Notes, NoteReactions, Polls, Users } from '@/models/index.js';
|
||||
import renderNote from '@/remote/activitypub/renderer/note.js';
|
||||
import { renderLike } from '@/remote/activitypub/renderer/like.js';
|
||||
|
@ -50,7 +50,7 @@ export class Resolver {
|
|||
|
||||
if (typeof value !== 'string') {
|
||||
if (typeof value.id !== 'undefined') {
|
||||
const host = extractDbHost(getApId(value));
|
||||
const host = extractPunyHost(getApId(value));
|
||||
if (await shouldBlockInstance(host)) {
|
||||
throw new Error('instance is blocked');
|
||||
}
|
||||
|
@ -73,7 +73,7 @@ export class Resolver {
|
|||
}
|
||||
this.history.add(value);
|
||||
|
||||
const host = extractDbHost(value);
|
||||
const host = extractPunyHost(value);
|
||||
if (isSelfHost(host)) {
|
||||
return await this.resolveLocal(value);
|
||||
}
|
||||
|
|
95
packages/backend/src/remote/http-signature.ts
Normal file
95
packages/backend/src/remote/http-signature.ts
Normal file
|
@ -0,0 +1,95 @@
|
|||
import { URL } from 'node:url';
|
||||
import { extractPunyHost } from "@/misc/convert-host.js";
|
||||
import { shouldBlockInstance } from "@/misc/should-block-instance.js";
|
||||
import httpSignature from "@peertube/http-signature";
|
||||
import { Resolver } from "./activitypub/resolver.js";
|
||||
import { StatusError } from "@/misc/fetch.js";
|
||||
import { AuthUser, authUserFromKeyId, getAuthUser } from "./activitypub/misc/auth-user.js";
|
||||
import { ApObject, getApId, isActor } from "./activitypub/type.js";
|
||||
import { createPerson } from "./activitypub/models/person.js";
|
||||
|
||||
async function resolveKeyId(keyId: string, resolver: Resolver): Promise<AuthUser | null> {
|
||||
// Do we already know that keyId?
|
||||
const authUser = await authUserFromKeyId(keyId);
|
||||
if (authUser != null) return authUser;
|
||||
|
||||
// If not, discover it.
|
||||
const keyUrl = new URL(keyId);
|
||||
keyUrl.hash = ''; // Fragment should not be part of the request.
|
||||
|
||||
const keyObject = await resolver.resolve(keyUrl.toString());
|
||||
|
||||
// Does the keyId end up resolving to an Actor?
|
||||
if (isActor(keyObject)) {
|
||||
await createPerson(keyObject, resolver);
|
||||
return await getAuthUser(keyId, getApId(keyObject), resolver);
|
||||
}
|
||||
|
||||
// Does the keyId end up resolving to a Key-like?
|
||||
const keyData = keyObject as any;
|
||||
if (keyData.owner != null && keyData.publicKeyPem != null) {
|
||||
await createPerson(keyData.owner, resolver);
|
||||
return await getAuthUser(keyId, getApId(keyData.owner), resolver);
|
||||
}
|
||||
|
||||
// Cannot be resolved.
|
||||
return null;
|
||||
}
|
||||
|
||||
export type SignatureValidationResult = {
|
||||
status: 'missing' | 'invalid' | 'rejected';
|
||||
authUser: AuthUser | null;
|
||||
} | {
|
||||
status: 'valid';
|
||||
authUser: AuthUser;
|
||||
};
|
||||
|
||||
export async function verifyHttpSignature(signature: httpSignature.IParsedSignature, resolver: Resolver, actor?: ApObject): Promise<SignatureValidationResult> {
|
||||
// This old `keyId` format is no longer supported.
|
||||
const keyIdLower = signature.keyId.toLowerCase();
|
||||
if (keyIdLower.startsWith('acct:')) return { status: 'invalid', authUser: null };
|
||||
|
||||
const host = extractPunyHost(keyIdLower);
|
||||
|
||||
// Reject if the host is blocked.
|
||||
if (await shouldBlockInstance(host)) return { status: 'rejected', authUser: null };
|
||||
|
||||
let authUser = null;
|
||||
try {
|
||||
if (actor != null) {
|
||||
authUser = await getAuthUser(signature.keyId, getApId(actor), resolver);
|
||||
} else {
|
||||
authUser = await resolveKeyId(signature.keyId, resolver);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof StatusError) {
|
||||
if (e.isClientError) {
|
||||
// Actor is deleted.
|
||||
return { status: 'rejected', authUser };
|
||||
} else {
|
||||
throw new Error(`Error in signature ${signature} - ${e.statusCode || e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (authUser == null) {
|
||||
// Key not found? Unacceptable!
|
||||
return { status: 'invalid', authUser };
|
||||
} else {
|
||||
// Found key!
|
||||
}
|
||||
|
||||
// Make sure the resolved user matches the keyId host.
|
||||
if (authUser.user.host !== host) return { status: 'rejected', authUser };
|
||||
|
||||
// Verify the HTTP Signature
|
||||
const httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem);
|
||||
if (httpSignatureValidated === true)
|
||||
return {
|
||||
status: 'valid',
|
||||
authUser,
|
||||
};
|
||||
|
||||
// Otherwise, fail.
|
||||
return { status: 'invalid', authUser };
|
||||
}
|
|
@ -20,6 +20,11 @@ import Outbox from './activitypub/outbox.js';
|
|||
import Followers from './activitypub/followers.js';
|
||||
import Following from './activitypub/following.js';
|
||||
import Featured from './activitypub/featured.js';
|
||||
import { isInstanceActor } from '@/services/instance-actor.js';
|
||||
import { getUser } from './api/common/getters.js';
|
||||
import config from '@/config/index.js';
|
||||
import { verifyHttpSignature } from '@/remote/http-signature.js';
|
||||
import { Resolver } from '@/remote/activitypub/resolver.js';
|
||||
|
||||
// Init router
|
||||
const router = new Router();
|
||||
|
@ -59,6 +64,36 @@ export function setResponseType(ctx: Router.RouterContext): void {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleSignature(ctx: Router.RouterContext): Promise<boolean> {
|
||||
if (config.allowUnsignedFetches) {
|
||||
// Fetch signature verification is disabled.
|
||||
ctx.set('Cache-Control', 'public, max-age=180');
|
||||
return true;
|
||||
} else {
|
||||
let verified;
|
||||
try {
|
||||
let signature = httpSignature.parseRequest(ctx.req);
|
||||
verified = await verifyHttpSignature(signature, new Resolver());
|
||||
} catch (e) {
|
||||
verified = { status: 'missing' };
|
||||
}
|
||||
|
||||
switch (verified.status) {
|
||||
// Fetch signature verification succeeded.
|
||||
case 'valid':
|
||||
ctx.set('Cache-Control', 'no-store');
|
||||
return true;
|
||||
case 'missing':
|
||||
case 'invalid':
|
||||
case 'rejected':
|
||||
default:
|
||||
ctx.status = 403;
|
||||
ctx.set('Cache-Control', 'no-store');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// inbox
|
||||
router.post('/inbox', json(), inbox);
|
||||
router.post('/users/:user/inbox', json(), inbox);
|
||||
|
@ -66,6 +101,7 @@ router.post('/users/:user/inbox', json(), inbox);
|
|||
// note
|
||||
router.get('/notes/:note', async (ctx, next) => {
|
||||
if (!isActivityPubReq(ctx)) return await next();
|
||||
if (!(await handleSignature(ctx))) return;
|
||||
|
||||
const note = await Notes.findOneBy({
|
||||
id: ctx.params.note,
|
||||
|
@ -89,7 +125,6 @@ router.get('/notes/:note', async (ctx, next) => {
|
|||
}
|
||||
|
||||
ctx.body = renderActivity(await renderNote(note, false));
|
||||
ctx.set('Cache-Control', 'public, max-age=180');
|
||||
setResponseType(ctx);
|
||||
});
|
||||
|
||||
|
@ -103,6 +138,7 @@ router.get('/notes/:note/activity', async ctx => {
|
|||
ctx.redirect(`/notes/${ctx.params.note}`);
|
||||
return;
|
||||
}
|
||||
if (!(await handleSignature(ctx))) return;
|
||||
|
||||
const note = await Notes.findOneBy({
|
||||
id: ctx.params.note,
|
||||
|
@ -117,23 +153,32 @@ router.get('/notes/:note/activity', async ctx => {
|
|||
}
|
||||
|
||||
ctx.body = renderActivity(await renderNoteOrRenoteActivity(note));
|
||||
ctx.set('Cache-Control', 'public, max-age=180');
|
||||
setResponseType(ctx);
|
||||
});
|
||||
|
||||
async function requireHttpSignature(ctx: Router.Context, next: () => Promise<void>) {
|
||||
if (!(await handleSignature(ctx))) {
|
||||
return;
|
||||
} else {
|
||||
await next();
|
||||
}
|
||||
}
|
||||
|
||||
// outbox
|
||||
router.get('/users/:user/outbox', Outbox);
|
||||
router.get('/users/:user/outbox', requireHttpSignature, Outbox);
|
||||
|
||||
// followers
|
||||
router.get('/users/:user/followers', Followers);
|
||||
router.get('/users/:user/followers', requireHttpSignature, Followers);
|
||||
|
||||
// following
|
||||
router.get('/users/:user/following', Following);
|
||||
router.get('/users/:user/following', requireHttpSignature, Following);
|
||||
|
||||
// featured
|
||||
router.get('/users/:user/collections/featured', Featured);
|
||||
router.get('/users/:user/collections/featured', requireHttpSignature, Featured);
|
||||
|
||||
// publickey
|
||||
// This does not require HTTP signatures in order for other instances
|
||||
// to be able to verify our own signatures.
|
||||
router.get('/users/:user/publickey', async ctx => {
|
||||
const userId = ctx.params.user;
|
||||
|
||||
|
@ -166,7 +211,6 @@ async function userInfo(ctx: Router.RouterContext, user: User | null): Promise<v
|
|||
}
|
||||
|
||||
ctx.body = renderActivity(await renderPerson(user as ILocalUser));
|
||||
ctx.set('Cache-Control', 'public, max-age=180');
|
||||
setResponseType(ctx);
|
||||
}
|
||||
|
||||
|
@ -181,11 +225,26 @@ router.get('/users/:user', async (ctx, next) => {
|
|||
isSuspended: false,
|
||||
});
|
||||
|
||||
// Allow fetching the instance actor without any HTTP signature.
|
||||
// Only on this route, as it is the canonical route.
|
||||
// If the user could not be resolved, or is not the instance actor,
|
||||
// validate and enforce signatures.
|
||||
if (user == null || !isInstanceActor(user))
|
||||
{
|
||||
if (!(await handleSignature(ctx))) return;
|
||||
}
|
||||
else if (isInstanceActor(user))
|
||||
{
|
||||
// Set cache at all times for instance actors.
|
||||
ctx.set('Cache-Control', 'public, max-age=180');
|
||||
}
|
||||
|
||||
await userInfo(ctx, user);
|
||||
});
|
||||
|
||||
router.get('/@:user', async (ctx, next) => {
|
||||
if (!isActivityPubReq(ctx)) return await next();
|
||||
if (!(await handleSignature(ctx))) return;
|
||||
|
||||
const user = await Users.findOneBy({
|
||||
usernameLower: ctx.params.user.toLowerCase(),
|
||||
|
@ -198,6 +257,9 @@ router.get('/@:user', async (ctx, next) => {
|
|||
|
||||
// emoji
|
||||
router.get('/emojis/:emoji', async ctx => {
|
||||
// Enforcing HTTP signatures on Emoji objects could cause problems for
|
||||
// other software that might use those objects for copying custom emoji.
|
||||
|
||||
const emoji = await Emojis.findOneBy({
|
||||
host: IsNull(),
|
||||
name: ctx.params.emoji,
|
||||
|
@ -215,6 +277,7 @@ router.get('/emojis/:emoji', async ctx => {
|
|||
|
||||
// like
|
||||
router.get('/likes/:like', async ctx => {
|
||||
if (!(await handleSignature(ctx))) return;
|
||||
const reaction = await NoteReactions.findOneBy({ id: ctx.params.like });
|
||||
|
||||
if (reaction == null) {
|
||||
|
@ -233,12 +296,12 @@ router.get('/likes/:like', async ctx => {
|
|||
}
|
||||
|
||||
ctx.body = renderActivity(await renderLike(reaction, note));
|
||||
ctx.set('Cache-Control', 'public, max-age=180');
|
||||
setResponseType(ctx);
|
||||
});
|
||||
|
||||
// follow
|
||||
router.get('/follows/:follower/:followee', async ctx => {
|
||||
if (!(await handleSignature(ctx))) return;
|
||||
// This may be used before the follow is completed, so we do not
|
||||
// check if the following exists.
|
||||
|
||||
|
@ -259,7 +322,6 @@ router.get('/follows/:follower/:followee', async ctx => {
|
|||
}
|
||||
|
||||
ctx.body = renderActivity(renderFollow(follower, followee));
|
||||
ctx.set('Cache-Control', 'public, max-age=180');
|
||||
setResponseType(ctx);
|
||||
});
|
||||
|
||||
|
|
|
@ -36,6 +36,5 @@ export default async (ctx: Router.RouterContext) => {
|
|||
);
|
||||
|
||||
ctx.body = renderActivity(rendered);
|
||||
ctx.set('Cache-Control', 'public, max-age=180');
|
||||
setResponseType(ctx);
|
||||
};
|
||||
|
|
|
@ -82,7 +82,6 @@ export default async (ctx: Router.RouterContext) => {
|
|||
// index page
|
||||
const rendered = renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`);
|
||||
ctx.body = renderActivity(rendered);
|
||||
ctx.set('Cache-Control', 'public, max-age=180');
|
||||
setResponseType(ctx);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -82,7 +82,6 @@ export default async (ctx: Router.RouterContext) => {
|
|||
// index page
|
||||
const rendered = renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`);
|
||||