Merge tag 'v13.0.0-preview6' into snug.moe

This commit is contained in:
vib 2023-07-02 12:19:32 +03:00
commit 2cf288a9fb
94 changed files with 1457 additions and 1090 deletions

View file

@ -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

View file

@ -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>

View file

@ -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.

View file

@ -9,7 +9,7 @@ services:
- redis
# - es
ports:
- "3000:3000"
- "127.0.0.1:3000:3000"
networks:
- internal_network
- external_network

View file

@ -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"

View file

@ -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"

View file

@ -748,6 +748,7 @@ _ffVisibility:
followers: "フォロワーだけに公開"
private: "非公開"
nobody: 誰にも見せない (あなたにさえも)
_signup:
almostThere: "ほとんど完了です"
emailAddressInfo: "あなたが使っているメールアドレスを入力してください。メールアドレスが公開されることはありません。"

View file

@ -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"

View file

@ -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,

View file

@ -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"`);
}
}

View file

@ -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",

View file

@ -61,6 +61,7 @@ export function loadConfig(): Config {
proxyRemoteFiles: false,
maxFileSize: 262144000, // 250 MiB
maxNoteTextLength: 3000,
allowUnsignedFetches: false,
}, config);
mixin.version = meta.version;

View file

@ -68,6 +68,8 @@ export type Source = {
notFound?: string;
error?: string;
};
allowUnsignedFetches?: boolean;
};
/**

View file

@ -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);
}

View file

@ -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(),

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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();
}

View file

@ -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}`;
}

View file

@ -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);

View file

@ -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';
}
}

View file

@ -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;
}

View file

@ -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})`;
}

View file

@ -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)}`;
}

View file

@ -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();
}
}
}

View file

@ -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);

View file

@ -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);
}

View file

@ -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 || []);

View file

@ -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);
}

View 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 };
}

View file

@ -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);
});

View file

@ -36,6 +36,5 @@ export default async (ctx: Router.RouterContext) => {
);
ctx.body = renderActivity(rendered);
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
};

View file

@ -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);
}
};

View file

@ -82,7 +82,6 @@ export default async (ctx: Router.RouterContext) => {
// index page
const rendered = renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`);