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`);
ctx.body = renderActivity(rendered);
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
}
};

View file

@ -90,7 +90,6 @@ export default async (ctx: Router.RouterContext) => {
`${partOf}?page=true&since_id=000000000000000000000000`,
);
ctx.body = renderActivity(rendered);
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
}
};

View file

@ -2,7 +2,7 @@ import { createPerson } from '@/remote/activitypub/models/person.js';
import { createNote } from '@/remote/activitypub/models/note.js';
import { DbResolver } from '@/remote/activitypub/db-resolver.js';
import { Resolver } from '@/remote/activitypub/resolver.js';
import { extractDbHost } from '@/misc/convert-host.js';
import { extractPunyHost } from '@/misc/convert-host.js';
import { Users, Notes } from '@/models/index.js';
import { Note } from '@/models/entities/note.js';
import { ILocalUser, User } from '@/models/entities/user.js';
@ -87,7 +87,7 @@ export default define(meta, paramDef, async (ps, me) => {
*/
async function fetchAny(uri: string, me: ILocalUser | null | undefined): Promise<SchemaType<typeof meta['res']> | null> {
// Stop if the host is blocked.
const host = extractDbHost(uri);
const host = extractPunyHost(uri);
if (await shouldBlockInstance(host)) {
return null;
}

View file

@ -3,7 +3,7 @@ import { WebSocket } from 'ws';
import { readNote } from '@/services/note/read.js';
import { User } from '@/models/entities/user.js';
import { Channel as ChannelModel } from '@/models/entities/channel.js';
import { Followings, Mutings, RenoteMutings, UserProfiles, ChannelFollowings, Blockings } from '@/models/index.js';
import { Followings, Mutings, Notes, RenoteMutings, UserProfiles, ChannelFollowings, Blockings } from '@/models/index.js';
import { AccessToken } from '@/models/entities/access-token.js';
import { UserProfile } from '@/models/entities/user-profile.js';
import { publishChannelStream, publishGroupMessagingStream, publishMessagingStream } from '@/services/stream.js';
@ -14,6 +14,7 @@ import { channels } from './channels/index.js';
import Channel from './channel.js';
import { StreamEventEmitter, StreamMessages } from './types.js';
import Logger from '@/services/logger.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
const logger = new Logger('streaming');
@ -254,11 +255,43 @@ export class Connection {
}
private async onNoteStreamMessage(data: StreamMessages['note']['payload']) {
this.sendMessageToWs('noteUpdated', {
id: data.body.id,
type: data.type,
body: data.body.body,
});
if (data.type === 'updated') {
const note = data.body.body.note;
// FIXME analogous to Channel.withPackedNote, but for some reason, the note
// stream is not handled as a channel but instead handled at the top level
// so this code is duplicated here I guess.
try {
// because `note` was previously JSON.stringify'ed, the fields that
// were objects before are now strings and have to be restored or
// removed from the object
note.createdAt = new Date(note.createdAt);
note.reply = null;
note.renote = null;
note.user = null;
note.channel = null;
const packed = await Notes.pack(note, this.user, { detail: true });
this.sendMessageToWs('noteUpdated', {
id: data.body.id,
type: 'updated',
body: { note: packed },
});
} catch (err) {
if (err instanceof IdentifiableError && err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') {
// skip: note not visible to user
return;
} else {
logger.error(err);
}
}
} else {
this.sendMessageToWs('noteUpdated', {
id: data.body.id,
type: data.type,
body: data.body.body,
});
}
}
/**

View file

@ -128,6 +128,9 @@ export interface NoteStreamTypes {
reaction: string;
userId: User['id'];
};
updated: {
note: Note;
};
}
type NoteStreamEventTypes = {
[key in keyof NoteStreamTypes]: {

View file

@ -2,7 +2,7 @@ import Router from '@koa/router';
import { IsNull, MoreThan } from 'typeorm';
import config from '@/config/index.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { Users, Notes } from '@/models/index.js';
import { Users, Notes, Webhooks } from '@/models/index.js';
import { MONTH, DAY } from '@/const.js';
const router = new Router();
@ -52,12 +52,14 @@ const nodeinfo2 = async (): Promise<NodeInfo2Base> => {
activeHalfyear,
activeMonth,
localPosts,
activeWebhooks,
] = await Promise.all([
fetchMeta(true),
Users.count({ where: { host: IsNull() } }),
Users.count({ where: { host: IsNull(), lastActiveDate: MoreThan(new Date(now - 180 * DAY)) } }),
Users.count({ where: { host: IsNull(), lastActiveDate: MoreThan(new Date(now - MONTH)) } }),
Notes.count({ where: { userHost: IsNull() } }),
Webhooks.countBy({ active: true }),
]);
const proxyAccount = meta.proxyAccountId ? await Users.pack(meta.proxyAccountId).catch(() => null) : null;
@ -100,6 +102,7 @@ const nodeinfo2 = async (): Promise<NodeInfo2Base> => {
enableEmail: meta.enableEmail,
proxyAccountName: proxyAccount?.username ?? null,
themeColor: meta.themeColor || '#86b300',
activeWebhooks,
},
};
};

View file

@ -0,0 +1,14 @@
import * as fs from 'node:fs';
import { DriveFiles } from '@/models/index.js';
import { DriveFile } from '@/models/entities/drive-file.js';
import { InternalStorage } from './internal-storage.js';
import { downloadUrl } from '@/misc/download-url.js';
export async function copyFileTo(file: DriveFile, toPath: string): Promise<void> {
if (file.storedInternal) {
const fromPath = InternalStorage.resolvePath(file.accessKey);
fs.copyFileSync(fromPath, toPath);
} else {
await downloadUrl(file.url, toPath);
}
}

View file

@ -1,5 +1,5 @@
import { IsNull } from 'typeorm';
import { ILocalUser } from '@/models/entities/user.js';
import { ILocalUser, User } from '@/models/entities/user.js';
import { Users } from '@/models/index.js';
import { getSystemUser } from './system-user.js';
@ -17,3 +17,7 @@ export async function getInstanceActor(): Promise<ILocalUser> {
return instanceActor;
}
export function isInstanceActor(user: User): boolean {
return user.username === ACTOR_USERNAME && user.host === null;
}

View file

@ -1,109 +1,21 @@
import { ArrayOverlap, Not, In } from 'typeorm';
import * as mfm from 'mfm-js';
import { db } from '@/db/postgre.js';
import { publishMainStream, publishNotesStream } from '@/services/stream.js';
import { DeliverManager } from '@/remote/activitypub/deliver-manager.js';
import renderNote from '@/remote/activitypub/renderer/note.js';
import renderCreate from '@/remote/activitypub/renderer/create.js';
import renderAnnounce from '@/remote/activitypub/renderer/announce.js';
import { renderActivity } from '@/remote/activitypub/renderer/index.js';
import { resolveUser } from '@/remote/resolve-user.js';
import { concat } from '@/prelude/array.js';
import { insertNoteUnread } from '@/services/note/unread.js';
import { extractMentions } from '@/misc/extract-mentions.js';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js';
import { Note } from '@/models/entities/note.js';
import { Mutings, Users, NoteWatchings, Notes, Instances, MutedNotes, Channels, ChannelFollowings, NoteThreadMutings } from '@/models/index.js';
import { Users, Channels } from '@/models/index.js';
import { DriveFile } from '@/models/entities/drive-file.js';
import { App } from '@/models/entities/app.js';
import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js';
import { User } from '@/models/entities/user.js';
import { genId } from '@/misc/gen-id.js';
import { notesChart, perUserNotesChart, activeUsersChart, instanceChart } from '@/services/chart/index.js';
import { Poll, IPoll } from '@/models/entities/poll.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import { checkHitAntenna } from '@/misc/check-hit-antenna.js';
import { checkWordMute } from '@/misc/check-word-mute.js';
import { countSameRenotes } from '@/misc/count-same-renotes.js';
import { Channel } from '@/models/entities/channel.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { getAntennas } from '@/misc/antenna-cache.js';
import { endedPollNotificationQueue } from '@/queue/queues.js';
import { webhookDeliver } from '@/queue/index.js';
import { UserProfile } from '@/models/entities/user-profile.js';
import { getActiveWebhooks } from '@/misc/webhook-cache.js';
import { IActivity } from '@/remote/activitypub/type.js';
import { renderNoteOrRenoteActivity } from '@/remote/activitypub/renderer/note-or-renote.js';
import { updateHashtags } from '../update-hashtag.js';
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js';
import { createNotification } from '../create-notification.js';
import { addNoteToAntenna } from '../add-note-to-antenna.js';
import { deliverToRelays } from '../relay.js';
import { mutedWordsCache, index } from './index.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
class NotificationManager {
private notifier: { id: User['id']; };
private note: Note;
private queue: {
target: ILocalUser['id'];
reason: NotificationType;
}[];
constructor(notifier: { id: User['id']; }, note: Note) {
this.notifier = notifier;
this.note = note;
this.queue = [];
}
public push(notifiee: ILocalUser['id'], reason: NotificationType): void {
// No notification to yourself.
if (this.notifier.id === notifiee) return;
const exist = this.queue.find(x => x.target === notifiee);
if (exist) {
// If you have been "mentioned and replied to," make the notification as a reply, not as a mention.
if (reason !== 'mention') {
exist.reason = reason;
}
} else {
this.queue.push({
reason,
target: notifiee,
});
}
}
public async deliver(): Promise<void> {
for (const x of this.queue) {
// check if the sender or thread are muted
const userMuted = await Mutings.countBy({
muterId: x.target,
muteeId: this.notifier.id,
});
const threadMuted = await NoteThreadMutings.countBy({
userId: x.target,
threadId: In([
// replies
this.note.threadId ?? this.note.id,
// renotes
this.note.renoteId ?? undefined,
]),
mutingNotificationTypes: ArrayOverlap([x.reason]),
});
if (!userMuted && !threadMuted) {
createNotification(x.target, x.reason, {
notifierId: this.notifier.id,
noteId: this.note.id,
});
}
}
}
}
import { sideEffects } from './side-effects.js';
type MinimumUser = {
id: User['id'];
@ -238,253 +150,9 @@ export default async (user: { id: User['id']; username: User['username']; host:
res(note);
// Update Statistics
notesChart.update(note, true);
perUserNotesChart.update(user, note, true);
// Register host
if (Users.isRemoteUser(user)) {
registerOrFetchInstanceDoc(user.host).then(i => {
Instances.increment({ id: i.id }, 'notesCount', 1);
instanceChart.updateNote(i.host, note, true);
});
}
// Hashtag Update
if (data.visibility === 'public' || data.visibility === 'home') {
updateHashtags(user, tags);
}
// Increment notes count (user)
incNotesCountOfUser(user);
// Word mute
mutedWordsCache.fetch('').then(us => {
for (const u of us) {
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
if (shouldMute) {
MutedNotes.insert({
id: genId(),
userId: u.userId,
noteId: note.id,
reason: 'word',
});
}
});
}
});
// Antenna
for (const antenna of (await getAntennas())) {
checkHitAntenna(antenna, note, user).then(hit => {
if (hit) {
addNoteToAntenna(antenna, note, user);
}
});
}
// Channel
if (note.channelId) {
ChannelFollowings.findBy({ followeeId: note.channelId }).then(followings => {
for (const following of followings) {
insertNoteUnread(following.followerId, note, {
isSpecified: false,
isMentioned: false,
});
}
});
}
if (data.reply) {
saveReply(data.reply, note);
}
// When there is no re-note of the specified note by the specified user except for this post
if (data.renote && (await countSameRenotes(user.id, data.renote.id, note.id) === 0)) {
incRenoteCount(data.renote);
}
if (data.poll && data.poll.expiresAt) {
const delay = data.poll.expiresAt.getTime() - Date.now();
endedPollNotificationQueue.add({
noteId: note.id,
}, {
delay,
removeOnComplete: true,
});
}
if (!silent) {
if (Users.isLocalUser(user)) activeUsersChart.write(user);
// Create unread notifications
if (data.visibility === 'specified') {
if (data.visibleUsers == null) throw new Error('invalid param');
for (const u of data.visibleUsers) {
// Local users only
if (!Users.isLocalUser(u)) continue;
insertNoteUnread(u.id, note, {
isSpecified: true,
isMentioned: false,
});
}
} else {
for (const u of mentionedUsers) {
// Local users only
if (!Users.isLocalUser(u)) continue;
insertNoteUnread(u.id, note, {
isSpecified: false,
isMentioned: true,
});
}
}
publishNotesStream(note);
const webhooks = await getActiveWebhooks().then(webhooks => webhooks.filter(x => x.userId === user.id && x.on.includes('note')));
for (const webhook of webhooks) {
webhookDeliver(webhook, 'note', {
note: await Notes.pack(note, user),
});
}
const nm = new NotificationManager(user, note);
const nmRelatedPromises = [];
await createMentionedEvents(mentionedUsers, note, nm);
// If has in reply to note
if (data.reply) {
// Fetch watchers
nmRelatedPromises.push(notifyToWatchersOfReplyee(data.reply, user, nm));
// 通知
if (data.reply.userHost === null) {
const threadMuted = await NoteThreadMutings.countBy({
userId: data.reply.userId,
threadId: data.reply.threadId || data.reply.id,
});
if (!threadMuted) {
nm.push(data.reply.userId, 'reply');
const packedReply = await Notes.pack(note, { id: data.reply.userId });
publishMainStream(data.reply.userId, 'reply', packedReply);
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply'));
for (const webhook of webhooks) {
webhookDeliver(webhook, 'reply', {
note: packedReply,
});
}
}
}
}
// If it is renote
if (data.renote) {
const type = data.text ? 'quote' : 'renote';
// Notify
if (data.renote.userHost === null) {
nm.push(data.renote.userId, type);
}
// Fetch watchers
nmRelatedPromises.push(notifyToWatchersOfRenotee(data.renote, user, nm, type));
// Publish event
if ((user.id !== data.renote.userId) && data.renote.userHost === null) {
const packedRenote = await Notes.pack(note, { id: data.renote.userId });
publishMainStream(data.renote.userId, 'renote', packedRenote);
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote'));
for (const webhook of webhooks) {
webhookDeliver(webhook, 'renote', {
note: packedRenote,
});
}
}
}
Promise.all(nmRelatedPromises).then(() => {
nm.deliver();
});
//#region AP deliver
if (Users.isLocalUser(user) && !data.localOnly) {
(async () => {
const noteActivity = renderActivity(await renderNoteOrRenoteActivity(note));
const dm = new DeliverManager(user, noteActivity);
// Delivered to remote users who have been mentioned
for (const u of mentionedUsers.filter(u => Users.isRemoteUser(u))) {
dm.addDirectRecipe(u as IRemoteUser);
}
// If the post is a reply and the poster is a local user and the poster of the post to which you are replying is a remote user, deliver
if (data.reply && data.reply.userHost !== null) {
const u = await Users.findOneBy({ id: data.reply.userId });
if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u);
}
// If the post is a Renote and the poster is a local user and the poster of the original Renote post is a remote user, deliver
if (data.renote && data.renote.userHost !== null) {
const u = await Users.findOneBy({ id: data.renote.userId });
if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u);
}
// Deliver to followers
if (['public', 'home', 'followers'].includes(note.visibility)) {
dm.addFollowersRecipe();
}
if (['public'].includes(note.visibility)) {
deliverToRelays(user, noteActivity);
}
dm.execute();
})();
}
//#endregion
}
if (data.channel) {
Channels.increment({ id: data.channel.id }, 'notesCount', 1);
Channels.update(data.channel.id, {
lastNotedAt: new Date(),
});
const count = await Notes.countBy({
userId: user.id,
channelId: data.channel.id,
});
// This process takes place after the note is created, so if there is only one note, you can determine that it is the first submission.
// TODO: but there's also the messiness of deleting a note and posting it multiple times, which is incremented by the number of times it's posted, so I'd like to do something about that.
if (count === 1) {
Channels.increment({ id: data.channel.id }, 'usersCount', 1);
}
}
// Register to search database
index(note);
sideEffects(user, note, silent, true);
});
function incRenoteCount(renote: Note): void {
Notes.createQueryBuilder().update()
.set({
renoteCount: () => '"renoteCount" + 1',
score: () => '"score" + 1',
})
.where('id = :id', { id: renote.id })
.execute();
}
async function insertNote(user: { id: User['id']; host: User['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]): Promise<Note> {
const createdAt = data.createdAt ?? new Date();
@ -563,77 +231,6 @@ async function insertNote(user: { id: User['id']; host: User['host']; }, data: O
}
}
async function notifyToWatchersOfRenotee(renote: Note, user: { id: User['id']; }, nm: NotificationManager, type: NotificationType): Promise<void> {
const watchers = await NoteWatchings.findBy({
noteId: renote.id,
userId: Not(user.id),
});
for (const watcher of watchers) {
nm.push(watcher.userId, type);
}
}
async function notifyToWatchersOfReplyee(reply: Note, user: { id: User['id']; }, nm: NotificationManager): Promise<void> {
const watchers = await NoteWatchings.findBy({
noteId: reply.id,
userId: Not(user.id),
});
for (const watcher of watchers) {
nm.push(watcher.userId, 'reply');
}
}
async function createMentionedEvents(mentionedUsers: MinimumUser[], note: Note, nm: NotificationManager): Promise<void> {
for (const u of mentionedUsers.filter(u => Users.isLocalUser(u))) {
const threadMuted = await NoteThreadMutings.countBy({
userId: u.id,
threadId: note.threadId || note.id,
});
if (threadMuted) {
continue;
}
// note with "specified" visibility might not be visible to mentioned users
try {
const detailPackedNote = await Notes.pack(note, u, {
detail: true,
});
publishMainStream(u.id, 'mention', detailPackedNote);
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention'));
for (const webhook of webhooks) {
webhookDeliver(webhook, 'mention', {
note: detailPackedNote,
});
}
} catch (err) {
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') continue;
throw err;
}
// Create notification
nm.push(u.id, 'mention');
}
}
function saveReply(reply: Note): void {
Notes.increment({ id: reply.id }, 'repliesCount', 1);
}
function incNotesCountOfUser(user: { id: User['id']; }): void {
Users.createQueryBuilder().update()
.set({
updatedAt: new Date(),
notesCount: () => '"notesCount" + 1',
})
.where('id = :id', { id: user.id })
.execute();
}
async function extractMentionedUsers(user: { host: User['host']; }, tokens: mfm.MfmNode[]): Promise<User[]> {
if (tokens.length === 0) return [];

View file

@ -0,0 +1,474 @@
import { ArrayOverlap, Not, In } from 'typeorm';
import * as mfm from 'mfm-js';
import { publishMainStream, publishNoteStream, publishNotesStream } from '@/services/stream.js';
import { DeliverManager } from '@/remote/activitypub/deliver-manager.js';
import { renderActivity } from '@/remote/activitypub/renderer/index.js';
import { resolveUser } from '@/remote/resolve-user.js';
import { insertNoteUnread } from '@/services/note/unread.js';
import { extractMentions } from '@/misc/extract-mentions.js';
import { extractHashtags } from '@/misc/extract-hashtags.js';
import { Note } from '@/models/entities/note.js';
import { AntennaNotes, Mutings, Users, NoteWatchings, Notes, Instances, MutedNotes, Channels, ChannelFollowings, NoteThreadMutings } from '@/models/index.js';
import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js';
import { genId } from '@/misc/gen-id.js';
import { notesChart, perUserNotesChart, activeUsersChart, instanceChart } from '@/services/chart/index.js';
import { checkHitAntenna } from '@/misc/check-hit-antenna.js';
import { checkWordMute } from '@/misc/check-word-mute.js';
import { countSameRenotes } from '@/misc/count-same-renotes.js';
import { getAntennas } from '@/misc/antenna-cache.js';
import { endedPollNotificationQueue } from '@/queue/queues.js';
import { webhookDeliver } from '@/queue/index.js';
import { getActiveWebhooks } from '@/misc/webhook-cache.js';
import { renderNoteOrRenoteActivity } from '@/remote/activitypub/renderer/note-or-renote.js';
import { updateHashtags } from '../update-hashtag.js';
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js';
import { createNotification } from '../create-notification.js';
import { addNoteToAntenna } from '../add-note-to-antenna.js';
import { deliverToRelays } from '../relay.js';
import { mutedWordsCache, index } from './index.js';
import { Polls } from '@/models/index.js';
import { Poll } from '@/models/entities/poll.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention' | 'update';
class NotificationManager {
private notifier: { id: User['id']; };
private note: Note;
private queue: {
target: ILocalUser['id'];
reason: NotificationType;
}[];
constructor(notifier: { id: User['id']; }, note: Note) {
this.notifier = notifier;
this.note = note;
this.queue = [];
}
public push(notifiee: ILocalUser['id'], reason: NotificationType): void {
// No notification to yourself.
if (this.notifier.id === notifiee) return;
const exist = this.queue.find(x => x.target === notifiee);
if (exist) {
// If you have been "mentioned and replied to," make the notification as a reply, not as a mention.
if (reason !== 'mention') {
exist.reason = reason;
}
} else {
this.queue.push({
reason,
target: notifiee,
});
}
}
public async deliver(): Promise<void> {
for (const x of this.queue) {
// check if the sender or thread are muted
const userMuted = await Mutings.countBy({
muterId: x.target,
muteeId: this.notifier.id,
});
const threadMuted = await NoteThreadMutings.countBy({
userId: x.target,
threadId: In([
// replies
this.note.threadId ?? this.note.id,
// renotes
this.note.renoteId ?? undefined,
]),
mutingNotificationTypes: ArrayOverlap([x.reason]),
});
if (!userMuted && !threadMuted) {
createNotification(x.target, x.reason, {
notifierId: this.notifier.id,
noteId: this.note.id,
});
}
}
}
}
/**
* Perform side effects for a Note such as incrementing statistics, updating hashtag usage etc.
*
* @param user The author of the note.
* @param note The note for which the side effects should be performed.
* @param silent Whether notifications and similar side effects should be suppressed.
* @param created Whether statistics should be incremented (i.e. the note was inserted and not updated in the database)
*/
export async function sideEffects(user: User, note: Note, silent = false, created = true): Promise<void> {
if (created) {
// Update Statistics
notesChart.update(note, true);
perUserNotesChart.update(user, note, true);
Users.createQueryBuilder().update()
.set({
updatedAt: new Date(),
notesCount: () => '"notesCount" + 1',
})
.where('id = :id', { id: user.id })
.execute();
if (Users.isLocalUser(user)) {
activeUsersChart.write(user);
} else {
// Remote user, register host
registerOrFetchInstanceDoc(user.host).then(i => {
Instances.increment({ id: i.id }, 'notesCount', 1);
instanceChart.updateNote(i.host, note, true);
});
}
// Channel
if (note.channelId) {
ChannelFollowings.findBy({ followeeId: note.channelId }).then(followings => {
for (const following of followings) {
insertNoteUnread(following.followerId, note, {
isSpecified: false,
isMentioned: false,
});
}
});
Channels.increment({ id: note.channelId }, 'notesCount', 1);
Channels.update(note.channelId, {
lastNotedAt: new Date(),
});
const count = await Notes.countBy({
userId: user.id,
channelId: note.channelId,
});
// This process takes place after the note is created, so if there is only one note, you can determine that it is the first submission.
// TODO: but there's also the messiness of deleting a note and posting it multiple times, which is incremented by the number of times it's posted, so I'd like to do something about that.
if (count === 1) {
Channels.increment({ id: note.channelId }, 'usersCount', 1);
}
}
if (note.replyId) {
Notes.increment({ id: note.replyId }, 'repliesCount', 1);
}
// When there is no re-note of the specified note by the specified user except for this post
if (note.renoteId && (await countSameRenotes(user.id, note.renoteId, note.id) === 0)) {
Notes.createQueryBuilder().update()
.set({
renoteCount: () => '"renoteCount" + 1',
score: () => '"score" + 1',
})
.where('id = :id', { id: note.renoteId })
.execute();
}
// create job for ended poll notifications
if (note.hasPoll) {
Polls.findOneByOrFail({ noteId: note.id })
.then((poll: Poll) => {
if (poll.expiresAt) {
const delay = poll.expiresAt.getTime() - Date.now();
endedPollNotificationQueue.add({
noteId: note.id,
}, {
delay,
removeOnComplete: true,
});
}
})
}
}
const { mentionedUsers, tags } = await extractFromMfm(user, note);
// Hashtag Update
if (note.visibility === 'public' || note.visibility === 'home') {
updateHashtags(user, tags);
}
// Word mute
mutedWordsCache.fetch('').then(us => {
for (const u of us) {
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
if (shouldMute) {
MutedNotes.insert({
id: genId(),
userId: u.userId,
noteId: note.id,
reason: 'word',
});
}
});
}
});
// Antenna
if (!created) {
// Provisionally remove from antenna, it may be added again in the next step.
// But if it is not removed, it can cause duplicate key errors when trying to
// add it to the same antenna again.
await AntennaNotes.delete({
noteId: note.id,
});
}
getAntennas()
.then(async antennas => {
await Promise.all(antennas.map(antenna => {
return checkHitAntenna(antenna, note, user)
.then(hit => { if (hit) return addNoteToAntenna(antenna, note, user); });
}));
});
// Notifications
if (!silent && created) {
// Create unread notifications
if (note.visibility === 'specified') {
if (note.visibleUserIds == null) {
throw new Error('specified note but does not have any visible user ids');
}
Users.findBy({
id: In(note.visibleUserIds),
}).then((visibleUsers: User[]) => {
visibleUsers
.filter(u => Users.isLocalUser(u))
.forEach(u => {
insertNoteUnread(u.id, note, {
isSpecified: true,
isMentioned: false,
});
});
})
} else {
mentionedUsers
.filter(u => Users.isLocalUser(u))
.forEach(u => {
insertNoteUnread(u.id, note, {
isSpecified: false,
isMentioned: true,
});
})
}
publishNotesStream(note);
const webhooks = await getActiveWebhooks().then(webhooks => webhooks.filter(x => x.userId === user.id && x.on.includes('note')));
for (const webhook of webhooks) {
webhookDeliver(webhook, 'note', {
note: await Notes.pack(note, user),
});
}
const nm = new NotificationManager(user, note);
const nmRelatedPromises = [];
await createMentionedEvents(mentionedUsers, note, nm);
// If it is in reply to another note
if (note.replyId) {
const reply = await Notes.findOneByOrFail({ id: note.replyId });
// Fetch watchers
nmRelatedPromises.push(notifyWatchers(note.replyId, user, nm, 'reply'));
// Notifications
if (reply.userHost === null) {
const threadMuted = await NoteThreadMutings.countBy({
userId: reply.userId,
threadId: reply.threadId ?? reply.id,
});
if (!threadMuted) {
nm.push(reply.userId, 'reply');
const packedReply = await Notes.pack(note, { id: reply.userId });
publishMainStream(reply.userId, 'reply', packedReply);
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === reply.userId && x.on.includes('reply'));
for (const webhook of webhooks) {
webhookDeliver(webhook, 'reply', {
note: packedReply,
});
}
}
}
}
// If it is a renote
if (note.renoteId) {
const type = note.text ? 'quote' : 'renote';
const renote = await Notes.findOneByOrFail({ id : note.renoteId });
// Notify
if (renote.userHost === null) {
nm.push(renote.userId, type);
}
// Fetch watchers
nmRelatedPromises.push(notifyWatchers(note.renoteId, user, nm, type));
// Publish event
if ((user.id !== renote.userId) && renote.userHost === null) {
const packedRenote = await Notes.pack(note, { id: renote.userId });
publishMainStream(renote.userId, 'renote', packedRenote);
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === renote.userId && x.on.includes('renote'));
for (const webhook of webhooks) {
webhookDeliver(webhook, 'renote', {
note: packedRenote,
});
}
}
}
Promise.all(nmRelatedPromises).then(() => {
nm.deliver();
});
//#region AP deliver
if (Users.isLocalUser(user) && !note.localOnly && created) {
(async () => {
const noteActivity = renderActivity(await renderNoteOrRenoteActivity(note));
const dm = new DeliverManager(user, noteActivity);
// Delivered to remote users who have been mentioned
for (const u of mentionedUsers.filter(u => Users.isRemoteUser(u))) {
dm.addDirectRecipe(u as IRemoteUser);
}
// If the post is a reply and the poster is a local user and the poster of the post to which you are replying is a remote user, deliver
if (note.replyId) {
const subquery = Notes.createaQueryBuilder()
.select('userId')
.where('"id" = :replyId', { replyId: note.replyId });
const u = await Users.createQueryBuilder()
.where('"id" IN (' + subquery.getQuery() + ')')
.andWhere('"userHost" IS NOT NULL')
.getOne();
if (u != null) dm.addDirectRecipe(u);
}
// If the post is a Renote and the poster is a local user and the poster of the original Renote post is a remote user, deliver
if (note.renoteId) {
const subquery = Notes.createaQueryBuilder()
.select('userId')
.where('"id" = :renoteId', { renoteId: note.renoteId });
const u = await Users.createQueryBuilder()
.where('"id" IN (' + subquery.getQuery() + ')')
.andWhere('"userHost" IS NOT NULL')
.getOne();
if (u != null) dm.addDirectRecipe(u);
}
// Deliver to followers
if (['public', 'home', 'followers'].includes(note.visibility)) {
dm.addFollowersRecipe();
}
if (['public'].includes(note.visibility)) {
deliverToRelays(user, noteActivity);
}
dm.execute();
})();
}
//#endregion
} else if (!created) {
// updating a note does not change its un-/read status
// updating does not trigger notifications for replied to or renoted notes
// updating does not trigger notifications for mentioned users (since mentions cannot be changed)
// TODO publish to streaming API
publishNoteStream(note.id, 'updated', { note });
const nm = new NotificationManager(user, note);
notifyWatchers(note.id, user, nm, 'update');
await nm.deliver();
// TODO AP deliver
}
// Register to search database
index(note);
}
async function notifyWatchers(noteId: Note['id'], user: { id: User['id']; }, nm: NotificationManager, type: NotificationType): Promise<void> {
const watchers = await NoteWatchings.findBy({
noteId,
userId: Not(user.id),
});
for (const watcher of watchers) {
nm.push(watcher.userId, type);
}
}
async function createMentionedEvents(mentionedUsers: User[], note: Note, nm: NotificationManager): Promise<void> {
for (const u of mentionedUsers.filter(u => Users.isLocalUser(u))) {
const threadMuted = await NoteThreadMutings.countBy({
userId: u.id,
threadId: note.threadId || note.id,
});
if (threadMuted) {
continue;
}
// note with "specified" visibility might not be visible to mentioned users
try {
const detailPackedNote = await Notes.pack(note, u, {
detail: true,
});
publishMainStream(u.id, 'mention', detailPackedNote);
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention'));
for (const webhook of webhooks) {
webhookDeliver(webhook, 'mention', {
note: detailPackedNote,
});
}
} catch (err) {
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') continue;
throw err;
}
// Create notification
nm.push(u.id, 'mention');
}
}
async function extractFromMfm(user: { host: User['host']; }, note: Note): Promise<{ mentionedUsers: User[], tags: string[] }> {
const tokens = mfm.parse(note.text ?? '').concat(mfm.parse(note.cw ?? ''));
const tags = extractHashtags(tokens);
if (tokens.length === 0) {
return {
mentionedUsers: [],
tags,
};
}
const mentions = extractMentions(tokens);
let mentionedUsers = (await Promise.all(mentions.map(m =>
resolveUser(m.username, m.host || user.host).catch(() => null),
))).filter(x => x != null) as User[];
// Drop duplicate users
mentionedUsers = mentionedUsers.filter((u, i, self) =>
i === self.findIndex(u2 => u.id === u2.id),
);
return {
mentionedUsers,
tags,
};
}

View file

@ -12,7 +12,7 @@ import { db } from '@/db/postgre.js';
import generateNativeUserToken from '@/server/api/common/generate-native-user-token.js';
export async function getSystemUser(username: string): Promise<User> {
const exist = await Users.findBy({
const exist = await Users.findOneBy({
usernameLower: username.toLowerCase(),
host: IsNull(),
});

View file

@ -1,11 +1,11 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { initDb } from '../src/db/postgre.js';
import { initTestDb } from './utils.js';
import { initDb } from '../built/db/postgre.js';
import { initTestDb } from './utils.mjs';
function rndstr(length): string {
function rndstr(length) {
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const chars_len = 62;
@ -52,8 +52,8 @@ describe('ActivityPub', () => {
};
it('Minimum Actor', async () => {
const { MockResolver } = await import('./misc/mock-resolver.js');
const { createPerson } = await import('../src/remote/activitypub/models/person.js');
const { MockResolver } = await import('./misc/mock-resolver.mjs');
const { createPerson } = await import('../built/remote/activitypub/models/person.js');
const resolver = new MockResolver();
resolver._register(actor.id, actor);
@ -66,8 +66,8 @@ describe('ActivityPub', () => {
});
it('Minimum Note', async () => {
const { MockResolver } = await import('./misc/mock-resolver.js');
const { createNote } = await import('../src/remote/activitypub/models/note.js');
const { MockResolver } = await import('./misc/mock-resolver.mjs');
const { createNote } = await import('../built/remote/activitypub/models/note.js');
const resolver = new MockResolver();
resolver._register(actor.id, actor);
@ -99,8 +99,8 @@ describe('ActivityPub', () => {
};
it('Actor', async () => {
const { MockResolver } = await import('./misc/mock-resolver.js');
const { createPerson } = await import('../src/remote/activitypub/models/person.js');
const { MockResolver } = await import('./misc/mock-resolver.mjs');
const { createPerson } = await import('../built/remote/activitypub/models/person.js');
const resolver = new MockResolver();
resolver._register(actor.id, actor);

View file

@ -1,9 +1,9 @@
import * as assert from 'assert';
import httpSignature from '@peertube/http-signature';
import { genRsaKeyPair } from '../src/misc/gen-key-pair.js';
import { createSignedPost, createSignedGet } from '../src/remote/activitypub/ap-request.js';
import { genRsaKeyPair } from '../built/misc/gen-key-pair.js';
import { createSignedPost, createSignedGet } from '../built/remote/activitypub/ap-request.js';
export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => {
export const buildParsedSignature = (signingString, signature, algorithm) => {
return {
scheme: 'Signature',
params: {

View file

@ -2,12 +2,12 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as childProcess from 'child_process';
import { async, signup, request, post, startServer, shutdownServer } from './utils.js';
import { async, signup, request, post, startServer, shutdownServer } from './utils.mjs';
describe('API visibility', function() {
this.timeout(20*60*1000);
let p: childProcess.ChildProcess;
let p;
before(async () => {
p = await startServer();
@ -20,48 +20,48 @@ describe('API visibility', function() {
describe('Note visibility', async () => {
//#region vars
/** protagonist */
let alice: any;
let alice;
/** follower */
let follower: any;
let follower;
/** non-follower */
let other: any;
let other;
/** non-follower who has been replied to or mentioned */
let target: any;
let target;
/** actor for which a specified visibility was set */
let target2: any;
let target2;
/** public-post */
let pub: any;
let pub;
/** home-post */
let home: any;
let home;
/** followers-post */
let fol: any;
let fol;
/** specified-post */
let spe: any;
let spe;
/** public-reply to target's post */
let pubR: any;
let pubR;
/** home-reply to target's post */
let homeR: any;
let homeR;
/** followers-reply to target's post */
let folR: any;
let folR;
/** specified-reply to target's post */
let speR: any;
let speR;
/** public-mention to target */
let pubM: any;
let pubM;
/** home-mention to target */
let homeM: any;
let homeM;
/** followers-mention to target */
let folM: any;
let folM;
/** specified-mention to target */
let speM: any;
let speM;
/** reply target post */
let tgt: any;
let tgt;
//#endregion
const show = async (noteId: any, by: any) => {
const show = async (noteId, by) => {
return await request('/notes/show', {
noteId,
}, by);
@ -412,21 +412,21 @@ describe('API visibility', function() {
it('[TL] public post on author home TL', async(async () => {
const res = await request('/notes/timeline', { limit: 100 }, alice);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id == pub.id);
const notes = res.body.filter((n) => n.id == pub.id);
assert.strictEqual(notes[0].text, 'x');
}));
it('[TL] public post absent from non-follower home TL', async(async () => {
const res = await request('/notes/timeline', { limit: 100 }, other);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id == pub.id);
const notes = res.body.filter((n) => n.id == pub.id);
assert.strictEqual(notes.length, 0);
}));
it('[TL] followers post on follower home TL', async(async () => {
const res = await request('/notes/timeline', { limit: 100 }, follower);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id == fol.id);
const notes = res.body.filter((n) => n.id == fol.id);
assert.strictEqual(notes[0].text, 'x');
}));
//#endregion
@ -435,21 +435,21 @@ describe('API visibility', function() {
it('[TL] followers reply on follower reply TL', async(async () => {
const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, follower);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id == folR.id);
const notes = res.body.filter((n) => n.id == folR.id);
assert.strictEqual(notes[0].text, 'x');
}));
it('[TL] followers reply absent from not replied to non-follower reply TL', async(async () => {
const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, other);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id == folR.id);
const notes = res.body.filter((n) => n.id == folR.id);
assert.strictEqual(notes.length, 0);
}));
it('[TL] followers reply on replied to actor reply TL', async(async () => {
const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, target);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id == folR.id);
const notes = res.body.filter((n) => n.id == folR.id);
assert.strictEqual(notes[0].text, 'x');
}));
//#endregion
@ -458,14 +458,14 @@ describe('API visibility', function() {
it('[TL] followers reply on replied to non-follower mention TL', async(async () => {
const res = await request('/notes/mentions', { limit: 100 }, target);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id == folR.id);
const notes = res.body.filter((n) => n.id == folR.id);
assert.strictEqual(notes[0].text, 'x');
}));
it('[TL] followers mention on mentioned non-follower mention TL', async(async () => {
const res = await request('/notes/mentions', { limit: 100 }, target);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id == folM.id);
const notes = res.body.filter((n) => n.id == folM.id);
assert.strictEqual(notes[0].text, '@target x');
}));
//#endregion

View file

@ -2,15 +2,13 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as childProcess from 'child_process';
import { async, signup, request, post, react, uploadFile, startServer, shutdownServer } from './utils.js';
import { async, signup, request, post, react, uploadFile, startServer, shutdownServer } from './utils.mjs';
describe('API', function() {
this.timeout(20*60*1000);
let p: childProcess.ChildProcess;
let alice: any;
let bob: any;
let carol: any;
let p;
let alice, bob, carol;
before(async () => {
p = await startServer();

View file

@ -2,17 +2,15 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as childProcess from 'child_process';
import { async, signup, request, post, startServer, shutdownServer } from './utils.js';
import { async, signup, request, post, startServer, shutdownServer } from './utils.mjs';
describe('Block', function() {
this.timeout(20*60*1000);
let p: childProcess.ChildProcess;
let p;
// alice blocks bob
let alice: any;
let bob: any;
let carol: any;
let alice, bob, carol;
before(async () => {
p = await startServer();
@ -80,8 +78,8 @@ describe('Block', function() {
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true);
assert.strictEqual(res.body.some((note) => note.id === aliceNote.id), false);
assert.strictEqual(res.body.some((note) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note) => note.id === carolNote.id), true);
}));
});

View file

@ -2,18 +2,14 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as lolex from '@sinonjs/fake-timers';
import TestChart from '../src/services/chart/charts/test.js';
import TestGroupedChart from '../src/services/chart/charts/test-grouped.js';
import TestUniqueChart from '../src/services/chart/charts/test-unique.js';
import TestIntersectionChart from '../src/services/chart/charts/test-intersection.js';
import { initDb } from '../src/db/postgre.js';
import TestChart from '../built/services/chart/charts/test.js';
import TestGroupedChart from '../built/services/chart/charts/test-grouped.js';
import TestUniqueChart from '../built/services/chart/charts/test-unique.js';
import TestIntersectionChart from '../built/services/chart/charts/test-intersection.js';
import { initDb } from '../built/db/postgre.js';
describe('Chart', () => {
let testChart: TestChart;
let testGroupedChart: TestGroupedChart;
let testUniqueChart: TestUniqueChart;
let testIntersectionChart: TestIntersectionChart;
let clock: lolex.InstalledClock;
let testChart, testGroupedChart, testUniqueChart, testIntersectionChart, clock;
beforeEach(async () => {
await initDb(true);

View file

@ -1,15 +0,0 @@
version: "3"
services:
redistest:
image: redis:6
ports:
- "127.0.0.1:56312:6379"
dbtest:
image: postgres:13
ports:
- "127.0.0.1:54312:5432"
environment:
POSTGRES_DB: "test-misskey"
POSTGRES_HOST_AUTH_METHOD: trust

View file

@ -3,13 +3,12 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as childProcess from 'child_process';
import { async, signup, request, post, react, uploadFile, startServer, shutdownServer } from './utils.js';
import { async, signup, request, post, react, uploadFile, startServer, shutdownServer } from './utils.mjs';
describe('API: Endpoints', () => {
let p: childProcess.ChildProcess;
let alice: any;
let bob: any;
let carol: any;
let p;
let alice, bob, carol;
before(async () => {
p = await startServer();

View file

@ -1,11 +1,11 @@
import * as assert from 'assert';
import { parse } from 'mfm-js';
import { extractMentions } from '../src/misc/extract-mentions.js';
import { extractMentions } from '../built/misc/extract-mentions.js';
describe('Extract mentions', () => {
it('simple', () => {
const ast = parse('@foo @bar @baz')!;
const ast = parse('@foo @bar @baz');
const mentions = extractMentions(ast);
assert.deepStrictEqual(mentions, [{
username: 'foo',
@ -23,7 +23,7 @@ describe('Extract mentions', () => {
});
it('nested', () => {
const ast = parse('@foo **@bar** @baz')!;
const ast = parse('@foo **@bar** @baz');
const mentions = extractMentions(ast);
assert.deepStrictEqual(mentions, [{
username: 'foo',

View file

@ -3,7 +3,7 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as childProcess from 'child_process';
import * as openapi from '@redocly/openapi-core';
import { async, startServer, signup, post, request, simpleGet, port, shutdownServer } from './utils.js';
import { async, startServer, signup, post, request, simpleGet, port, shutdownServer } from './utils.mjs';
// Request Accept
const ONLY_AP = 'application/activity+json';
@ -19,10 +19,9 @@ const HTML = 'text/html; charset=utf-8';
describe('Fetch resource', function() {
this.timeout(20*60*1000);
let p: childProcess.ChildProcess;
let p;
let alice: any;
let alicesPost: any;
let alice, alicesPost;
before(async () => {
p = await startServer();

View file

@ -2,16 +2,14 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as childProcess from 'child_process';
import { async, signup, request, post, react, connectStream, startServer, shutdownServer, simpleGet } from './utils.js';
import { async, signup, request, post, react, connectStream, startServer, shutdownServer, simpleGet } from './utils.mjs';
describe('FF visibility', function() {
this.timeout(20*60*1000);
let p: childProcess.ChildProcess;
let p;
let alice: any;
let bob: any;
let follower: any;
let alice, bob, follower;
before(async () => {
p = await startServer();

View file

@ -1,8 +1,8 @@
import * as assert from 'assert';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import { getFileInfo } from '../src/misc/get-file-info.js';
import { async } from './utils.js';
import { getFileInfo } from '../built/misc/get-file-info.js';
import { async } from './utils.mjs';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
@ -10,7 +10,7 @@ const _dirname = dirname(_filename);
describe('Get file info', () => {
it('Empty file', async (async () => {
const path = `${_dirname}/resources/emptyfile`;
const info = await getFileInfo(path) as any;
const info = await getFileInfo(path);
delete info.warnings;
delete info.blurhash;
assert.deepStrictEqual(info, {
@ -28,7 +28,7 @@ describe('Get file info', () => {
it('Generic JPEG', async (async () => {
const path = `${_dirname}/resources/Lenna.jpg`;
const info = await getFileInfo(path) as any;
const info = await getFileInfo(path);
delete info.warnings;
delete info.blurhash;
assert.deepStrictEqual(info, {
@ -46,7 +46,7 @@ describe('Get file info', () => {
it('Generic APNG', async (async () => {
const path = `${_dirname}/resources/anime.png`;
const info = await getFileInfo(path) as any;
const info = await getFileInfo(path);
delete info.warnings;
delete info.blurhash;
assert.deepStrictEqual(info, {
@ -64,7 +64,7 @@ describe('Get file info', () => {
it('Generic AGIF', async (async () => {
const path = `${_dirname}/resources/anime.gif`;
const info = await getFileInfo(path) as any;
const info = await getFileInfo(path);
delete info.warnings;
delete info.blurhash;
assert.deepStrictEqual(info, {
@ -82,7 +82,7 @@ describe('Get file info', () => {
it('PNG with alpha', async (async () => {
const path = `${_dirname}/resources/with-alpha.png`;
const info = await getFileInfo(path) as any;
const info = await getFileInfo(path);
delete info.warnings;
delete info.blurhash;
assert.deepStrictEqual(info, {
@ -100,7 +100,7 @@ describe('Get file info', () => {
it('Generic SVG', async (async () => {
const path = `${_dirname}/resources/image.svg`;
const info = await getFileInfo(path) as any;
const info = await getFileInfo(path);
delete info.warnings;
delete info.blurhash;
assert.deepStrictEqual(info, {
@ -119,7 +119,7 @@ describe('Get file info', () => {
it('SVG with XML definition', async (async () => {
// https://github.com/misskey-dev/misskey/issues/4413
const path = `${_dirname}/resources/with-xml-def.svg`;
const info = await getFileInfo(path) as any;
const info = await getFileInfo(path);
delete info.warnings;
delete info.blurhash;
assert.deepStrictEqual(info, {
@ -137,7 +137,7 @@ describe('Get file info', () => {
it('Dimension limit', async (async () => {
const path = `${_dirname}/resources/25000x25000.png`;
const info = await getFileInfo(path) as any;
const info = await getFileInfo(path);
delete info.warnings;
delete info.blurhash;
assert.deepStrictEqual(info, {
@ -155,7 +155,7 @@ describe('Get file info', () => {
it('Rotate JPEG', async (async () => {
const path = `${_dirname}/resources/rotate.jpg`;
const info = await getFileInfo(path) as any;
const info = await getFileInfo(path);
delete info.warnings;
delete info.blurhash;
assert.deepStrictEqual(info, {

View file

@ -1,34 +0,0 @@
/**
* ts-node/esmローダーに投げる前にpath mappingを解決する
* 参考
* - https://github.com/TypeStrong/ts-node/discussions/1450#discussioncomment-1806115
* - https://nodejs.org/api/esm.html#loaders
* https://github.com/TypeStrong/ts-node/pull/1585 が取り込まれたらこのカスタムローダーは必要なくなる
*/
import { resolve as resolveTs, load } from 'ts-node/esm';
import { loadConfig, createMatchPath } from 'tsconfig-paths';
import { pathToFileURL } from 'url';
const tsconfig = loadConfig();
const matchPath = createMatchPath(tsconfig.absoluteBaseUrl, tsconfig.paths);
export function resolve(specifier, ctx, defaultResolve) {
let resolvedSpecifier;
if (specifier.endsWith('.js')) {
// maybe transpiled
const specifierWithoutExtension = specifier.substring(0, specifier.length - '.js'.length);
const matchedSpecifier = matchPath(specifierWithoutExtension);
if (matchedSpecifier) {
resolvedSpecifier = pathToFileURL(`${matchedSpecifier}.js`).href;
}
} else {
const matchedSpecifier = matchPath(specifier);
if (matchedSpecifier) {
resolvedSpecifier = pathToFileURL(matchedSpecifier).href;
}
}
return resolveTs(resolvedSpecifier ?? specifier, ctx, defaultResolve);
}
export { load };

View file

@ -1,7 +1,7 @@
import * as assert from 'assert';
import { toHtml } from '../src/mfm/to-html.js';
import { fromHtml } from '../src/mfm/from-html.js';
import { toHtml } from '../built/mfm/to-html.js';
import { fromHtml } from '../built/mfm/from-html.js';
describe('toHtml', () => {
it('br', async () => {

View file

@ -1,21 +1,16 @@
import { Resolver } from '../../src/remote/activitypub/resolver.js';
import { IObject } from '../../src/remote/activitypub/type.js';
type MockResponse = {
type: string;
content: string;
};
import { Resolver } from '../../built/remote/activitypub/resolver.js';
export class MockResolver extends Resolver {
private _rs = new Map<string, MockResponse>();
public async _register(uri: string, content: string | Record<string, any>, type = 'application/activity+json') {
_rs = new Map();
async _register(uri, content, type = 'application/activity+json') {
this._rs.set(uri, {
type,
content: typeof content === 'string' ? content : JSON.stringify(content),
});
}
public async resolve(value: string | IObject): Promise<IObject> {
async resolve(value) {
if (typeof value !== 'string') return value;
const r = this._rs.get(value);

View file

@ -2,17 +2,15 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as childProcess from 'child_process';
import { async, signup, request, post, react, startServer, shutdownServer, waitFire } from './utils.js';
import { async, signup, request, post, react, startServer, shutdownServer, waitFire } from './utils.mjs';
describe('Mute', function() {
this.timeout(20*60*1000);
let p: childProcess.ChildProcess;
let p;
// alice mutes carol
let alice: any;
let bob: any;
let carol: any;
let alice, bob, carol;
before(async () => {
p = await startServer();
@ -41,8 +39,8 @@ describe('Mute', function() {
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
assert.strictEqual(res.body.some((note) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note) => note.id === carolNote.id), false);
}));
it('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async(async () => {
@ -86,9 +84,9 @@ describe('Mute', function() {
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
assert.strictEqual(res.body.some((note) => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some((note) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note) => note.id === carolNote.id), false);
}));
it('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async(async () => {
@ -102,9 +100,9 @@ describe('Mute', function() {
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
assert.strictEqual(res.body.some((note) => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some((note) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note) => note.id === carolNote.id), false);
}));
});
@ -118,8 +116,8 @@ describe('Mute', function() {
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
assert.strictEqual(res.body.some((notification) => notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification) => notification.userId === carol.id), false);
}));
});
});

View file

@ -2,17 +2,16 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as childProcess from 'child_process';
import { Note } from '../src/models/entities/note.js';
import { async, signup, request, post, uploadUrl, startServer, shutdownServer, initTestDb, api } from './utils.js';
import { Note } from '../built/models/entities/note.js';
import { async, signup, request, post, uploadUrl, startServer, shutdownServer, initTestDb, api } from './utils.mjs';
describe('Note', function() {
this.timeout(20*60*1000);
let p: childProcess.ChildProcess;
let Notes: any;
let p;
let Notes;
let alice: any;
let bob: any;
let alice, bob;
before(async () => {
p = await startServer();

View file

@ -1,5 +1,5 @@
import * as assert from 'assert';
import { query } from '../../src/prelude/url.js';
import { query } from '../../built/prelude/url.js';
describe('url', () => {
it('query', () => {

View file

@ -1,83 +0,0 @@
/*
import * as assert from 'assert';
import { toDbReaction } from '../src/misc/reaction-lib.js';
describe('toDbReaction', async () => {
it('既存の文字列リアクションはそのまま', async () => {
assert.strictEqual(await toDbReaction('like'), 'like');
});
it('Unicodeプリンは寿司化不能とするため文字列化しない', async () => {
assert.strictEqual(await toDbReaction('🍮'), '🍮');
});
it('プリン以外の既存のリアクションは文字列化する like', async () => {
assert.strictEqual(await toDbReaction('👍'), 'like');
});
it('プリン以外の既存のリアクションは文字列化する love', async () => {
assert.strictEqual(await toDbReaction('❤️'), 'love');
});
it('プリン以外の既存のリアクションは文字列化する love 異体字セレクタなし', async () => {
assert.strictEqual(await toDbReaction('❤'), 'love');
});
it('プリン以外の既存のリアクションは文字列化する laugh', async () => {
assert.strictEqual(await toDbReaction('😆'), 'laugh');
});
it('プリン以外の既存のリアクションは文字列化する hmm', async () => {
assert.strictEqual(await toDbReaction('🤔'), 'hmm');
});
it('プリン以外の既存のリアクションは文字列化する surprise', async () => {
assert.strictEqual(await toDbReaction('😮'), 'surprise');
});
it('プリン以外の既存のリアクションは文字列化する congrats', async () => {
assert.strictEqual(await toDbReaction('🎉'), 'congrats');
});
it('プリン以外の既存のリアクションは文字列化する angry', async () => {
assert.strictEqual(await toDbReaction('💢'), 'angry');
});
it('プリン以外の既存のリアクションは文字列化する confused', async () => {
assert.strictEqual(await toDbReaction('😥'), 'confused');
});
it('プリン以外の既存のリアクションは文字列化する rip', async () => {
assert.strictEqual(await toDbReaction('😇'), 'rip');
});
it('それ以外はUnicodeのまま', async () => {
assert.strictEqual(await toDbReaction('🍅'), '🍅');
});
it('異体字セレクタ除去', async () => {
assert.strictEqual(await toDbReaction('㊗️'), '㊗');
});
it('異体字セレクタ除去 必要なし', async () => {
assert.strictEqual(await toDbReaction('㊗'), '㊗');
});
it('fallback - undefined', async () => {
assert.strictEqual(await toDbReaction(undefined), 'like');
});
it('fallback - null', async () => {
assert.strictEqual(await toDbReaction(null), 'like');
});
it('fallback - empty', async () => {
assert.strictEqual(await toDbReaction(''), 'like');
});
it('fallback - unknown', async () => {
assert.strictEqual(await toDbReaction('unknown'), 'like');
});
});
*/

View file

@ -3,17 +3,15 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as childProcess from 'child_process';
import * as sinon from 'sinon';
import { async, signup, startServer, shutdownServer, initTestDb } from '../utils.js';
import { async, signup, startServer, shutdownServer, initTestDb } from '../utils.mjs';
describe('Creating a block activity', function() {
this.timeout(20*60*1000);
let p: childProcess.ChildProcess;
let p;
// alice blocks bob
let alice: any;
let bob: any;
let carol: any;
let alice, bob, carol;
before(async () => {
await initTestDb();
@ -34,10 +32,10 @@ describe('Creating a block activity', function() {
});
it('Should federate blocks normally', async(async () => {
const createBlock = (await import('../../src/services/blocking/create')).default;
const deleteBlock = (await import('../../src/services/blocking/delete')).default;
const createBlock = (await import('../../built/services/blocking/create')).default;
const deleteBlock = (await import('../../built/services/blocking/delete')).default;
const queues = await import('../../src/queue/index');
const queues = await import('../../built/queue/index');
const spy = sinon.spy(queues, 'deliver');
await createBlock(alice, bob);
assert(spy.calledOnce);
@ -46,12 +44,12 @@ describe('Creating a block activity', function() {
}));
it('Should not federate blocks if federateBlocks is false', async () => {
const createBlock = (await import('../../src/services/blocking/create')).default;
const deleteBlock = (await import('../../src/services/blocking/delete')).default;
const createBlock = (await import('../../built/services/blocking/create')).default;
const deleteBlock = (await import('../../built/services/blocking/delete')).default;
alice.federateBlocks = true;
const queues = await import('../../src/queue/index');
const queues = await import('../../built/queue/index');
const spy = sinon.spy(queues, 'deliver');
await createBlock(alice, carol);
await deleteBlock(alice, carol);

View file

@ -2,14 +2,14 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as childProcess from 'child_process';
import { Following } from '../src/models/entities/following.js';
import { connectStream, signup, api, post, startServer, shutdownServer, initTestDb, waitFire } from './utils.js';
import { Following } from '../built/models/entities/following.js';
import { connectStream, signup, api, post, startServer, shutdownServer, initTestDb, waitFire } from './utils.mjs';
describe('Streaming', () => {
let p: childProcess.ChildProcess;
let Followings: any;
let p;
let Followings;
const follow = async (follower: any, followee: any) => {
const follow = async (follower, followee) => {
await Followings.save({
id: 'a',
createdAt: new Date(),
@ -28,16 +28,12 @@ describe('Streaming', () => {
this.timeout(20*60*1000);
// Local users
let ayano: any;
let kyoko: any;
let chitose: any;
let ayano, kyoko, chitose;
// Remote users
let akari: any;
let chinatsu: any;
let akari, chinatsu;
let kyokoNote: any;
let list: any;
let kyokoNote, list;
before(async () => {
p = await startServer();
@ -388,7 +384,7 @@ describe('Streaming', () => {
});
describe('Hashtag Timeline', () => {
it('指定したハッシュタグの投稿が流れる', () => new Promise<void>(async done => {
it('指定したハッシュタグの投稿が流れる', () => new Promise(async done => {
const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
if (type == 'note') {
assert.deepStrictEqual(body.text, '#foo');
@ -406,7 +402,7 @@ describe('Streaming', () => {
});
}));
it('指定したハッシュタグの投稿が流れる (AND)', () => new Promise<void>(async done => {
it('指定したハッシュタグの投稿が流れる (AND)', () => new Promise(async done => {
let fooCount = 0;
let barCount = 0;
let fooBarCount = 0;
@ -444,7 +440,7 @@ describe('Streaming', () => {
}, 3000);
}));
it('指定したハッシュタグの投稿が流れる (OR)', () => new Promise<void>(async done => {
it('指定したハッシュタグの投稿が流れる (OR)', () => new Promise(async done => {
let fooCount = 0;
let barCount = 0;
let fooBarCount = 0;
@ -490,7 +486,7 @@ describe('Streaming', () => {
}, 3000);
}));
it('指定したハッシュタグの投稿が流れる (AND + OR)', () => new Promise<void>(async done => {
it('指定したハッシュタグの投稿が流れる (AND + OR)', () => new Promise(async done => {
let fooCount = 0;
let barCount = 0;
let fooBarCount = 0;

View file

@ -2,16 +2,14 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as childProcess from 'child_process';
import { async, signup, request, post, react, connectStream, startServer, shutdownServer } from './utils.js';
import { async, signup, request, post, react, connectStream, startServer, shutdownServer } from './utils.mjs';
describe('Note thread mute', function() {
this.timeout(20*60*1000);
let p: childProcess.ChildProcess;
let p;
let alice: any;
let bob: any;
let carol: any;
let alice, bob, carol;
before(async () => {
p = await startServer();
@ -37,9 +35,9 @@ describe('Note thread mute', function() {
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolReply.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolReplyWithoutMention.id), false);
assert.strictEqual(res.body.some((note) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note) => note.id === carolReply.id), false);
assert.strictEqual(res.body.some((note) => note.id === carolReplyWithoutMention.id), false);
}));
it('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async(async () => {
@ -97,8 +95,8 @@ describe('Note thread mute', function() {
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.note.id === carolReply.id), false);
assert.strictEqual(res.body.some((notification: any) => notification.note.id === carolReplyWithoutMention.id), false);
assert.strictEqual(res.body.some((notification) => notification.note.id === carolReply.id), false);
assert.strictEqual(res.body.some((notification) => notification.note.id === carolReplyWithoutMention.id), false);
// NOTE: bobの投稿はスレッドミュート前に行われたため通知に含まれていてもよい
}));

View file

@ -1,41 +0,0 @@
{
"compilerOptions": {
"allowJs": true,
"noEmitOnError": false,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedParameters": false,
"noUnusedLocals": true,
"noFallthroughCasesInSwitch": true,
"declaration": false,
"sourceMap": true,
"target": "es2017",
"module": "es2020",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"removeComments": false,
"noLib": false,
"strict": true,
"strictNullChecks": true,
"strictPropertyInitialization": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true,
"isolatedModules": true,
"baseUrl": "./",
"paths": {
"@/*": ["../src/*"]
},
"typeRoots": [
"../node_modules/@types",
"../src/@types"
],
"lib": [
"esnext"
]
},
"compileOnSave": false,
"include": [
"./**/*.ts"
]
}

View file

@ -2,17 +2,14 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as childProcess from 'child_process';
import { async, signup, request, post, uploadUrl, startServer, shutdownServer } from './utils.js';
import { async, signup, request, post, uploadUrl, startServer, shutdownServer } from './utils.mjs';
describe('users/notes', function() {
this.timeout(20*60*1000);
let p: childProcess.ChildProcess;
let p;
let alice: any;
let jpgNote: any;
let pngNote: any;
let jpgPngNote: any;
let alice, jpgNote, pngNote, jpgPngNote;
before(async () => {
p = await startServer();
@ -43,8 +40,8 @@ describe('users/notes', function() {
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 2);
assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true);
assert.strictEqual(res.body.some((note) => note.id === jpgNote.id), true);
assert.strictEqual(res.body.some((note) => note.id === jpgPngNote.id), true);
}));
it('ファイルタイプ指定 (jpg or png)', async(async () => {
@ -56,8 +53,8 @@ describe('users/notes', function() {
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 3);
assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === pngNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true);
assert.strictEqual(res.body.some((note) => note.id === jpgNote.id), true);
assert.strictEqual(res.body.some((note) => note.id === pngNote.id), true);
assert.strictEqual(res.body.some((note) => note.id === jpgPngNote.id), true);
}));
});

View file

@ -10,8 +10,8 @@ import * as foundkey from 'foundkey-js';
import fetch from 'node-fetch';
import FormData from 'form-data';
import { DataSource } from 'typeorm';
import { loadConfig } from '../src/config/load.js';
import { entities } from '../src/db/postgre.js';
import { loadConfig } from '../built/config/load.js';
import { entities } from '../built/db/postgre.js';
import got from 'got';
const _filename = fileURLToPath(import.meta.url);
@ -20,20 +20,20 @@ const _dirname = dirname(_filename);
const config = loadConfig();
export const port = config.port;
export const async = (fn: Function) => (done: Function) => {
export const async = (fn) => (done) => {
fn().then(() => {
done();
}, (err: Error) => {
}, (err) => {
done(err);
});
};
export const api = async (endpoint: string, params: any, me?: any) => {
export const api = async (endpoint, params, me) => {
endpoint = endpoint.replace(/^\//, '');
const auth = me ? { authorization: `Bearer ${me.token}` } : {};
const res = await got<string>(`http://localhost:${port}/api/${endpoint}`, {
const res = await got(`http://localhost:${port}/api/${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -63,7 +63,7 @@ export const api = async (endpoint: string, params: any, me?: any) => {
};
};
export const request = async (endpoint: string, params: any, me?: any): Promise<{ body: any, status: number }> => {
export const request = async (endpoint, params, me) => {
const auth = me ? { authorization: `Bearer ${me.token}` } : {};
const res = await fetch(`http://localhost:${port}/api${endpoint}`, {
@ -83,7 +83,7 @@ export const request = async (endpoint: string, params: any, me?: any): Promise<
};
};
export const signup = async (params?: any): Promise<any> => {
export const signup = async (params) => {
const q = Object.assign({
username: 'test',
password: 'test',
@ -94,7 +94,7 @@ export const signup = async (params?: any): Promise<any> => {
return res.body;
};
export const post = async (user: any, params?: foundkey.Endpoints['notes/create']['req']): Promise<foundkey.entities.Note> => {
export const post = async (user, params) => {
const q = Object.assign({
text: 'test',
}, params);
@ -104,7 +104,7 @@ export const post = async (user: any, params?: foundkey.Endpoints['notes/create'
return res.body ? res.body.createdNote : null;
};
export const react = async (user: any, note: any, reaction: string): Promise<any> => {
export const react = async (user, note, reaction) => {
await api('notes/reactions/create', {
noteId: note.id,
reaction: reaction,
@ -116,10 +116,10 @@ export const react = async (user: any, note: any, reaction: string): Promise<any
* @param user User
* @param _path Optional, absolute path or relative from ./resources/
*/
export const uploadFile = async (user: any, _path?: string): Promise<any> => {
export const uploadFile = async (user, _path) => {
const absPath = _path == null ? `${_dirname}/resources/Lenna.jpg` : path.isAbsolute(_path) ? _path : `${_dirname}/resources/${_path}`;
const formData = new FormData() as any;
const formData = new FormData();
formData.append('i', user.token);
formData.append('file', fs.createReadStream(absPath));
formData.append('force', 'true');
@ -137,8 +137,8 @@ export const uploadFile = async (user: any, _path?: string): Promise<any> => {
return body;
};
export const uploadUrl = async (user: any, url: string) => {
let file: any;
export const uploadUrl = async (user, url) => {
let file;
const ws = await connectStream(user, 'main', (msg) => {
if (msg.type === 'driveFileCreated') {
@ -157,7 +157,7 @@ export const uploadUrl = async (user: any, url: string) => {
return file;
};
export function connectStream(user: any, channel: string, listener: (message: Record<string, any>) => any, params?: any): Promise<WebSocket> {
export function connectStream(user, channel, listener, params) {
return new Promise((res, rej) => {
const ws = new WebSocket(`ws://localhost:${port}/streaming?i=${user.token}`);
@ -184,11 +184,11 @@ export function connectStream(user: any, channel: string, listener: (message: Re
});
}
export const waitFire = async (user: any, channel: string, trgr: () => any, cond: (msg: Record<string, any>) => boolean, params?: any) => {
return new Promise<boolean>(async (res, rej) => {
let timer: NodeJS.Timeout;
export const waitFire = async (user, channel, trgr, cond, params) => {
return new Promise(async (res, rej) => {
let timer;
let ws: WebSocket;
let ws;
try {
ws = await connectStream(user, channel, msg => {
if (cond(msg)) {
@ -201,7 +201,7 @@ export const waitFire = async (user: any, channel: string, trgr: () => any, cond
rej(e);
}
if (!ws!) return;
if (!ws) return;
timer = setTimeout(() => {
ws.close();
@ -218,7 +218,7 @@ export const waitFire = async (user: any, channel: string, trgr: () => any, cond
})
};
export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status?: number, type?: string, location?: string }> => {
export const simpleGet = async (path, accept = '*/*') => {
// node-fetchだと3xxを取れない
return await new Promise((resolve, reject) => {
const req = http.request(`http://localhost:${port}${path}`, {
@ -226,7 +226,7 @@ export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status?
Accept: accept,
},
}, res => {
if (res.statusCode! >= 400) {
if (res.statusCode >= 400) {
reject(res);
} else {
resolve({
@ -241,8 +241,8 @@ export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status?
});
};
export function launchServer(callbackSpawnedProcess: (p: childProcess.ChildProcess) => void, moreProcess: () => Promise<void> = async () => {}) {
return (done: (err?: Error) => any) => {
export function launchServer(callbackSpawnedProcess, moreProcess = async () => {}) {
return (done) => {
const p = childProcess.spawn('node', [_dirname + '/../index.js'], {
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
env: { NODE_ENV: 'test', PATH: process.env.PATH },
@ -254,7 +254,7 @@ export function launchServer(callbackSpawnedProcess: (p: childProcess.ChildProce
};
}
export async function initTestDb(justBorrow = false, initEntities?: any[]) {
export async function initTestDb(justBorrow = false, initEntities) {
if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test';
const db = new DataSource({
@ -274,7 +274,7 @@ export async function initTestDb(justBorrow = false, initEntities?: any[]) {
return db;
}
export function startServer(timeout = 60 * 1000): Promise<childProcess.ChildProcess> {
export function startServer(timeout = 60 * 1000) {
return new Promise((res, rej) => {
const t = setTimeout(() => {
p.kill(SIGKILL);
@ -297,7 +297,7 @@ export function startServer(timeout = 60 * 1000): Promise<childProcess.ChildProc
});
}
export function shutdownServer(p: childProcess.ChildProcess, timeout = 20 * 1000) {
export function shutdownServer(p, timeout = 20 * 1000) {
return new Promise((res, rej) => {
const t = setTimeout(() => {
p.kill(SIGKILL);
@ -313,8 +313,8 @@ export function shutdownServer(p: childProcess.ChildProcess, timeout = 20 * 1000
});
}
export function sleep(msec: number) {
return new Promise<void>(res => {
export function sleep(msec) {
return new Promise(res => {
setTimeout(() => {
res();
}, msec);

View file

@ -1,6 +1,6 @@
{
"name": "client",
"version": "13.0.0-preview5",
"version": "13.0.0-preview6",
"private": true,
"scripts": {
"watch": "vite build --watch --mode development",

View file

@ -6,7 +6,7 @@
<XNoteHeader class="header" :note="note" :mini="true"/>
<div class="body">
<p v-if="note.cw != null" class="cw">
<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :custom-emojis="note.emojis"/>
<XCwButton v-model="showContent" :note="note"/>
</p>
<div v-show="note.cw == null || showContent" class="content">

View file

@ -175,6 +175,9 @@ withDefaults(defineProps<{
<style lang="scss" scoped>
.havbbuyv {
white-space: pre-wrap;
display: inline-block;
vertical-align: top;
overflow: hidden;
&.nowrap {
white-space: pre;

View file

@ -49,19 +49,19 @@
<div class="main">
<div class="body">
<p v-if="appearNote.cw != null" class="cw">
<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :custom-emojis="appearNote.emojis"/>
<XCwButton v-model="showContent" :note="appearNote"/>
</p>
<div v-show="appearNote.cw == null || showContent" class="content">
<div class="text">
<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :custom-emojis="appearNote.emojis"/>
<a v-if="appearNote.renote != null" class="rp">RN:</a>
<div v-if="translating || translation" class="translation">
<MkLoading v-if="translating" mini/>
<div v-else class="translated">
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
<Mfm :text="translation.text" :author="appearNote.user" :custom-emojis="appearNote.emojis"/>
</div>
</div>
</div>
@ -79,6 +79,12 @@
<MkA class="created-at" :to="notePage(appearNote)">
<MkTime :time="appearNote.createdAt" mode="detail"/>
</MkA>
<!-- TODO link to edit history? -->
<div v-if="appearNote.updatedAt != null">
<i class="fas fa-pencil"></i>
{{ i18n.ts.updatedAt }}
<MkTime :time="appearNote.updatedAt" mode="detail"/>
</div>
</div>
<XReactionsViewer ref="reactionsViewer" :note="appearNote"/>
<button class="button _button" @click="reply()">

View file

@ -9,6 +9,9 @@
<MkA class="created-at" :to="notePage(note)">
<MkTime :time="note.createdAt"/>
</MkA>
<span class="updated-at" v-if="note.updatedAt != null" ref="updatedEl">
<i class="fas fa-pencil"></i>
</span>
<MkVisibility :note="note"/>
</div>
</header>
@ -69,6 +72,10 @@ defineProps<{
flex-shrink: 0;
margin-left: auto;
font-size: 0.9em;
> .updated-at {
margin-left: var(--marginHalf);
}
}
}
</style>

View file

@ -7,7 +7,7 @@
</div>
<div class="body">
<div class="content">
<Mfm :text="text.trim()" :author="$i" :i="$i"/>
<Mfm :text="text.trim()" :author="$i"/>
</div>
</div>
</div>

View file

@ -5,7 +5,7 @@
<XNoteHeader class="header" :note="note" :mini="true"/>
<div class="body">
<p v-if="note.cw != null" class="cw">
<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :custom-emojis="note.emojis"/>
<XCwButton v-model="showContent" :note="note"/>
</p>
<div v-show="note.cw == null || showContent" class="content">

View file

@ -37,19 +37,19 @@
<MkInstanceTicker v-if="showTicker" class="ticker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
<div class="body">
<p v-if="appearNote.cw != null" class="cw">
<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :custom-emojis="appearNote.emojis"/>
<XCwButton v-model="showContent" :note="appearNote"/>
</p>
<div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed, isLong }">
<div class="text">
<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :custom-emojis="appearNote.emojis"/>
<a v-if="appearNote.renote != null" class="rp">RN:</a>
<div v-if="translating || translation" class="translation">
<MkLoading v-if="translating" mini/>
<div v-else class="translated">
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
<Mfm :text="translation.text" :author="appearNote.user" :custom-emojis="appearNote.emojis"/>
</div>
</div>
</div>

View file

@ -3,7 +3,7 @@
<div class="body">
<span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deleted }})</span>
<MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="fas fa-reply"></i></MkA>
<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
<Mfm v-if="note.text" :text="note.text" :author="note.user" :custom-emojis="note.emojis"/>
<MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
</div>
<details v-if="note.files.length > 0">

View file

@ -8,7 +8,7 @@
</div>
<div class="description">
<div v-if="user.description" class="mfm">
<Mfm :text="user.description" :author="user" :i="$i" :custom-emojis="user.emojis"/>
<Mfm :text="user.description" :author="user" :custom-emojis="user.emojis"/>
</div>
<span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span>
</div>

View file

@ -11,7 +11,7 @@
<p class="username"><MkAcct :user="user"/></p>
</div>
<div class="description">
<Mfm v-if="user.description" :text="user.description" :author="user" :i="$i" :custom-emojis="user.emojis"/>
<Mfm v-if="user.description" :text="user.description" :author="user" :custom-emojis="user.emojis"/>
</div>
<div class="status">
<div>

View file

@ -19,7 +19,7 @@
<div class="fade"></div>
</div>
<div v-if="channel.description" class="description">
<Mfm :text="channel.description" :is-note="false" :i="$i"/>
<Mfm :text="channel.description" :is-note="false"/>
</div>
</div>

View file

@ -5,7 +5,7 @@
<div v-if="clip">
<div class="okzinsic _panel">
<div v-if="clip.description" class="description">
<Mfm :text="clip.description" :is-note="false" :i="$i"/>
<Mfm :text="clip.description" :is-note="false"/>
</div>
<div class="user">
<MkAvatar :user="clip.user" class="avatar" :show-indicator="true"/> <MkUserName :user="clip.user" :nowrap="false"/>

View file

@ -17,7 +17,7 @@
<p class="acct">@{{ acct(req.follower) }}</p>
</div>
<div v-if="req.follower.description" class="description" :title="req.follower.description">
<Mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/>
<Mfm :text="req.follower.description" :is-note="false" :author="req.follower" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/>
</div>
<div class="actions">
<button class="_button" @click="accept(req.follower)"><i class="fas fa-check"></i></button>

View file

@ -7,7 +7,7 @@
<img src="/client-assets/remove.png" alt="Delete"/>
</button>
<div v-if="!message.isDeleted" class="content">
<Mfm v-if="message.text" ref="text" class="text" :text="message.text" :i="$i"/>
<Mfm v-if="message.text" ref="text" class="text" :text="message.text"/>
<div v-if="message.file" class="file">
<a :href="message.file.url" rel="noopener" target="_blank" :title="message.file.name">
<img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/>

View file

@ -51,7 +51,7 @@
</div>
</div>
<div class="description">
<Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/>
<Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :custom-emojis="user.emojis"/>
<p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p>
</div>
<div class="fields system">
@ -74,7 +74,7 @@
<Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/>
</dt>
<dd class="value">
<Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/>
<Mfm :text="field.value" :author="user" :custom-emojis="user.emojis" :colored="false"/>
</dd>
</dl>
</div>

View file

@ -5,7 +5,7 @@
<div class="content _panel">
<div class="body">
<MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="fas fa-reply"></i></MkA>
<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
<Mfm v-if="note.text" :text="note.text" :author="note.user" :custom-emojis="note.emojis"/>
<MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
</div>
<div v-if="note.files.length > 0" class="richcontent">

View file

@ -243,7 +243,7 @@ export class ColdDeviceStorage {
plugins: [] as Plugin[],
mediaVolume: 0.5,
sound_masterVolume: 0.3,
sound_note: { type: 'syuilo/down', volume: 1 },
sound_note: { type: 'syuilo/down', volume: 0 },
sound_noteMy: { type: 'syuilo/up', volume: 1 },
sound_notification: { type: 'syuilo/pope2', volume: 1 },
sound_chat: { type: 'syuilo/pope1', volume: 1 },

View file

@ -1,6 +1,6 @@
{
"name": "foundkey-js",
"version": "13.0.0-preview5",
"version": "13.0.0-preview6",
"description": "Fork of misskey-js for Foundkey",
"type": "module",
"main": "./built/index.js",

View file

@ -1,6 +1,6 @@
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'move', 'app'] as const;
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'update', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'move', 'app'] as const;
export const noteNotificationTypes = ['mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded'] as const;
export const noteNotificationTypes = ['mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'update'] as const;
export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const;

View file

@ -33,7 +33,6 @@ export type UserLite = {
export type UserDetailed = UserLite & {
bannerBlurhash: string | null;
bannerColor: string | null;
bannerUrl: string | null;
birthday: string | null;
createdAt: DateString;
@ -130,6 +129,7 @@ export type DriveFolder = TODO;
export type Note = {
id: ID;
createdAt: DateString;
updatedAt: DateString | null;
text: string | null;
cw: string | null;
user: User;
@ -207,6 +207,11 @@ export type Notification = {
user: User;
userId: User['id'];
note: Note;
} | {
type: 'update';
user: User;
userId: User['id'];
note: Note;
} | {
type: 'follow';
user: User;

View file

@ -142,6 +142,12 @@ export type NoteUpdatedEvent = {
choice: number;
userId: User['id'];
};
} | {
id: Note['id'];
type: 'updated';
body: {
note: Note;
};
};
export type BroadcastEvents = {

View file

@ -1,6 +1,6 @@
{
"name": "sw",
"version": "13.0.0-preview5",
"version": "13.0.0-preview6",
"private": true,
"scripts": {
"watch": "node build.js watch",

365
yarn.lock
View file

@ -3717,8 +3717,8 @@ __metadata:
cli-highlight: 2.1.11
color-convert: 2.0.1
content-disposition: 0.5.4
cross-env: 7.0.3
date-fns: 2.28.0
decompress: 4.2.1
deep-email-validator: 0.1.21
escape-regexp: 0.0.1
eslint: ^8.29.0
@ -3794,7 +3794,6 @@ __metadata:
twemoji-parser: 14.0.0
typeorm: 0.3.7
typescript: ^4.9.4
unzipper: 0.10.11
uuid: 8.3.2
web-push: 3.5.0
ws: 8.8.0
@ -3861,13 +3860,6 @@ __metadata:
languageName: node
linkType: hard
"big-integer@npm:^1.6.17":
version: 1.6.51
resolution: "big-integer@npm:1.6.51"
checksum: 3d444173d1b2e20747e2c175568bedeebd8315b0637ea95d75fd27830d3b8e8ba36c6af40374f36bdaea7b5de376dcada1b07587cb2a79a928fccdb6e6e3c518
languageName: node
linkType: hard
"big.js@npm:^5.2.2":
version: 5.2.2
resolution: "big.js@npm:5.2.2"
@ -3882,16 +3874,6 @@ __metadata:
languageName: node
linkType: hard
"binary@npm:~0.3.0":
version: 0.3.0
resolution: "binary@npm:0.3.0"
dependencies:
buffers: ~0.1.1
chainsaw: ~0.1.0
checksum: b4699fda9e2c2981e74a46b0115cf0d472eda9b68c0e9d229ef494e92f29ce81acf0a834415094cffcc340dfee7c4ef8ce5d048c65c18067a7ed850323f777af
languageName: node
linkType: hard
"binaryextensions@npm:^2.2.0":
version: 2.3.0
resolution: "binaryextensions@npm:2.3.0"
@ -3899,6 +3881,16 @@ __metadata:
languageName: node
linkType: hard
"bl@npm:^1.0.0":
version: 1.2.3
resolution: "bl@npm:1.2.3"
dependencies:
readable-stream: ^2.3.5
safe-buffer: ^5.1.1
checksum: 123f097989ce2fa9087ce761cd41176aaaec864e28f7dfe5c7dab8ae16d66d9844f849c3ad688eb357e3c5e4f49b573e3c0780bb8bc937206735a3b6f8569a5f
languageName: node
linkType: hard
"bl@npm:^4.0.3":
version: 4.1.0
resolution: "bl@npm:4.1.0"
@ -3924,13 +3916,6 @@ __metadata:
languageName: node
linkType: hard
"bluebird@npm:~3.4.1":
version: 3.4.7
resolution: "bluebird@npm:3.4.7"
checksum: bffa9dee7d3a41ab15c4f3f24687b49959b4e64e55c058a062176feb8ccefc2163414fb4e1a0f3053bf187600936509660c3ebd168fd9f0e48c7eba23b019466
languageName: node
linkType: hard
"blurhash@npm:1.1.5":
version: 1.1.5
resolution: "blurhash@npm:1.1.5"
@ -4078,6 +4063,23 @@ __metadata:
languageName: node
linkType: hard
"buffer-alloc-unsafe@npm:^1.1.0":
version: 1.1.0
resolution: "buffer-alloc-unsafe@npm:1.1.0"
checksum: c5e18bf51f67754ec843c9af3d4c005051aac5008a3992938dda1344e5cfec77c4b02b4ca303644d1e9a6e281765155ce6356d85c6f5ccc5cd21afc868def396
languageName: node
linkType: hard
"buffer-alloc@npm:^1.2.0":
version: 1.2.0
resolution: "buffer-alloc@npm:1.2.0"
dependencies:
buffer-alloc-unsafe: ^1.1.0
buffer-fill: ^1.0.0
checksum: 560cd27f3cbe73c614867da373407d4506309c62fe18de45a1ce191f3785ec6ca2488d802ff82065798542422980ca25f903db078c57822218182c37c3576df5
languageName: node
linkType: hard
"buffer-crc32@npm:^0.2.1, buffer-crc32@npm:^0.2.13, buffer-crc32@npm:~0.2.3":
version: 0.2.13
resolution: "buffer-crc32@npm:0.2.13"
@ -4099,6 +4101,13 @@ __metadata:
languageName: node
linkType: hard
"buffer-fill@npm:^1.0.0":
version: 1.0.0
resolution: "buffer-fill@npm:1.0.0"
checksum: c29b4723ddeab01e74b5d3b982a0c6828f2ded49cef049ddca3dac661c874ecdbcecb5dd8380cf0f4adbeb8cff90a7de724126750a1f1e5ebd4eb6c59a1315b1
languageName: node
linkType: hard
"buffer-from@npm:^1.0.0":
version: 1.1.1
resolution: "buffer-from@npm:1.1.1"
@ -4106,13 +4115,6 @@ __metadata:
languageName: node
linkType: hard
"buffer-indexof-polyfill@npm:~1.0.0":
version: 1.0.2
resolution: "buffer-indexof-polyfill@npm:1.0.2"
checksum: fbfb2d69c6bb2df235683126f9dc140150c08ac3630da149913a9971947b667df816a913b6993bc48f4d611999cb99a1589914d34c02dccd2234afda5cb75bbc
languageName: node
linkType: hard
"buffer-writer@npm:2.0.0":
version: 2.0.0
resolution: "buffer-writer@npm:2.0.0"
@ -4131,7 +4133,7 @@ __metadata:
languageName: node
linkType: hard
"buffer@npm:^5.5.0, buffer@npm:^5.6.0":
"buffer@npm:^5.2.1, buffer@npm:^5.5.0, buffer@npm:^5.6.0":
version: 5.7.1
resolution: "buffer@npm:5.7.1"
dependencies:
@ -4151,13 +4153,6 @@ __metadata:
languageName: node
linkType: hard
"buffers@npm:~0.1.1":
version: 0.1.1
resolution: "buffers@npm:0.1.1"
checksum: ad6f8e483efab39cefd92bdc04edbff6805e4211b002f4d1cfb70c6c472a61cc89fb18c37bcdfdd4ee416ca096e9ff606286698a7d41a18b539bac12fd76d4d5
languageName: node
linkType: hard
"bull@npm:4.8.4":
version: 4.8.4
resolution: "bull@npm:4.8.4"
@ -4385,15 +4380,6 @@ __metadata:
languageName: node
linkType: hard
"chainsaw@npm:~0.1.0":
version: 0.1.0
resolution: "chainsaw@npm:0.1.0"
dependencies:
traverse: ">=0.3.0 <0.4"
checksum: 22a96b9fb0cd9fb20813607c0869e61817d1acc81b5d455cc6456b5e460ea1dd52630e0f76b291cf8294bfb6c1fc42e299afb52104af9096242699d6d3aa6d3e
languageName: node
linkType: hard
"chalk-template@npm:0.4.0":
version: 0.4.0
resolution: "chalk-template@npm:0.4.0"
@ -5042,7 +5028,7 @@ __metadata:
languageName: node
linkType: hard
"commander@npm:^2.19.0, commander@npm:^2.20.0, commander@npm:^2.20.3":
"commander@npm:^2.19.0, commander@npm:^2.20.0, commander@npm:^2.20.3, commander@npm:^2.8.1":
version: 2.20.3
resolution: "commander@npm:2.20.3"
checksum: ab8c07884e42c3a8dbc5dd9592c606176c7eb5c1ca5ff274bcf907039b2c41de3626f684ea75ccf4d361ba004bbaff1f577d5384c155f3871e456bdf27becf9e
@ -5684,6 +5670,69 @@ __metadata:
languageName: node
linkType: hard
"decompress-tar@npm:^4.0.0, decompress-tar@npm:^4.1.0, decompress-tar@npm:^4.1.1":
version: 4.1.1
resolution: "decompress-tar@npm:4.1.1"
dependencies:
file-type: ^5.2.0
is-stream: ^1.1.0
tar-stream: ^1.5.2
checksum: 42d5360b558a28dd884e1bf809e3fea92b9910fda5151add004d4a64cc76ac124e8b3e9117e805f2349af9e49c331d873e6fc5ad86a00e575703fee632b0a225
languageName: node
linkType: hard
"decompress-tarbz2@npm:^4.0.0":
version: 4.1.1
resolution: "decompress-tarbz2@npm:4.1.1"
dependencies:
decompress-tar: ^4.1.0
file-type: ^6.1.0
is-stream: ^1.1.0
seek-bzip: ^1.0.5
unbzip2-stream: ^1.0.9
checksum: 519c81337730159a1f2d7072a6ee8523ffd76df48d34f14c27cb0a27f89b4e2acf75dad2f761838e5bc63230cea1ac154b092ecb7504be4e93f7d0e32ddd6aff
languageName: node
linkType: hard
"decompress-targz@npm:^4.0.0":
version: 4.1.1
resolution: "decompress-targz@npm:4.1.1"
dependencies:
decompress-tar: ^4.1.1
file-type: ^5.2.0
is-stream: ^1.1.0
checksum: 22738f58eb034568dc50d370c03b346c428bfe8292fe56165847376b5af17d3c028fefca82db642d79cb094df4c0a599d40a8f294b02aad1d3ddec82f3fd45d4
languageName: node
linkType: hard
"decompress-unzip@npm:^4.0.1":
version: 4.0.1
resolution: "decompress-unzip@npm:4.0.1"
dependencies:
file-type: ^3.8.0
get-stream: ^2.2.0
pify: ^2.3.0
yauzl: ^2.4.2
checksum: ba9f3204ab2415bedb18d796244928a18148ef40dbb15174d0d01e5991b39536b03d02800a8a389515a1523f8fb13efc7cd44697df758cd06c674879caefd62b
languageName: node
linkType: hard
"decompress@npm:4.2.1":
version: 4.2.1
resolution: "decompress@npm:4.2.1"
dependencies:
decompress-tar: ^4.0.0
decompress-tarbz2: ^4.0.0
decompress-targz: ^4.0.0
decompress-unzip: ^4.0.1
graceful-fs: ^4.1.10
make-dir: ^1.0.0
pify: ^2.3.0
strip-dirs: ^2.0.0
checksum: 8247a31c6db7178413715fdfb35a482f019c81dfcd6e8e623d9f0382c9889ce797ce0144de016b256ed03298907a620ce81387cca0e69067a933470081436cb8
languageName: node
linkType: hard
"dedent@npm:^0.7.0":
version: 0.7.0
resolution: "dedent@npm:0.7.0"
@ -6080,15 +6129,6 @@ __metadata:
languageName: node
linkType: hard
"duplexer2@npm:~0.1.4":
version: 0.1.4
resolution: "duplexer2@npm:0.1.4"
dependencies:
readable-stream: ^2.0.2
checksum: 744961f03c7f54313f90555ac20284a3fb7bf22fdff6538f041a86c22499560eb6eac9d30ab5768054137cb40e6b18b40f621094e0261d7d8c35a37b7a5ad241
languageName: node
linkType: hard
"duplexer@npm:~0.1.1":
version: 0.1.2
resolution: "duplexer@npm:0.1.2"
@ -7531,6 +7571,27 @@ __metadata:
languageName: node
linkType: hard
"file-type@npm:^3.8.0":
version: 3.9.0
resolution: "file-type@npm:3.9.0"
checksum: 1db70b2485ac77c4edb4b8753c1874ee6194123533f43c2651820f96b518f505fa570b093fedd6672eb105ba9fb89c62f84b6492e46788e39c3447aed37afa2d
languageName: node
linkType: hard
"file-type@npm:^5.2.0":
version: 5.2.0
resolution: "file-type@npm:5.2.0"
checksum: b2b21c7fc3cfb3c6a3a18b0d5d7233b74d8c17d82757655766573951daf42962a5c809e5fc3637675b237c558ebc67e4958fb2cc5a4ad407bc545aaa40001c74
languageName: node
linkType: hard
"file-type@npm:^6.1.0":
version: 6.2.0
resolution: "file-type@npm:6.2.0"
checksum: 749540cefcd4959121eb83e373ed84e49b2e5a510aa5d598b725bd772dd306ae41fd00d3162ae3f6563b4db5cfafbbd0df321de3f20c17e20a8c56431ae55e58
languageName: node
linkType: hard
"filelist@npm:^1.0.1":
version: 1.0.4
resolution: "filelist@npm:1.0.4"
@ -7954,18 +8015,6 @@ __metadata:
languageName: node
linkType: hard
"fstream@npm:^1.0.12":
version: 1.0.12
resolution: "fstream@npm:1.0.12"
dependencies:
graceful-fs: ^4.1.2
inherits: ~2.0.0
mkdirp: ">=0.5 0"
rimraf: 2
checksum: e6998651aeb85fd0f0a8a68cec4d05a3ada685ecc4e3f56e0d063d0564a4fc39ad11a856f9020f926daf869fc67f7a90e891def5d48e4cadab875dc313094536
languageName: node
linkType: hard
"function-bind@npm:^1.1.1":
version: 1.1.1
resolution: "function-bind@npm:1.1.1"
@ -8087,6 +8136,16 @@ __metadata:
languageName: node
linkType: hard
"get-stream@npm:^2.2.0":
version: 2.3.1
resolution: "get-stream@npm:2.3.1"
dependencies:
object-assign: ^4.0.1
pinkie-promise: ^2.0.0
checksum: d82c86556e131ba7bef00233aa0aa7a51230e6deac11a971ce0f47cd43e2a5e968a3e3914cd082f07cd0d69425653b2f96735b0a7d5c5c03fef3ab857a531367
languageName: node
linkType: hard
"get-stream@npm:^5.0.0, get-stream@npm:^5.1.0":
version: 5.2.0
resolution: "get-stream@npm:5.2.0"
@ -8395,6 +8454,13 @@ __metadata:
languageName: node
linkType: hard
"graceful-fs@npm:^4.1.10":
version: 4.2.11
resolution: "graceful-fs@npm:4.2.11"
checksum: ac85f94da92d8eb6b7f5a8b20ce65e43d66761c55ce85ac96df6865308390da45a8d3f0296dd3a663de65d30ba497bd46c696cc1e248c72b13d6d567138a4fc7
languageName: node
linkType: hard
"graceful-fs@npm:^4.2.0":
version: 4.2.8
resolution: "graceful-fs@npm:4.2.8"
@ -8402,7 +8468,7 @@ __metadata:
languageName: node
linkType: hard
"graceful-fs@npm:^4.2.2, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9":
"graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9":
version: 4.2.10
resolution: "graceful-fs@npm:4.2.10"
checksum: 3f109d70ae123951905d85032ebeae3c2a5a7a997430df00ea30df0e3a6c60cf6689b109654d6fdacd28810a053348c4d14642da1d075049e6be1ba5216218da
@ -9063,7 +9129,7 @@ __metadata:
languageName: node
linkType: hard
"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.0, inherits@npm:~2.0.3":
"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3":
version: 2.0.4
resolution: "inherits@npm:2.0.4"
checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1
@ -9494,6 +9560,13 @@ __metadata:
languageName: node
linkType: hard
"is-natural-number@npm:^4.0.1":
version: 4.0.1
resolution: "is-natural-number@npm:4.0.1"
checksum: 3e5e3d52e0dfa4fea923b5d2b8a5cdbd9bf110c4598d30304b98528b02f40c9058a2abf1bae10bcbaf2bac18ace41cff7bc9673aff339f8c8297fae74ae0e75d
languageName: node
linkType: hard
"is-negated-glob@npm:^1.0.0":
version: 1.0.0
resolution: "is-negated-glob@npm:1.0.0"
@ -9619,6 +9692,13 @@ __metadata:
languageName: node
linkType: hard
"is-stream@npm:^1.1.0":
version: 1.1.0
resolution: "is-stream@npm:1.1.0"
checksum: 063c6bec9d5647aa6d42108d4c59723d2bd4ae42135a2d4db6eadbd49b7ea05b750fd69d279e5c7c45cf9da753ad2c00d8978be354d65aa9f6bb434969c6a2ae
languageName: node
linkType: hard
"is-stream@npm:^2.0.0":
version: 2.0.0
resolution: "is-stream@npm:2.0.0"
@ -11183,13 +11263,6 @@ __metadata:
languageName: node
linkType: hard
"listenercount@npm:~1.0.1":
version: 1.0.1
resolution: "listenercount@npm:1.0.1"
checksum: 0f1c9077cdaf2ebc16473c7d72eb7de6d983898ca42500f03da63c3914b6b312dd5f7a90d2657691ea25adf3fe0ac5a43226e8b2c673fd73415ed038041f4757
languageName: node
linkType: hard
"listr2@npm:^3.8.3":
version: 3.11.0
resolution: "listr2@npm:3.11.0"
@ -11502,6 +11575,15 @@ __metadata:
languageName: node
linkType: hard
"make-dir@npm:^1.0.0":
version: 1.3.0
resolution: "make-dir@npm:1.3.0"
dependencies:
pify: ^3.0.0
checksum: c564f6e7bb5ace1c02ad56b3a5f5e07d074af0c0b693c55c7b2c2b148882827c8c2afc7b57e43338a9f90c125b58d604e8cf3e6990a48bf949dfea8c79668c0b
languageName: node
linkType: hard
"make-dir@npm:^3.0.0, make-dir@npm:^3.1.0":
version: 3.1.0
resolution: "make-dir@npm:3.1.0"
@ -11947,7 +12029,7 @@ __metadata:
languageName: node
linkType: hard
"mkdirp@npm:>=0.5 0, mkdirp@npm:^0.5.4":
"mkdirp@npm:^0.5.4":
version: 0.5.6
resolution: "mkdirp@npm:0.5.6"
dependencies:
@ -13316,13 +13398,20 @@ __metadata:
languageName: node
linkType: hard
"pify@npm:^2.0.0, pify@npm:^2.2.0":
"pify@npm:^2.0.0, pify@npm:^2.2.0, pify@npm:^2.3.0":
version: 2.3.0
resolution: "pify@npm:2.3.0"
checksum: 9503aaeaf4577acc58642ad1d25c45c6d90288596238fb68f82811c08104c800e5a7870398e9f015d82b44ecbcbef3dc3d4251a1cbb582f6e5959fe09884b2ba
languageName: node
linkType: hard
"pify@npm:^3.0.0":
version: 3.0.0
resolution: "pify@npm:3.0.0"
checksum: 6cdcbc3567d5c412450c53261a3f10991665d660961e06605decf4544a61a97a54fefe70a68d5c37080ff9d6f4cf51444c90198d1ba9f9309a6c0d6e9f5c4fde
languageName: node
linkType: hard
"pify@npm:^4.0.1":
version: 4.0.1
resolution: "pify@npm:4.0.1"
@ -14426,6 +14515,21 @@ __metadata:
languageName: node
linkType: hard
"readable-stream@npm:^2.3.0":
version: 2.3.8
resolution: "readable-stream@npm:2.3.8"
dependencies:
core-util-is: ~1.0.0
inherits: ~2.0.3
isarray: ~1.0.0
process-nextick-args: ~2.0.0
safe-buffer: ~5.1.1
string_decoder: ~1.1.1
util-deprecate: ~1.0.1
checksum: 65645467038704f0c8aaf026a72fbb588a9e2ef7a75cd57a01702ee9db1c4a1e4b03aaad36861a6a0926546a74d174149c8c207527963e0c2d3eee2f37678a42
languageName: node
linkType: hard
"readable-web-to-node-stream@npm:^3.0.2":
version: 3.0.2
resolution: "readable-web-to-node-stream@npm:3.0.2"
@ -14922,17 +15026,6 @@ __metadata:
languageName: node
linkType: hard
"rimraf@npm:2":
version: 2.7.1
resolution: "rimraf@npm:2.7.1"
dependencies:
glob: ^7.1.3
bin:
rimraf: ./bin.js
checksum: cdc7f6eacb17927f2a075117a823e1c5951792c6498ebcce81ca8203454a811d4cf8900314154d3259bb8f0b42ab17f67396a8694a54cae3283326e57ad250cd
languageName: node
linkType: hard
"rimraf@npm:3.0.2, rimraf@npm:^3.0.0, rimraf@npm:^3.0.2":
version: 3.0.2
resolution: "rimraf@npm:3.0.2"
@ -15002,7 +15095,7 @@ __metadata:
languageName: node
linkType: hard
"safe-buffer@npm:5.2.1":
"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.1.1":
version: 5.2.1
resolution: "safe-buffer@npm:5.2.1"
checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491
@ -15123,6 +15216,18 @@ __metadata:
languageName: node
linkType: hard
"seek-bzip@npm:^1.0.5":
version: 1.0.6
resolution: "seek-bzip@npm:1.0.6"
dependencies:
commander: ^2.8.1
bin:
seek-bunzip: bin/seek-bunzip
seek-table: bin/seek-bzip-table
checksum: c2ab3291e7085558499efd4e99d1466ee6782f6c4a4e4c417aa859e1cd2f5117fb3b5444f3d27c38ec5908c0f0312e2a0bc69dff087751f97b3921b5bde4f9ed
languageName: node
linkType: hard
"semver-greatest-satisfied-range@npm:^1.1.0":
version: 1.1.0
resolution: "semver-greatest-satisfied-range@npm:1.1.0"
@ -15211,7 +15316,7 @@ __metadata:
languageName: node
linkType: hard
"setimmediate@npm:^1.0.5, setimmediate@npm:~1.0.4":
"setimmediate@npm:^1.0.5":
version: 1.0.5
resolution: "setimmediate@npm:1.0.5"
checksum: c9a6f2c5b51a2dabdc0247db9c46460152ffc62ee139f3157440bd48e7c59425093f42719ac1d7931f054f153e2d26cf37dfeb8da17a794a58198a2705e527fd
@ -15935,6 +16040,15 @@ __metadata:
languageName: node
linkType: hard
"strip-dirs@npm:^2.0.0":
version: 2.1.0
resolution: "strip-dirs@npm:2.1.0"
dependencies:
is-natural-number: ^4.0.1
checksum: 9465547d71d8819daa7a5c9d4d783289ed8eac72eb06bd687bed382ce62af8ab8e6ffbda229805f5d2e71acce2ca4915e781c94190d284994cbc0b7cdc8303cc
languageName: node
linkType: hard
"strip-final-newline@npm:^2.0.0":
version: 2.0.0
resolution: "strip-final-newline@npm:2.0.0"
@ -16175,6 +16289,21 @@ __metadata:
languageName: node
linkType: hard
"tar-stream@npm:^1.5.2":
version: 1.6.2
resolution: "tar-stream@npm:1.6.2"
dependencies:
bl: ^1.0.0
buffer-alloc: ^1.2.0
end-of-stream: ^1.0.0
fs-constants: ^1.0.0
readable-stream: ^2.3.0
to-buffer: ^1.1.1
xtend: ^4.0.0
checksum: a5d49e232d3e33321bbd150381b6a4e5046bf12b1c2618acb95435b7871efde4d98bd1891eb2200478a7142ef7e304e033eb29bbcbc90451a2cdfa1890e05245
languageName: node
linkType: hard
"tar-stream@npm:^2.1.4, tar-stream@npm:^2.2.0":
version: 2.2.0
resolution: "tar-stream@npm:2.2.0"
@ -16372,6 +16501,13 @@ __metadata:
languageName: node
linkType: hard
"to-buffer@npm:^1.1.1":
version: 1.1.1
resolution: "to-buffer@npm:1.1.1"
checksum: 6c897f58c2bdd8b8b1645ea515297732fec6dafb089bf36d12370c102ff5d64abf2be9410e0b1b7cfc707bada22d9a4084558010bfc78dd7023748dc5dd9a1ce
languageName: node
linkType: hard
"to-fast-properties@npm:^2.0.0":
version: 2.0.0
resolution: "to-fast-properties@npm:2.0.0"
@ -16506,13 +16642,6 @@ __metadata:
languageName: node
linkType: hard
"traverse@npm:>=0.3.0 <0.4":
version: 0.3.9
resolution: "traverse@npm:0.3.9"
checksum: 982982e4e249e9bbf063732a41fe5595939892758524bbef5d547c67cdf371b13af72b5434c6a61d88d4bb4351d6dabc6e22d832e0d16bc1bc684ef97a1cc59e
languageName: node
linkType: hard
"trim-newlines@npm:^3.0.0":
version: 3.0.1
resolution: "trim-newlines@npm:3.0.1"
@ -16964,6 +17093,16 @@ __metadata:
languageName: node
linkType: hard
"unbzip2-stream@npm:^1.0.9":
version: 1.4.3
resolution: "unbzip2-stream@npm:1.4.3"
dependencies:
buffer: ^5.2.1
through: ^2.3.8
checksum: 0e67c4a91f4fa0fc7b4045f8b914d3498c2fc2e8c39c359977708ec85ac6d6029840e97f508675fdbdf21fcb8d276ca502043406f3682b70f075e69aae626d1d
languageName: node
linkType: hard
"unc-path-regex@npm:^0.1.2":
version: 0.1.2
resolution: "unc-path-regex@npm:0.1.2"
@ -17111,24 +17250,6 @@ __metadata:
languageName: node
linkType: hard
"unzipper@npm:0.10.11":
version: 0.10.11
resolution: "unzipper@npm:0.10.11"
dependencies:
big-integer: ^1.6.17
binary: ~0.3.0
bluebird: ~3.4.1
buffer-indexof-polyfill: ~1.0.0
duplexer2: ~0.1.4
fstream: ^1.0.12
graceful-fs: ^4.2.2
listenercount: ~1.0.1
readable-stream: ~2.3.6
setimmediate: ~1.0.4
checksum: 006cd43ec4d6df47d86aa6b15044a606f50cdcd6a3d6f96f64f54ca0b663c09abb221f76edca0e9592511036d37ea094b1d76ce92c5bf10d7c6eb56f0be678f8
languageName: node
linkType: hard
"update-browserslist-db@npm:^1.0.5":
version: 1.0.5
resolution: "update-browserslist-db@npm:1.0.5"
@ -18066,7 +18187,7 @@ __metadata:
languageName: node
linkType: hard
"yauzl@npm:^2.10.0":
"yauzl@npm:^2.10.0, yauzl@npm:^2.4.2":
version: 2.10.0
resolution: "yauzl@npm:2.10.0"
dependencies: