Compare commits

...

40 commits

Author SHA1 Message Date
77358c8f4b
13.0.0-preview6 2023-07-02 11:13:19 +02:00
62cd1e7ed6 Translated using Weblate (German)
Currently translated at 100.0% (1206 of 1206 strings)

Co-authored-by: Johann <johann@qwertqwefsday.eu>
Translate-URL: http://translate.akkoma.dev/projects/foundkey/foundkey/de/
Translation: Foundkey/foundkey
2023-07-02 09:11:00 +00:00
796adc8599
trigger side effects after updating note 2023-07-02 10:06:54 +02:00
ff8b9b6651
refactor note creation side effects
Refactoring the side effects in this way should allow them to be
reused for updating notes as well.
2023-07-02 10:06:53 +02:00
c1268c04f8
server: add noteStream handling for updated notes 2023-07-02 10:06:53 +02:00
42b555e5e4
activitypub: handle incoming Update Note activities
Changelog: Added
2023-07-02 10:06:53 +02:00
5c3e7c132a
add function to update a note 2023-07-02 10:06:53 +02:00
76c8e6b11b
client: display note update time 2023-07-02 10:06:52 +02:00
ca24080596
add update timestamp & notification 2023-07-02 10:06:38 +02:00
a12debb7b6
server: replace unzipper with decompress
The unzipper package did not seem to work any more and was
mangling the meta.json file in its extracted form and potentially
other files which lead to the emoji import not working properly.

Changelog: Fixed
2023-07-02 00:09:45 +02:00
f760426142
fix internal download in emoji import
Changelog: Fixed
2023-07-02 00:09:35 +02:00
2f30af1812
server: fix instance actor creation
Because findBy returns an array which is always truthy, this would
mean the user is not actually created as requested and instead an
empty array is returned.
2023-06-29 21:21:26 +02:00
2d46cf7c1e
fixup! client: fix MFM overflow
Turning the MFM render container into a div changes it to display
as a block which messes up rendering in some places, e.g. when
it is used to render user names in "Renoted by".
2023-06-27 22:26:13 +02:00
2ea6daaf7a
rename extractDbHost to extractPunyHost 2023-06-27 22:02:32 +02:00
597de07465
server: refactor HTTP signature validation 2023-06-27 21:46:00 +02:00
9289b0e8ed
adjust config file example 2023-06-25 20:44:08 +02:00
b600efae0d
BREAKING: activitypub: validate fetch signatures
Enforces HTTP signatures on object fetches, and rejects fetches from blocked
instances. This should mean proper and full blocking of remote instances.

This is now default behavior, which makes it a breaking change. To disable
it (mostly for development purposes), the configuration item
`allowUnsignedFetches` can be set to true. It is not the default for
development environments as it is important to have as close as possible
behavior to real environments for ActivityPub development.

Co-authored-by: nullobsi <me@nullob.si>
Co-authored-by: Norm <normandy@biribiri.dev>
Changelog: Added
2023-06-25 20:42:14 +02:00
ecca5a164e
client: always forbid MFM overflow
Some MFM overlays UI components. This should remove any possibility that
rendered MFM escapes a Note's body.

Changelog: Changed
2023-06-25 18:41:08 +02:00
1125a623a7
Revert "client: fix MFM overflow"
This reverts commit f7904a240a.

Changelog: Fixed
2023-06-25 18:40:48 +02:00
51a319e8ca
use extractDbHost 2023-06-23 22:00:31 +02:00
f7904a240a
client: fix MFM overflow
closes FoundKeyGang/FoundKey#397

Changelog: Fixed
2023-06-21 23:25:16 +02:00
693dd3ad97
remove unused parameter from MFM component 2023-06-21 22:26:39 +02:00
777b981bf1
client: disable sound for received note by default
Lessen the sound pollution.

closes FoundKeyGang/FoundKey#394

Changelog: Changed
Co-authored-by: Jeder <jeder+git@jeder.pl>
2023-06-09 17:58:44 +02:00
bbd35054e6
change my name in mailmap 2023-06-07 16:20:52 -04:00
kazari
e9862d480c Translated using Weblate (Japanese)
Currently translated at 99.8% (1203 of 1205 strings)

Co-authored-by: kazari <6c577a54-aac9-482a-955e-745c858445e3@simplelogin.com>
Translate-URL: http://translate.akkoma.dev/projects/foundkey/foundkey/ja/
Translation: Foundkey/foundkey
2023-06-05 21:43:27 +00:00
cc0915775b
server: add webhook stat to nodeinfo
This will show the number of active web hooks in the node info.
This is desired to be able to gauge webhook usage in Foundkey.

Changelog: Added
2023-06-05 23:39:43 +02:00
f181a8805d
docs: remove bannerColor
This is a fixup for commit a673647fba.
2023-06-05 23:39:43 +02:00
ac482e6eec
fix lockfile 2023-06-01 23:24:11 +02:00
38786b6999
transform tests from ts to js
This allows to get rid of the special loader for ts files. There is
no need for the test files to be written in TypeScript, plain
JavaScript should be fine for this purpose.
2023-06-01 23:21:03 +02:00
680d1f1459
docker: only publish port on localhost
Changelog: Changed
2023-05-31 15:19:10 -04:00
a0f0bac1ca
make mutes case insensitive
closes FoundKeyGang/FoundKey#392

Changelog: Changed
2023-05-31 13:02:40 +02:00
af003fc0fe
try to fix test timeout (again) 2023-05-31 12:42:13 +02:00
94cd10365d
Revert "try to fix tests"
This reverts commit c53486a47c.
2023-05-31 12:31:57 +02:00
0addcddd6c
server: respect log level environment variable 2023-05-31 11:39:50 +02:00
64c4973eca
fixup: removing default exports
This is a fixup for commit 410c519953.
2023-05-30 21:14:42 +02:00
7272bde464
fix more variable issues in processContent 2023-05-30 20:58:18 +02:00
7c9e118ff1
refactor checkExpired to use Promise.all 2023-05-30 20:40:25 +02:00
bed8286175
fix missing variable in processContent 2023-05-30 20:25:55 +02:00
f00b3cc378
add missing import for emoji parsing 2023-05-30 19:53:17 +02:00
21ab8e75ee
activitypub: improve JSON-LD context
Added @type values for most definitions, and made name and value only
scoped to the PropertyValue type. (Blame Volpeon)
2023-05-30 17:57:03 +02:00
100 changed files with 1587 additions and 1188 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

@ -20,10 +20,11 @@ export async function checkWordMute(note: NoteLike, me: UserLike | null | undefi
const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim();
if (text === '') return false;
const textLower = text.toLowerCase();
const matched = mutedWords.some(filter => {
if (Array.isArray(filter)) {
return filter.every(keyword => text.includes(keyword));
return filter.every(keyword => textLower.includes(keyword.toLowerCase()));
} else {
// represents RegExp
const regexp = filter.match(/^\/(.+)\/(.*)$/);

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

@ -10,52 +10,49 @@ const logger = queueLogger.createSubLogger('check-expired');
export async function checkExpired(job: Bull.Job<Record<string, unknown>>, done: any): Promise<void> {
logger.info('Checking expired data...');
const expiredMutings = await Mutings.createQueryBuilder('muting')
.where('muting.expiresAt IS NOT NULL')
.andWhere('muting.expiresAt < :now', { now: new Date() })
.innerJoinAndSelect('muting.mutee', 'mutee')
.getMany();
if (expiredMutings.length > 0) {
await Mutings.delete({
id: In(expiredMutings.map(m => m.id)),
});
for (const m of expiredMutings) {
publishUserEvent(m.muterId, 'unmute', m.mutee!);
}
}
const OlderThan = (millis: number) => {
return LessThan(new Date(new Date().getTime() - millis));
};
await Signins.delete({
createdAt: OlderThan(2 * MONTH),
});
await Promise.all([
Mutings.createQueryBuilder('muting')
.where('muting.expiresAt IS NOT NULL')
.andWhere('muting.expiresAt < :now', { now: new Date() })
.innerJoinAndSelect('muting.mutee', 'mutee')
.getMany()
.then(async (expiredMutings) => {
if (expiredMutings.length > 0) {
await Mutings.delete({
id: In(expiredMutings.map(m => m.id)),
});
await AttestationChallenges.delete({
createdAt: OlderThan(5 * MINUTE),
});
await PasswordResetRequests.delete({
// this timing should be the same as in @/server/api/endpoints/reset-password.ts
createdAt: OlderThan(30 * MINUTE),
});
await AuthSessions.delete({
createdAt: OlderThan(15 * MINUTE),
});
await Notifications.delete({
isRead: true,
createdAt: OlderThan(3 * MONTH),
});
await Users.delete({
// delete users where the deletion status reference count has come down to zero
isDeleted: 0,
});
for (const m of expiredMutings) {
publishUserEvent(m.muterId, 'unmute', m.mutee!);
}
}
}),
Signins.delete({
createdAt: OlderThan(2 * MONTH),
}),
AttestationChallenges.delete({
createdAt: OlderThan(5 * MINUTE),
}),
PasswordResetRequests.delete({
// this timing should be the same as in @/server/api/endpoints/reset-password.ts
createdAt: OlderThan(30 * MINUTE),
}),
AuthSessions.delete({
createdAt: OlderThan(15 * MINUTE),
}),
Notifications.delete({
isRead: true,
createdAt: OlderThan(3 * MONTH),
}),
Users.delete({
// delete users where the deletion status reference count has come down to zero
isDeleted: 0,
}),
]);
logger.succ('Deleted expired data.');

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}`);
@ -62,7 +64,7 @@ export function validateNote(object: IObject): Error | null {
/**
* Function to process the content of a note, reusable in createNote and updateNote.
*/
async function processContent(note: IPost, quoteUri: string | null):
async function processContent(actor: IRemoteUser, note: IPost, quoteUri: string | null, resolver: Resolver):
Promise<{
cw: string | null,
files: DriveFile[],
@ -78,10 +80,10 @@ async function processContent(note: IPost, quoteUri: string | null):
const limit = promiseLimit(2);
const attachments = toArray(note.attachment);
const files = note.attachment
const files = attachments
// If the note is marked as sensitive, the images should be marked sensitive too.
.map(attach => attach.sensitive |= note.sensitive)
? (await Promise.all(note.attachment.map(x => limit(() => resolveImage(actor, x, resolver)) as Promise<DriveFile>)))
? (await Promise.all(attachments.map(x => limit(() => resolveImage(actor, x, resolver)) as Promise<DriveFile>)))
.filter(image => image != null)
: [];
@ -93,7 +95,7 @@ async function processContent(note: IPost, quoteUri: string | null):
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[];
});
@ -258,7 +260,7 @@ export async function createNote(value: string | IObject, resolver: Resolver, si
const poll = await extractPollFromQuestion(note, resolver).catch(() => undefined);
const processedContent = await processContent(note, quote?.uri);
const processedContent = await processContent(actor, note, quote?.uri, resolver);
if (isTalk) {
for (const recipient of visibleUsers) {
@ -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.