Compare commits

..

3 commits

Author SHA1 Message Date
1d653fdb0b
server: able to store remote thread IDs 2022-10-31 14:57:03 +01:00
46629a9884
federation: add context property to notes 2022-10-31 14:55:42 +01:00
457bd5ee3f
refactor: properly migrate thread ids
Commit fc65190ef7 introduced thread ids but
did not add the thread id to previous notes. This is fixed by this commit.

The original notes in each thread should also have a thread id set which will
be the same as their ID.

These two migrations allow the threadId column be a NOT NULL column.
2022-10-31 14:55:35 +01:00
57 changed files with 504 additions and 517 deletions

View file

@ -297,11 +297,8 @@ PostgreSQL array indices **start at 1**.
When `IN` is performed on a column that may contain `NULL` values, use `OR` or similar to handle `NULL` values.
### creating migrations
First make changes to the entity files in `packages/backend/src/models/entities/`.
Then, in `packages/backend`, run:
In `packages/backend`, run:
```sh
yarn build
npx typeorm migration:generate -d ormconfig.js -o <migration name>
```

View file

@ -41,7 +41,7 @@ git merge tags/v13.0.0-preview2 --squash
```
## Making sure modern Yarn works
Foundkey uses Modern Yarn instead of Classic (1.x). To make sure the `yarn` command will work going forward, run `corepack enable`.
Foundkey uses Yarn 3.2.3 instead of 1.x. To make sure the `yarn` command will work going forward, run `corepack enable`.
## Rebuilding and running database migrations
This will be pretty much the same as a regular update of Misskey. Note that `yarn install` may take a while since dependency versions have been updated or removed and we use a newer version of Yarn.

View file

@ -190,9 +190,7 @@ charts: "Charts"
perHour: "Per Hour"
perDay: "Per Day"
stopActivityDelivery: "Stop sending activities"
stopActivityDeliveryDescription: "Local activities will not be sent to this instance. Receiving activities works as before."
blockThisInstance: "Block this instance"
blockThisInstanceDescription: "Local activites will not be sent to this instance. Activites from this instance will be discarded."
operations: "Operations"
software: "Software"
version: "Version"

View file

@ -1,5 +1,5 @@
export class removeAds1657570176749 {
name = 'removeAds1657570176749';
name = 'removeAds1657570176749'
async up(queryRunner) {
await queryRunner.query(`DROP TABLE "ad"`);

View file

@ -0,0 +1,30 @@
export class fixThreadId1659446758000 {
name = 'fixThreadId1659446758000'
async up(queryRunner) {
await Promise.all([
queryRunner.query(`UPDATE "note" SET "threadId" = "id" WHERE "replyId" IS NULL`),
queryRunner.query(`WITH "threads" ("noteId", "thread") AS (
SELECT "id" as "noteId", "parent" as "thread" FROM (
WITH RECURSIVE "parents" ("id", "parent", "height") AS (
SELECT "id", "replyId", 0 FROM "note" WHERE "replyId" IS NOT NULL AND "threadId" IS NULL
UNION ALL
SELECT "parents"."id", "note"."replyId", "parents"."height" + 1
FROM "parents"
JOIN "note" ON "parents"."parent" = "note"."id"
WHERE "note"."replyId" IS NOT NULL
) SELECT *, MAX("height") OVER (PARTITION BY "id") AS "maxheight" FROM "parents"
) AS "x"
WHERE "height" = "maxheight"
) UPDATE "note" SET "threadId" = "threads"."thread" FROM "threads" WHERE "id" = "threads"."noteId"`),
]);
await queryRunner.query(`ALTER TABLE "note" ALTER COLUMN "threadId" SET NOT NULL`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" ALTER COLUMN "threaadId" DROP NOT NULL`);
// Cannot un-fix thread id's for ones that just were not migrated (2nd query above)
// but can remove the thread ids for the root notes of each thread.
await queryRunner.query(`UPDATE "note" SET "threadId" = NULL WHERE "replyId" IS NULL`);
}
}

View file

@ -0,0 +1,17 @@
import config from '../built/config/index.js';
export class remoteThreadIds1667212446191 {
name = 'remoteThreadIds1667212446191';
async up(queryRunner) {
await Promise.all([
queryRunner.query(`UPDATE "note" SET "threadId" = '${config.url}/notes/' + "threadId"`),
queryRunner.query(`UPDATE "note_thread_muting" SET "threadId" = '${config.url}/notes/' + "threadId"`),
]);
}
async down() {
// cannot be undone:
// after this migration other instances threadIds may be stored in the database
}
}

View file

@ -1,44 +0,0 @@
export class sync1667503570994 {
name = 'sync1667503570994'
async up(queryRunner) {
await Promise.all([
// the migration for renote mutes added the index to the wrong table
queryRunner.query(`DROP INDEX "public"."IDX_renote_muting_createdAt"`),
queryRunner.query(`DROP INDEX "public"."IDX_renote_muting_muteeId"`),
queryRunner.query(`DROP INDEX "public"."IDX_renote_muting_muterId"`),
queryRunner.query(`CREATE INDEX "IDX_d1259a2c2b7bb413ff449e8711" ON "renote_muting" ("createdAt") `),
queryRunner.query(`CREATE INDEX "IDX_7eac97594bcac5ffcf2068089b" ON "renote_muting" ("muteeId") `),
queryRunner.query(`CREATE INDEX "IDX_7aa72a5fe76019bfe8e5e0e8b7" ON "renote_muting" ("muterId") `),
queryRunner.query(`COMMENT ON COLUMN "renote_muting"."createdAt" IS 'The created date of the Muting.'`),
queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muteeId" IS 'The mutee user ID.'`),
queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muterId" IS 'The muter user ID.'`),
queryRunner.query(`ALTER TABLE "page" ALTER COLUMN "text" SET NOT NULL`),
queryRunner.query(`ALTER TABLE "page" ALTER COLUMN "text" SET DEFAULT ''`),
queryRunner.query(`CREATE UNIQUE INDEX "IDX_0d801c609cec4e9eb4b6b4490c" ON "renote_muting" ("muterId", "muteeId") `),
queryRunner.query(`ALTER TABLE "renote_muting" ADD CONSTRAINT "FK_7eac97594bcac5ffcf2068089b6" FOREIGN KEY ("muteeId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`),
queryRunner.query(`ALTER TABLE "renote_muting" ADD CONSTRAINT "FK_7aa72a5fe76019bfe8e5e0e8b7d" FOREIGN KEY ("muterId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`),
]);
}
async down(queryRunner) {
await Promise.all([
queryRunner.query(`ALTER TABLE "renote_muting" DROP CONSTRAINT "FK_7aa72a5fe76019bfe8e5e0e8b7d"`),
queryRunner.query(`ALTER TABLE "renote_muting" DROP CONSTRAINT "FK_7eac97594bcac5ffcf2068089b6"`),
queryRunner.query(`DROP INDEX "public"."IDX_0d801c609cec4e9eb4b6b4490c"`),
queryRunner.query(`ALTER TABLE "page" ALTER COLUMN "text" DROP DEFAULT`),
queryRunner.query(`ALTER TABLE "page" ALTER COLUMN "text" DROP NOT NULL`),
queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muterId" IS NULL`),
queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muteeId" IS NULL`),
queryRunner.query(`COMMENT ON COLUMN "renote_muting"."createdAt" IS NULL`),
queryRunner.query(`DROP INDEX "public"."IDX_7aa72a5fe76019bfe8e5e0e8b7"`),
queryRunner.query(`DROP INDEX "public"."IDX_7eac97594bcac5ffcf2068089b"`),
queryRunner.query(`DROP INDEX "public"."IDX_d1259a2c2b7bb413ff449e8711"`),
queryRunner.query(`CREATE INDEX "IDX_renote_muting_muterId" ON "muting" ("muterId") `),
queryRunner.query(`CREATE INDEX "IDX_renote_muting_muteeId" ON "muting" ("muteeId") `),
queryRunner.query(`CREATE INDEX "IDX_renote_muting_createdAt" ON "muting" ("createdAt") `),
]);
}
}

View file

@ -96,7 +96,7 @@
"rss-parser": "3.12.0",
"sanitize-html": "2.7.0",
"semver": "7.3.7",
"sharp": "0.31.2",
"sharp": "0.30.7",
"speakeasy": "2.0.0",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",

View file

@ -10,7 +10,7 @@ function getRedisFamily(family?: string | number): number {
dual: 0,
};
if (typeof family === 'string' && family in familyMap) {
return familyMap[family as keyof typeof familyMap];
return familyMap[family];
} else if (typeof family === 'number' && Object.values(familyMap).includes(family)) {
return family;
}

View file

@ -62,21 +62,22 @@ export function fromHtml(html: string, hashtagNames?: string[]): string {
const rel = node.attrs.find(x => x.name === 'rel');
const href = node.attrs.find(x => x.name === 'href');
// hashtags
// ハッシュタグ
if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) {
text += txt;
// mentions
// メンション
} else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) {
const part = txt.split('@');
if (part.length === 2 && href) {
// restore the host name part
//#region ホスト名部分が省略されているので復元する
const acct = `${txt}@${(new URL(href.value)).hostname}`;
text += acct;
//#endregion
} else if (part.length === 3) {
text += txt;
}
// other
// その他
} else {
const generateLink = () => {
if (!href && !txt) {

View file

@ -1,12 +1,10 @@
export class Cache<T> {
public cache: Map<string | null, { date: number; value: T; }>;
private lifetime: number;
public fetcher: (key: string | null) => Promise<T | undefined>;
constructor(lifetime: number, fetcher: Cache<T>['fetcher']) {
constructor(lifetime: Cache<never>['lifetime']) {
this.cache = new Map();
this.lifetime = lifetime;
this.fetcher = fetcher;
}
public set(key: string | null, value: T): void {
@ -19,13 +17,10 @@ export class Cache<T> {
public get(key: string | null): T | undefined {
const cached = this.cache.get(key);
if (cached == null) return undefined;
// discard if past the cache lifetime
if ((Date.now() - cached.date) > this.lifetime) {
this.cache.delete(key);
return undefined;
}
return cached.value;
}
@ -34,22 +29,52 @@ export class Cache<T> {
}
/**
* If the value is cached, it is returned. Otherwise the fetcher is
* run to get the value. If the fetcher returns undefined, it is
* returned but not cached.
* fetcherを呼び出して結果をキャッシュ&
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
*/
public async fetch(key: string | null): Promise<T | undefined> {
const cached = this.get(key);
if (cached !== undefined) {
return cached;
} else {
const value = await this.fetcher(key);
// don't cache undefined
if (value !== undefined)
this.set(key, value);
return value;
public async fetch(key: string | null, fetcher: () => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> {
const cachedValue = this.get(key);
if (cachedValue !== undefined) {
if (validator) {
if (validator(cachedValue)) {
// Cache HIT
return cachedValue;
}
} else {
// Cache HIT
return cachedValue;
}
}
// Cache MISS
const value = await fetcher();
this.set(key, value);
return value;
}
/**
* fetcherを呼び出して結果をキャッシュ&
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
*/
public async fetchMaybe(key: string | null, fetcher: () => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> {
const cachedValue = this.get(key);
if (cachedValue !== undefined) {
if (validator) {
if (validator(cachedValue)) {
// Cache HIT
return cachedValue;
}
} else {
// Cache HIT
return cachedValue;
}
}
// Cache MISS
const value = await fetcher();
if (value !== undefined) {
this.set(key, value);
}
return value;
}
}

View file

@ -3,26 +3,22 @@ import { Note } from '@/models/entities/note.js';
import { User } from '@/models/entities/user.js';
import { UserListJoinings, UserGroupJoinings, Blockings } from '@/models/index.js';
import * as Acct from '@/misc/acct.js';
import { MINUTE } from '@/const.js';
import { getFullApAccount } from './convert-host.js';
import { Packed } from './schema.js';
import { Cache } from './cache.js';
const blockingCache = new Cache<User['id'][]>(
5 * MINUTE,
(blockerId) => Blockings.findBy({ blockerId }).then(res => res.map(x => x.blockeeId)),
);
const blockingCache = new Cache<User['id'][]>(1000 * 60 * 5);
// designation for users you follow, list users and groups is disabled for performance reasons
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
/**
* either noteUserFollowers or antennaUserFollowing must be specified
* noteUserFollowers / antennaUserFollowing
*/
export async function checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }, noteUserFollowers?: User['id'][], antennaUserFollowing?: User['id'][]): Promise<boolean> {
if (note.visibility === 'specified') return false;
// skip if the antenna creator is blocked by the note author
const blockings = await blockingCache.fetch(noteUser.id);
// アンテナ作成者がノート作成者にブロックされていたらスキップ
const blockings = await blockingCache.fetch(noteUser.id, () => Blockings.findBy({ blockerId: noteUser.id }).then(res => res.map(x => x.blockeeId)));
if (blockings.some(blocking => blocking === antenna.userId)) return false;
if (note.visibility === 'followers') {

View file

@ -1,44 +1,44 @@
import push from 'web-push';
import { db } from '@/db/postgre.js';
import { Meta } from '@/models/entities/meta.js';
import { getFetchInstanceMetadataLock } from '@/misc/app-lock.js';
let cache: Meta;
/**
* Performs the primitive database operation to set the server configuration
*/
export async function setMeta(meta: Meta): Promise<void> {
const unlock = await getFetchInstanceMetadataLock('localhost');
// try to mitigate older bugs where multiple meta entries may have been created
db.manager.clear(Meta);
db.manager.insert(Meta, meta);
unlock();
}
/**
* Performs the primitive database operation to fetch server configuration.
* Writes to `cache` instead of returning.
*/
async function getMeta(): Promise<void> {
const unlock = await getFetchInstanceMetadataLock('localhost');
// new IDs are prioritised because multiple records may have been created due to past bugs
cache = db.manager.findOne(Meta, {
order: {
id: 'DESC',
},
});
unlock();
}
export async function fetchMeta(noCache = false): Promise<Meta> {
if (!noCache && cache) return cache;
await getMeta();
return await db.transaction(async transactionalEntityManager => {
// 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する
const metas = await transactionalEntityManager.find(Meta, {
order: {
id: 'DESC',
},
});
return cache;
const meta = metas[0];
if (meta) {
cache = meta;
return meta;
} else {
// metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う
const saved = await transactionalEntityManager
.upsert(
Meta,
{
id: 'x',
},
['id'],
)
.then((x) => transactionalEntityManager.findOneByOrFail(Meta, x.identifiers[0]));
cache = saved;
return saved;
}
});
}
setInterval(() => {
fetchMeta(true).then(meta => {
cache = meta;
});
}, 1000 * 10);

View file

@ -3,11 +3,8 @@ import { User } from '@/models/entities/user.js';
import { UserKeypair } from '@/models/entities/user-keypair.js';
import { Cache } from './cache.js';
const cache = new Cache<UserKeypair>(
Infinity,
(userId) => UserKeypairs.findOneByOrFail({ userId }),
);
const cache = new Cache<UserKeypair>(Infinity);
export async function getUserKeypair(userId: User['id']): Promise<UserKeypair> {
return await cache.fetch(userId);
return await cache.fetch(userId, () => UserKeypairs.findOneByOrFail({ userId }));
}

View file

@ -4,27 +4,14 @@ import { Emojis } from '@/models/index.js';
import { Emoji } from '@/models/entities/emoji.js';
import { Note } from '@/models/entities/note.js';
import { query } from '@/prelude/url.js';
import { HOUR } from '@/const.js';
import { Cache } from './cache.js';
import { isSelfHost, toPunyNullable } from './convert-host.js';
import { decodeReaction } from './reaction-lib.js';
/**
* composite cache key: `${host ?? ''}:${name}`
*/
const cache = new Cache<Emoji | null>(
12 * HOUR,
async (key) => {
const [host, name] = key.split(':');
return (await Emojis.findOneBy({
name,
host: host || IsNull(),
})) || null;
},
);
const cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12);
/**
* Information needed to attach in ActivityPub
*
*/
type PopulatedEmoji = {
name: string;
@ -49,22 +36,28 @@ function parseEmojiStr(emojiName: string, noteUserHost: string | null) {
const name = match[1];
// ホスト正規化
const host = toPunyNullable(normalizeHost(match[2], noteUserHost));
return { name, host };
}
/**
* Resolve emoji information from ActivityPub attachment.
* @param emojiName custom emoji names attached to notes, user profiles or in rections. Colons should not be included. Localhost is denote by @. (see also `decodeReaction`)
* @param noteUserHost host that the content is from, to default to
* @returns emoji information. `null` means not found.
*
* @param emojiName (:, @. (decodeReactionで可能))
* @param noteUserHost
* @returns , nullは未マッチを意味する
*/
export async function populateEmoji(emojiName: string, noteUserHost: string | null): Promise<PopulatedEmoji | null> {
const { name, host } = parseEmojiStr(emojiName, noteUserHost);
if (name == null) return null;
const emoji = await cache.fetch(`${host ?? ''}:${name}`);
const queryOrNull = async () => (await Emojis.findOneBy({
name,
host: host ?? IsNull(),
})) || null;
const emoji = await cache.fetch(`${name} ${host}`, queryOrNull);
if (emoji == null) return null;
@ -79,7 +72,7 @@ export async function populateEmoji(emojiName: string, noteUserHost: string | nu
}
/**
* Retrieve list of emojis from the cache. Uncached emoji are dropped.
* (, )
*/
export async function populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<PopulatedEmoji[]> {
const emojis = await Promise.all(emojiNames.map(x => populateEmoji(x, noteUserHost)));
@ -110,20 +103,11 @@ export function aggregateNoteEmojis(notes: Note[]) {
}
/**
* Query list of emojis in bulk and add them to the cache.
*
*/
export async function prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> {
const notCachedEmojis = emojis.filter(emoji => {
// check if the cache has this emoji
return cache.get(`${emoji.host ?? ''}:${emoji.name}`) == null;
});
// check if there even are any uncached emoji to handle
if (notCachedEmojis.length === 0) return;
// query all uncached emoji
const notCachedEmojis = emojis.filter(emoji => cache.get(`${emoji.name} ${emoji.host}`) == null);
const emojisQuery: any[] = [];
// group by hosts to try to reduce query size
const hosts = new Set(notCachedEmojis.map(e => e.host));
for (const host of hosts) {
emojisQuery.push({
@ -131,14 +115,11 @@ export async function prefetchEmojis(emojis: { name: string; host: string | null
host: host ?? IsNull(),
});
}
await Emojis.find({
const _emojis = emojisQuery.length > 0 ? await Emojis.find({
where: emojisQuery,
select: ['name', 'host', 'originalUrl', 'publicUrl'],
}).then(emojis => {
// store all emojis into the cache
emojis.forEach(emoji => {
cache.set(`${emoji.host ?? ''}:${emoji.name}`, emoji);
});
});
}) : [];
for (const emoji of _emojis) {
cache.set(`${emoji.name} ${emoji.host}`, emoji);
}
}

View file

@ -6,7 +6,7 @@ import { DAY } from '@/const.js';
// Threshold from last contact after which an instance will be considered
// "dead" and should no longer get activities delivered to it.
const deadThreshold = 7 * DAY;
const deadThreshold = 30 * DAY;
/**
* Returns the subset of hosts which should be skipped.
@ -33,8 +33,7 @@ export async function skippedInstances(hosts: Array<Instace['host']>): Array<Ins
})
.andWhere(new Brackets(qb => { qb
.where('instance.isSuspended')
.orWhere('instance.lastCommunicatedAt < :deadTime', { deadTime })
.orWhere('instance.latestStatus = 410');
.orWhere('instance.lastCommunicatedAt < :deadTime', { deadTime });
}))
.select('host')
.getRawMany()

View file

@ -62,8 +62,7 @@ export class DriveFile {
public size: number;
@Column('varchar', {
length: 2048,
nullable: true,
length: 512, nullable: true,
comment: 'The comment of the DriveFile.',
})
public comment: string | null;

View file

@ -7,7 +7,7 @@ export class Instance {
public id: string;
/**
* Date and time this instance was first seen.
*
*/
@Index()
@Column('timestamp with time zone', {
@ -16,7 +16,7 @@ export class Instance {
public caughtAt: Date;
/**
* Hostname
*
*/
@Index({ unique: true })
@Column('varchar', {
@ -26,7 +26,7 @@ export class Instance {
public host: string;
/**
* Number of users on this instance.
*
*/
@Column('integer', {
default: 0,
@ -35,7 +35,7 @@ export class Instance {
public usersCount: number;
/**
* Number of notes on this instance.
* 稿
*/
@Column('integer', {
default: 0,
@ -44,7 +44,7 @@ export class Instance {
public notesCount: number;
/**
* Number of local users who are followed by users from this instance.
*
*/
@Column('integer', {
default: 0,
@ -52,7 +52,7 @@ export class Instance {
public followingCount: number;
/**
* Number of users from this instance who are followed by local users.
*
*/
@Column('integer', {
default: 0,
@ -60,7 +60,7 @@ export class Instance {
public followersCount: number;
/**
* Timestamp of the latest outgoing HTTP request.
*
*/
@Column('timestamp with time zone', {
nullable: true,
@ -68,7 +68,7 @@ export class Instance {
public latestRequestSentAt: Date | null;
/**
* HTTP status code that was received for the last outgoing HTTP request.
* HTTPステータスコード
*/
@Column('integer', {
nullable: true,
@ -76,7 +76,7 @@ export class Instance {
public latestStatus: number | null;
/**
* Timestamp of the latest incoming HTTP request.
*
*/
@Column('timestamp with time zone', {
nullable: true,
@ -84,13 +84,13 @@ export class Instance {
public latestRequestReceivedAt: Date | null;
/**
* Timestamp of last communication with this instance (incoming or outgoing).
*
*/
@Column('timestamp with time zone')
public lastCommunicatedAt: Date;
/**
* Whether this instance seems unresponsive.
*
*/
@Column('boolean', {
default: false,
@ -98,7 +98,7 @@ export class Instance {
public isNotResponding: boolean;
/**
* Whether sending activities to this instance has been suspended.
*
*/
@Index()
@Column('boolean', {

View file

@ -51,7 +51,7 @@ export class Note {
@Column('varchar', {
length: 256, nullable: true,
})
public threadId: string | null;
public threadId: string;
@Column('text', {
nullable: true,

View file

@ -6,16 +6,13 @@ import { Packed } from '@/misc/schema.js';
import { awaitAll, Promiseable } from '@/prelude/await-all.js';
import { populateEmojis } from '@/misc/populate-emojis.js';
import { getAntennas } from '@/misc/antenna-cache.js';
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD, HOUR } from '@/const.js';
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
import { Cache } from '@/misc/cache.js';
import { db } from '@/db/postgre.js';
import { Instance } from '../entities/instance.js';
import { Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, RenoteMutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, AntennaNotes, ChannelFollowings, Instances, DriveFiles } from '../index.js';
const userInstanceCache = new Cache<Instance | null>(
3 * HOUR,
(host) => Instances.findOneBy({ host }).then(x => x ?? undefined),
);
const userInstanceCache = new Cache<Instance | null>(1000 * 60 * 60 * 3);
type IsUserDetailed<Detailed extends boolean> = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>;
type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends boolean> =
@ -30,7 +27,7 @@ const ajv = new Ajv();
const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const;
const passwordSchema = { type: 'string', minLength: 1 } as const;
const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
const descriptionSchema = { type: 'string', minLength: 1, maxLength: 2048 } as const;
const descriptionSchema = { type: 'string', minLength: 1, maxLength: 500 } as const;
const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1) } as const;
@ -312,15 +309,17 @@ export const UserRepository = db.getRepository(User).extend({
isModerator: user.isModerator || falsy,
isBot: user.isBot || falsy,
isCat: user.isCat || falsy,
instance: !user.host ? undefined : userInstanceCache.fetch(user.host)
.then(instance => !instance ? undefined : {
name: instance.name,
softwareName: instance.softwareName,
softwareVersion: instance.softwareVersion,
iconUrl: instance.iconUrl,
faviconUrl: instance.faviconUrl,
themeColor: instance.themeColor,
}),
instance: user.host ? userInstanceCache.fetch(user.host,
() => Instances.findOneBy({ host: user.host! }),
v => v != null,
).then(instance => instance ? {
name: instance.name,
softwareName: instance.softwareName,
softwareVersion: instance.softwareVersion,
iconUrl: instance.iconUrl,
faviconUrl: instance.faviconUrl,
themeColor: instance.themeColor,
} : undefined) : undefined,
emojis: populateEmojis(user.emojis, user.host),
onlineStatus: this.getOnlineStatus(user),

View file

@ -62,8 +62,8 @@ export default async (job: Bull.Job<DeliverJobData>) => {
if (res instanceof StatusError) {
// 4xx
if (res.isClientError) {
// A client error means that something is wrong with the request we are making,
// which means that retrying it makes no sense.
// HTTPステータスコード4xxはクライアントエラーであり、それはつまり
// 何回再送しても成功することはないということなのでエラーにはしないでおく
return `${res.statusCode} ${res.statusMessage}`;
}

View file

@ -10,14 +10,8 @@ import { uriPersonCache, userByIdCache } from '@/services/user-cache.js';
import { IObject, getApId } from './type.js';
import { resolvePerson } from './models/person.js';
const publicKeyCache = new Cache<UserPublickey>(
Infinity,
(keyId) => UserPublickeys.findOneBy({ keyId }).then(x => x ?? undefined),
);
const publicKeyByUserIdCache = new Cache<UserPublickey>(
Infinity,
(userId) => UserPublickeys.findOneBy({ userId }).then(x => x ?? undefined),
);
const publicKeyCache = new Cache<UserPublickey | null>(Infinity);
const publicKeyByUserIdCache = new Cache<UserPublickey | null>(Infinity);
export type UriParseResult = {
/** wether the URI was generated by us */
@ -105,9 +99,13 @@ export default class DbResolver {
if (parsed.local) {
if (parsed.type !== 'users') return null;
return await userByIdCache.fetch(parsed.id) ?? null;
return await userByIdCache.fetchMaybe(parsed.id, () => Users.findOneBy({
id: parsed.id,
}).then(x => x ?? undefined)) ?? null;
} else {
return await uriPersonCache.fetch(parsed.uri) ?? null;
return await uriPersonCache.fetch(parsed.uri, () => Users.findOneBy({
uri: parsed.uri,
}));
}
}
@ -118,12 +116,20 @@ export default class DbResolver {
user: CacheableRemoteUser;
key: UserPublickey;
} | null> {
const key = await publicKeyCache.fetch(keyId);
const key = await publicKeyCache.fetch(keyId, async () => {
const key = await UserPublickeys.findOneBy({
keyId,
});
if (key == null) return null;
return key;
}, key => key != null);
if (key == null) return null;
return {
user: await userByIdCache.fetch(key.userId) as CacheableRemoteUser,
user: await userByIdCache.fetch(key.userId, () => Users.findOneByOrFail({ id: key.userId })) as CacheableRemoteUser,
key,
};
}
@ -139,7 +145,7 @@ export default class DbResolver {
if (user == null) return null;
const key = await publicKeyByUserIdCache.fetch(user.id);
const key = await publicKeyByUserIdCache.fetch(user.id, () => UserPublickeys.findOneBy({ userId: user.id }), v => v != null);
return {
user,

View file

@ -119,18 +119,26 @@ export default class DeliverManager {
if (this.recipes.some(r => isFollowers(r))) {
// followers deliver
const followers = await Followings.createQueryBuilder('followings')
// return either the shared inbox (if available) or the individual inbox
.select('COALESCE(followings.followerSharedInbox, followings.followerInbox)', 'inbox')
// so we don't have to make our inboxes Set work as hard
.distinct(true)
// ...for the specific actors followers
.where('followings.followeeId = :actorId', { actorId: this.actor.id })
// don't deliver to ourselves
.andWhere('followings.followerHost IS NOT NULL')
.getRawMany();
// TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう
// ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう
const followers = await Followings.find({
where: {
followeeId: this.actor.id,
followerHost: Not(IsNull()),
},
select: {
followerSharedInbox: true,
followerInbox: true,
},
}) as {
followerSharedInbox: string | null;
followerInbox: string;
}[];
followers.forEach(({ inbox }) => inboxes.add(inbox));
for (const following of followers) {
const inbox = following.followerSharedInbox || following.followerInbox;
inboxes.add(inbox);
}
}
this.recipes.filter((recipe): recipe is IDirectRecipe =>

View file

@ -131,6 +131,7 @@ export default async function renderNote(note: Note, dive = true, isTalk = false
return {
id: `${config.url}/notes/${note.id}`,
context: note.threadId,
type: 'Note',
attributedTo,
summary,

View file

@ -23,6 +23,8 @@ import Featured from './activitypub/featured.js';
// Init router
const router = new Router();
//#region Routing
function inbox(ctx: Router.RouterContext) {
let signature;
@ -43,8 +45,6 @@ const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystr
function isActivityPubReq(ctx: Router.RouterContext) {
ctx.response.vary('Accept');
// if no accept header is supplied, koa returns the 1st, so html is used as a dummy
// i.e. activitypub requests must be explicit
const accepted = ctx.accepts('html', ACTIVITY_JSON, LD_JSON);
return typeof accepted === 'string' && !accepted.match(/html/);
}
@ -77,7 +77,7 @@ router.get('/notes/:note', async (ctx, next) => {
return;
}
// redirect if remote
// リモートだったらリダイレクト
if (note.userHost != null) {
if (note.uri == null || isSelfHost(note.userHost)) {
ctx.status = 500;
@ -94,15 +94,6 @@ router.get('/notes/:note', async (ctx, next) => {
// note activity
router.get('/notes/:note/activity', async ctx => {
if (!isActivityPubReq(ctx)) {
/*
Redirect to the human readable page. in this case using next is not possible,
since there is no human readable page explicitly for the activity.
*/
ctx.redirect(`/notes/${ctx.params.note}`);
return;
}
const note = await Notes.findOneBy({
id: ctx.params.note,
userHost: IsNull(),
@ -194,6 +185,7 @@ router.get('/@:user', async (ctx, next) => {
await userInfo(ctx, user);
});
//#endregion
// emoji
router.get('/emojis/:emoji', async ctx => {

View file

@ -3,13 +3,10 @@ import { Users, AccessTokens, Apps } from '@/models/index.js';
import { AccessToken } from '@/models/entities/access-token.js';
import { Cache } from '@/misc/cache.js';
import { App } from '@/models/entities/app.js';
import { userByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js';
import { localUserByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js';
import isNativeToken from './common/is-native-token.js';
const appCache = new Cache<App>(
Infinity,
(id) => Apps.findOneByOrFail({ id }),
);
const appCache = new Cache<App>(Infinity);
export class AuthenticationError extends Error {
constructor(message: string) {
@ -42,7 +39,8 @@ export default async (authorization: string | null | undefined, bodyToken: strin
const token: string = maybeToken;
if (isNativeToken(token)) {
const user = await localUserByNativeTokenCache.fetch(token);
const user = await localUserByNativeTokenCache.fetch(token,
() => Users.findOneBy({ token }) as Promise<ILocalUser | null>);
if (user == null) {
throw new AuthenticationError('unknown token');
@ -66,13 +64,14 @@ export default async (authorization: string | null | undefined, bodyToken: strin
lastUsedAt: new Date(),
});
const user = await userByIdCache.fetch(accessToken.userId);
// can't authorize remote users
if (!Users.isLocalUser(user)) return [null, null];
const user = await localUserByIdCache.fetch(accessToken.userId,
() => Users.findOneBy({
id: accessToken.userId,
}) as Promise<ILocalUser>);
if (accessToken.appId) {
const app = await appCache.fetch(accessToken.appId);
const app = await appCache.fetch(accessToken.appId,
() => Apps.findOneByOrFail({ id: accessToken.appId! }));
return [user, {
id: accessToken.id,

View file

@ -7,11 +7,7 @@ export function generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { i
.select('threadMuted.threadId')
.where('threadMuted.userId = :userId', { userId: me.id });
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
q.andWhere(new Brackets(qb => { qb
.where('note.threadId IS NULL')
.orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
}));
q.andWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
q.setParameters(mutedQuery.getParameters());
}

View file

@ -1,5 +1,6 @@
import { Meta } from '@/models/entities/meta.js';
import { insertModerationLog } from '@/services/insert-moderation-log.js';
import { fetchMeta, setMeta } from '@/misc/fetch-meta.js';
import { db } from '@/db/postgre.js';
import define from '../../define.js';
export const meta = {
@ -374,10 +375,20 @@ export default define(meta, paramDef, async (ps, me) => {
set.deeplIsPro = ps.deeplIsPro;
}
const meta = await fetchMeta();
await setMeta({
...meta,
...set,
await db.transaction(async transactionalEntityManager => {
const metas = await transactionalEntityManager.find(Meta, {
order: {
id: 'DESC',
},
});
const meta = metas[0];
if (meta) {
await transactionalEntityManager.update(Meta, meta.id, set);
} else {
await transactionalEntityManager.save(Meta, set);
}
});
insertModerationLog(me, 'updateMeta');

View file

@ -2,7 +2,6 @@ import { Apps } from '@/models/index.js';
import { genId } from '@/misc/gen-id.js';
import { unique } from '@/prelude/array.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { kinds } from '@/misc/api-permissions.js';
import define from '../../define.js';
export const meta = {
@ -22,14 +21,10 @@ export const paramDef = {
properties: {
name: { type: 'string' },
description: { type: 'string' },
permission: {
type: 'array',
uniqueItems: true,
items: {
type: 'string',
enum: kinds,
},
},
permission: { type: 'array', uniqueItems: true, items: {
type: 'string',
// FIXME: add enum of possible permissions
} },
callbackUrl: { type: 'string', nullable: true },
},
required: ['name', 'description', 'permission'],

View file

@ -57,7 +57,7 @@ export default define(meta, paramDef, async (ps, user) => {
NoteThreadMutings.count({
where: {
userId: user.id,
threadId: note.threadId || note.id,
threadId: note.threadId,
},
take: 1,
}),

View file

@ -41,9 +41,7 @@ export default define(meta, paramDef, async (ps, user) => {
const mutedNotes = await Notes.find({
where: [{
id: note.threadId || note.id,
}, {
threadId: note.threadId || note.id,
threadId: note.threadId,
}],
});
@ -52,7 +50,7 @@ export default define(meta, paramDef, async (ps, user) => {
await NoteThreadMutings.insert({
id: genId(),
createdAt: new Date(),
threadId: note.threadId || note.id,
threadId: note.threadId,
userId: user.id,
mutingNotificationTypes: ps.mutingNotificationTypes,
});

View file

@ -29,7 +29,7 @@ export default define(meta, paramDef, async (ps, user) => {
});
await NoteThreadMutings.delete({
threadId: note.threadId || note.id,
threadId: note.threadId,
userId: user.id,
});
});

View file

@ -32,7 +32,10 @@ app.use(async (ctx, next) => {
await next();
});
app.use(bodyParser());
app.use(bodyParser({
// リクエストが multipart/form-data でない限りはJSONだと見なす
detectJSON: ctx => !ctx.is('multipart/form-data'),
}));
// Init multer instance
const upload = multer({

View file

@ -11,51 +11,48 @@ import { getIpHash } from '@/misc/get-ip-hash.js';
import signin from '../common/signin.js';
import { verifyLogin, hash } from '../2fa.js';
import { limiter } from '../limiter.js';
import { ApiError } from '../error.js';
export default async (ctx: Koa.Context) => {
ctx.set('Access-Control-Allow-Origin', config.url);
ctx.set('Access-Control-Allow-Credentials', 'true');
const body = ctx.request.body as any;
const { username, password, token } = body;
const username = body['username'];
const password = body['password'];
const token = body['token'];
// taken from @server/api/api-handler.ts
function error (e: ApiError): void {
ctx.status = e.httpStatusCode;
if (e.httpStatusCode === 401) {
ctx.response.set('WWW-Authenticate', 'Bearer');
}
ctx.body = {
error: {
message: e!.message,
code: e!.code,
...(e!.info ? { info: e!.info } : {}),
endpoint: endpoint.name,
},
};
function error(status: number, error: { id: string }) {
ctx.status = status;
ctx.body = { error };
}
try {
// not more than 1 attempt per second and not more than 10 attempts per hour
await limiter({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(ctx.ip));
} catch (err) {
error(new ApiError('RATE_LIMIT_EXCEEDED'));
ctx.status = 429;
ctx.body = {
error: {
message: 'Too many failed attempts to sign in. Try again later.',
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
},
};
return;
}
if (typeof username !== 'string') {
error(new ApiError('INVALID_PARAM', { param: 'username', reason: 'not a string' }));
ctx.status = 400;
return;
}
if (typeof password !== 'string') {
error(new ApiError('INVALID_PARAM', { param: 'password', reason: 'not a string' }));
ctx.status = 400;
return;
}
if (token != null && typeof token !== 'string') {
error(new ApiError('INVALID_PARAM', { param: 'token', reason: 'provided but not a string' }));
ctx.status = 400;
return;
}
@ -66,12 +63,16 @@ export default async (ctx: Koa.Context) => {
}) as ILocalUser;
if (user == null) {
error(new ApiError('NO_SUCH_USER'));
error(404, {
id: '6cc579cc-885d-43d8-95c2-b8c7fc963280',
});
return;
}
if (user.isSuspended) {
error(new ApiError('SUSPENDED'));
error(403, {
id: 'e03a5f46-d309-4865-9b69-56282d94e1eb',
});
return;
}
@ -80,7 +81,7 @@ export default async (ctx: Koa.Context) => {
// Compare password
const same = await bcrypt.compare(password, profile.password!);
async function fail(): void {
async function fail(status?: number, failure?: { id: string }) {
// Append signin history
await Signins.insert({
id: genId(),
@ -91,7 +92,7 @@ export default async (ctx: Koa.Context) => {
success: false,
});
error(new ApiError('ACCESS_DENIED'));
error(status || 500, failure || { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' });
}
if (!profile.twoFactorEnabled) {
@ -99,14 +100,18 @@ export default async (ctx: Koa.Context) => {
signin(ctx, user);
return;
} else {
await fail();
await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
});
return;
}
}
if (token) {
if (!same) {
await fail();
await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
});
return;
}
@ -121,12 +126,16 @@ export default async (ctx: Koa.Context) => {
signin(ctx, user);
return;
} else {
await fail();
await fail(403, {
id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f',
});
return;
}
} else if (body.credentialId) {
if (!same && !profile.usePasswordLessLogin) {
await fail();
await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
});
return;
}
@ -140,7 +149,9 @@ export default async (ctx: Koa.Context) => {
});
if (!challenge) {
await fail();
await fail(403, {
id: '2715a88a-2125-4013-932f-aa6fe72792da',
});
return;
}
@ -150,7 +161,9 @@ export default async (ctx: Koa.Context) => {
});
if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) {
await fail();
await fail(403, {
id: '2715a88a-2125-4013-932f-aa6fe72792da',
});
return;
}
@ -164,7 +177,9 @@ export default async (ctx: Koa.Context) => {
});
if (!securityKey) {
await fail();
await fail(403, {
id: '66269679-aeaf-4474-862b-eb761197e046',
});
return;
}
@ -181,12 +196,16 @@ export default async (ctx: Koa.Context) => {
signin(ctx, user);
return;
} else {
await fail();
await fail(403, {
id: '93b86c4b-72f9-40eb-9815-798928603d1e',
});
return;
}
} else {
if (!same && !profile.usePasswordLessLogin) {
await fail();
await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
});
return;
}
@ -195,7 +214,9 @@ export default async (ctx: Koa.Context) => {
});
if (keys.length === 0) {
await fail();
await fail(403, {
id: 'f27fd449-9af4-4841-9249-1f989b9fa4a4',
});
return;
}

View file

@ -3,6 +3,7 @@ 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 { Cache } from '@/misc/cache.js';
const router = new Router();
@ -17,33 +18,7 @@ export const links = [{
href: config.url + nodeinfo2_0path,
}];
const repository = 'https://akkoma.dev/FoundKeyGang/FoundKey';
type NodeInfo2Base = {
software: {
name: string;
version: string;
repository?: string; // Not used in NodeInfo 2.0; used in 2.1
};
protocols: string[];
services: {
inbound: string[];
outbound: string[];
};
openRegistrations: boolean;
usage: {
users: {
total: number;
activeHalfyear: number;
activeMonth: number;
};
localPosts: number;
localComments: number;
};
metadata: Record<string, any>;
};
const nodeinfo2 = async (): Promise<NodeInfo2Base> => {
const nodeinfo2 = async () => {
const now = Date.now();
const [
meta,
@ -65,7 +40,7 @@ const nodeinfo2 = async (): Promise<NodeInfo2Base> => {
software: {
name: 'foundkey',
version: config.version,
repository,
repository: 'https://akkoma.dev/FoundKeyGang/FoundKey',
},
protocols: ['activitypub'],
services: {
@ -87,7 +62,7 @@ const nodeinfo2 = async (): Promise<NodeInfo2Base> => {
},
langs: meta.langs,
tosUrl: meta.ToSUrl,
repositoryUrl: repository,
repositoryUrl: meta.repositoryUrl,
feedbackUrl: 'ircs://irc.akkoma.dev/foundkey',
disableRegistration: meta.disableRegistration,
disableLocalTimeline: meta.disableLocalTimeline,
@ -107,15 +82,17 @@ const nodeinfo2 = async (): Promise<NodeInfo2Base> => {
};
};
const cache = new Cache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10);
router.get(nodeinfo2_1path, async ctx => {
const base = await nodeinfo2();
const base = await cache.fetch(null, () => nodeinfo2());
ctx.body = { version: '2.1', ...base };
ctx.set('Cache-Control', 'public, max-age=600');
});
router.get(nodeinfo2_0path, async ctx => {
const base = await nodeinfo2();
const base = await cache.fetch(null, () => nodeinfo2());
delete base.software.repository;

View file

@ -1,20 +1,28 @@
import { IsNull } from 'typeorm';
import { ILocalUser } from '@/models/entities/user.js';
import { Users } from '@/models/index.js';
import { Cache } from '@/misc/cache.js';
import { createSystemUser } from './create-system-user.js';
const ACTOR_USERNAME = 'instance.actor' as const;
let instanceActor = await Users.findOneBy({
host: IsNull(),
username: ACTOR_USERNAME,
}) as ILocalUser | undefined;
const cache = new Cache<ILocalUser>(Infinity);
export async function getInstanceActor(): Promise<ILocalUser> {
if (instanceActor) {
return instanceActor;
const cached = cache.get(null);
if (cached) return cached;
const user = await Users.findOneBy({
host: IsNull(),
username: ACTOR_USERNAME,
}) as ILocalUser | undefined;
if (user) {
cache.set(null, user);
return user;
} else {
instanceActor = await createSystemUser(ACTOR_USERNAME) as ILocalUser;
const created = await createSystemUser(ACTOR_USERNAME) as ILocalUser;
cache.set(null, created);
return created;
}
}

View file

@ -36,22 +36,13 @@ import { Cache } from '@/misc/cache.js';
import { UserProfile } from '@/models/entities/user-profile.js';
import { getActiveWebhooks } from '@/misc/webhook-cache.js';
import { IActivity } from '@/remote/activitypub/type.js';
import { MINUTE } from '@/const.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';
const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(
5 * MINUTE,
() => UserProfiles.find({
where: {
enableWordMute: true,
},
select: ['userId', 'mutedWords'],
}),
);
const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5);
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -266,7 +257,12 @@ export default async (user: { id: User['id']; username: User['username']; host:
incNotesCountOfUser(user);
// Word mute
mutedWordsCache.fetch(null).then(us => {
mutedWordsCache.fetch(null, () => UserProfiles.find({
where: {
enableWordMute: true,
},
select: ['userId', 'mutedWords'],
})).then(us => {
for (const u of us) {
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
if (shouldMute) {
@ -373,7 +369,7 @@ export default async (user: { id: User['id']; username: User['username']; host:
if (data.reply.userHost === null) {
const threadMuted = await NoteThreadMutings.findOneBy({
userId: data.reply.userId,
threadId: data.reply.threadId || data.reply.id,
threadId: data.reply.threadId,
});
if (!threadMuted) {
@ -504,15 +500,16 @@ function incRenoteCount(renote: Note): void {
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();
const id = genId(createdAt);
const insert = new Note({
id: genId(createdAt),
id,
createdAt,
fileIds: data.files?.map(file => file.id) ?? [],
replyId: data.reply?.id ?? null,
renoteId: data.renote?.id ?? null,
channelId: data.channel?.id ?? null,
threadId: data.reply?.threadId ?? data.reply?.id ?? null,
fileIds: data.files ? data.files.map(file => file.id) : [],
replyId: data.reply ? data.reply.id : null,
renoteId: data.renote ? data.renote.id : null,
channelId: data.channel ? data.channel.id : null,
threadId: data.reply?.threadId ?? `${config.url}/notes/${id}`,
name: data.name,
text: data.text,
hasPoll: data.poll != null,
@ -620,7 +617,7 @@ async function createMentionedEvents(mentionedUsers: MinimumUser[], note: Note,
for (const u of mentionedUsers.filter(u => Users.isLocalUser(u))) {
const threadMuted = await NoteThreadMutings.findOneBy({
userId: u.id,
threadId: note.threadId || note.id,
threadId: note.threadId,
});
if (threadMuted) {

View file

@ -17,14 +17,14 @@ import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js
import { deliverToRelays } from '../relay.js';
/**
* Delete your note.
* @param user author
* @param note note to be deleted
* 稿
* @param user 稿
* @param note 稿
*/
export default async function(user: { id: User['id']; uri: User['uri']; host: User['host']; }, note: Note, quiet = false): Promise<void> {
const deletedAt = new Date();
// If this is the only renote of this note by this user
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
if (note.renoteId && (await countSameRenotes(user.id, note.renoteId, note.id)) === 0) {
Notes.decrement({ id: note.renoteId }, 'renoteCount', 1);
Notes.decrement({ id: note.renoteId }, 'score', 1);
@ -37,7 +37,7 @@ export default async function(user: { id: User['id']; uri: User['uri']; host: Us
if (!quiet) {
publishNoteStream(note.id, 'deleted', { deletedAt });
// deliver delete activity of note itself for local posts
//#region ローカルの投稿なら削除アクティビティを配送
if (Users.isLocalUser(user) && !note.localOnly) {
let renote: Note | null = null;
@ -53,14 +53,17 @@ export default async function(user: { id: User['id']; uri: User['uri']; host: Us
deliverToConcerned(user, note, content);
}
// also deliver delete activity to cascaded notes
const cascadingNotes = await findCascadingNotes(note);
// also deliever delete activity to cascaded notes
const cascadingNotes = (await findCascadingNotes(note)).filter(note => !note.localOnly); // filter out local-only notes
for (const cascadingNote of cascadingNotes) {
if (!cascadingNote.user) continue;
if (!Users.isLocalUser(cascadingNote.user)) continue;
const content = renderActivity(renderDelete(renderTombstone(`${config.url}/notes/${cascadingNote.id}`), cascadingNote.user));
deliverToConcerned(cascadingNote.user, cascadingNote, content);
}
//#endregion
// update statistics
// 統計を更新
notesChart.update(note, false);
perUserNotesChart.update(user, note, false);
@ -78,43 +81,26 @@ export default async function(user: { id: User['id']; uri: User['uri']; host: Us
});
}
/**
* Search for notes that will be affected by ON CASCADE DELETE.
* However, only notes for which it is relevant to deliver delete activities are searched.
* This means only local notes that are not local-only are searched.
*/
async function findCascadingNotes(note: Note): Promise<Note[]> {
const cascadingNotes: Note[] = [];
const recursive = async (noteId: string): Promise<void> => {
// FIXME: use note_replies SQL function? Unclear what to do with 2nd and 3rd parameter, maybe rewrite the function.
const replies = await Notes.find({
where: [{
replyId: noteId,
localOnly: false,
userHost: IsNull(),
}, {
renoteId: noteId,
text: Not(IsNull()),
localOnly: false,
userHost: IsNull(),
}],
relations: {
user: true,
},
});
await Promise.all(replies.map(reply => {
// only add unique notes
if (cascadingNotes.find((x) => x.id == reply.id) != null) return;
const query = Notes.createQueryBuilder('note')
.where('note.replyId = :noteId', { noteId })
.orWhere(new Brackets(q => {
q.where('note.renoteId = :noteId', { noteId })
.andWhere('note.text IS NOT NULL');
}))
.leftJoinAndSelect('note.user', 'user');
const replies = await query.getMany();
for (const reply of replies) {
cascadingNotes.push(reply);
return recursive(reply.id);
}));
await recursive(reply.id);
}
};
await recursive(note.id);
return cascadingNotes;
return cascadingNotes.filter(note => note.userHost === null); // filter out non-local users
}
async function getMentionedRemoteUsers(note: Note): Promise<IRemoteUser[]> {

View file

@ -20,7 +20,7 @@ export async function insertNoteUnread(userId: User['id'], note: Note, params: {
// スレッドミュート
const threadMute = await NoteThreadMutings.findOneBy({
userId,
threadId: note.threadId || note.id,
threadId: note.threadId,
});
if (threadMute) return;

View file

@ -15,21 +15,20 @@ type pushNotificationsTypes = {
'readAllMessagingMessagesOfARoom': { userId: string } | { groupId: string };
};
// Reduce the content of the push message because of the character limit
// プッシュメッセージサーバーには文字数制限があるため、内容を削減します
function truncateNotification(notification: Packed<'Notification'>): any {
if (notification.note) {
return {
...notification,
note: {
...notification.note,
// replace text with getNoteSummary
// textをgetNoteSummaryしたものに置き換える
text: getNoteSummary(notification.type === 'renote' ? notification.note.renote as Packed<'Note'> : notification.note),
cw: undefined,
reply: undefined,
renote: undefined,
// unnecessary, since usually the user who is receiving the notification knows who they are
user: undefined as any,
user: undefined as any, // 通知を受け取ったユーザーである場合が多いのでこれも捨てる
},
};
}
@ -42,7 +41,7 @@ export async function pushNotification<T extends keyof pushNotificationsTypes>(u
if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return;
// Register key pair information
// アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録
push.setVapidDetails(config.url,
meta.swPublicKey,
meta.swPrivateKey);
@ -66,6 +65,10 @@ export async function pushNotification<T extends keyof pushNotificationsTypes>(u
}), {
proxy: config.proxy,
}).catch((err: any) => {
//swLogger.info(err.statusCode);
//swLogger.info(err.headers);
//swLogger.info(err.body);
if (err.statusCode === 410) {
SwSubscriptions.delete({
userId,

View file

@ -3,27 +3,29 @@ import { Instances } from '@/models/index.js';
import { genId } from '@/misc/gen-id.js';
import { toPuny } from '@/misc/convert-host.js';
import { Cache } from '@/misc/cache.js';
import { HOUR } from '@/const.js';
const cache = new Cache<Instance>(
HOUR,
(host) => Instances.findOneBy({ host }).then(x => x ?? undefined),
);
const cache = new Cache<Instance>(1000 * 60 * 60);
export async function registerOrFetchInstanceDoc(idnHost: string): Promise<Instance> {
const host = toPuny(idnHost);
const cached = cache.fetch(host);
const cached = cache.get(host);
if (cached) return cached;
// apparently a new instance
const i = await Instances.insert({
id: genId(),
host,
caughtAt: new Date(),
lastCommunicatedAt: new Date(),
}).then(x => Instances.findOneByOrFail(x.identifiers[0]));
const index = await Instances.findOneBy({ host });
cache.set(host, i);
return i;
if (index == null) {
const i = await Instances.insert({
id: genId(),
host,
caughtAt: new Date(),
lastCommunicatedAt: new Date(),
}).then(x => Instances.findOneByOrFail(x.identifiers[0]));
cache.set(host, i);
return i;
} else {
cache.set(host, index);
return index;
}
}

View file

@ -8,21 +8,11 @@ import { Users, Relays } from '@/models/index.js';
import { genId } from '@/misc/gen-id.js';
import { Cache } from '@/misc/cache.js';
import { Relay } from '@/models/entities/relay.js';
import { MINUTE } from '@/const.js';
import { createSystemUser } from './create-system-user.js';
const ACTOR_USERNAME = 'relay.actor' as const;
/**
* There is only one cache key: null.
* A cache is only used here to have expiring storage.
*/
const relaysCache = new Cache<Relay[]>(
10 * MINUTE,
() => Relays.findBy({
status: 'accepted',
}),
);
const relaysCache = new Cache<Relay[]>(1000 * 60 * 10);
export async function getRelayActor(): Promise<ILocalUser> {
const user = await Users.findOneBy({
@ -93,7 +83,9 @@ export async function relayRejected(id: string) {
export async function deliverToRelays(user: { id: User['id']; host: null; }, activity: any) {
if (activity == null) return;
const relays = await relaysCache.fetch(null);
const relays = await relaysCache.fetch(null, () => Relays.findBy({
status: 'accepted',
}));
if (relays.length === 0) return;
// TODO

View file

@ -1,7 +1,7 @@
import { Not, IsNull } from 'typeorm';
import renderDelete from '@/remote/activitypub/renderer/delete.js';
import { renderActivity } from '@/remote/activitypub/renderer/index.js';
import DeliverManager from '@/remote/activitypub/deliver-manager.js';
import { deliver } from '@/queue/index.js';
import config from '@/config/index.js';
import { User } from '@/models/entities/user.js';
import { Users, Followings } from '@/models/index.js';
@ -11,11 +11,27 @@ export async function doPostSuspend(user: { id: User['id']; host: User['host'] }
publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
if (Users.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信
const content = renderActivity(renderDelete(`${config.url}/users/${user.id}`, user));
// deliver to all of known network
const dm = new DeliverManager(user, content);
dm.addEveryone();
await dm.execute();
const queue: string[] = [];
const followings = await Followings.find({
where: [
{ followerSharedInbox: Not(IsNull()) },
{ followeeSharedInbox: Not(IsNull()) },
],
select: ['followerSharedInbox', 'followeeSharedInbox'],
});
const inboxes = followings.map(x => x.followerSharedInbox || x.followeeSharedInbox);
for (const inbox of inboxes) {
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
}
for (const inbox of queue) {
deliver(user, content, inbox);
}
}
}

View file

@ -3,18 +3,10 @@ import { Users } from '@/models/index.js';
import { Cache } from '@/misc/cache.js';
import { subscriber } from '@/db/redis.js';
export const userByIdCache = new Cache<CacheableUser>(
Infinity,
(id) => Users.findOneBy({ id }).then(x => x ?? undefined),
);
export const localUserByNativeTokenCache = new Cache<CacheableLocalUser>(
Infinity,
(token) => Users.findOneBy({ token }).then(x => x ?? undefined),
);
export const uriPersonCache = new Cache<CacheableUser>(
Infinity,
(uri) => Users.findOneBy({ uri }).then(x => x ?? undefined),
);
export const userByIdCache = new Cache<CacheableUser>(Infinity);
export const localUserByNativeTokenCache = new Cache<CacheableLocalUser | null>(Infinity);
export const localUserByIdCache = new Cache<CacheableLocalUser>(Infinity);
export const uriPersonCache = new Cache<CacheableUser | null>(Infinity);
subscriber.on('message', async (_, data) => {
const obj = JSON.parse(data);
@ -35,6 +27,7 @@ subscriber.on('message', async (_, data) => {
}
if (Users.isLocalUser(user)) {
localUserByNativeTokenCache.set(user.token, user);
localUserByIdCache.set(user.id, user);
}
break;
}

View file

@ -32,8 +32,12 @@ export async function signout() {
const registration = await navigator.serviceWorker.ready;
const push = await registration.pushManager.getSubscription();
if (push) {
await api('sw/unregister', {
endpoint: push.endpoint,
await fetch(`${apiUrl}/sw/unregister`, {
method: 'POST',
body: JSON.stringify({
i: $i.token,
endpoint: push.endpoint,
}),
});
}
}
@ -75,7 +79,13 @@ export async function removeAccount(id: Account['id']) {
function fetchAccount(token: string): Promise<Account> {
return new Promise((done, fail) => {
// Fetch user
api('i', {}, token)
fetch(`${apiUrl}/i`, {
method: 'POST',
body: JSON.stringify({
i: token,
}),
})
.then(res => res.json())
.then(res => {
if (res.error) {
if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {

View file

@ -17,7 +17,6 @@
:spellcheck="spellcheck"
:step="step"
:list="id"
:maxlength="max"
@focus="focused = true"
@blur="focused = false"
@keydown="onKeydown($event)"
@ -59,7 +58,6 @@ const props = defineProps<{
manualSave?: boolean;
small?: boolean;
large?: boolean;
max?: number;
}>();
const emit = defineEmits<{

View file

@ -14,7 +14,6 @@
:pattern="pattern"
:autocomplete="autocomplete ? 'on' : 'off'"
:spellcheck="spellcheck"
:maxlength="max"
@focus="focused = true"
@blur="focused = false"
@keydown="onKeydown($event)"
@ -55,7 +54,6 @@ const props = withDefaults(defineProps<{
pre?: boolean;
debounce?: boolean;
manualSave?: boolean;
max?: number;
}>(), {
pattern: undefined,
placeholder: '',

View file

@ -8,6 +8,7 @@
<div v-if="!totpLogin" class="normal-signin">
<MkInput v-model="username" class="_formBlock" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
</MkInput>
<MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" class="_formBlock" :placeholder="i18n.ts.password" type="password" :with-password-toggle="true" required data-cy-signin-password>
<template #prefix><i class="fas fa-lock"></i></template>
@ -54,7 +55,7 @@ import { showSuspendedDialog } from '@/scripts/show-suspended-dialog';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkInfo from '@/components/ui/info.vue';
import { apiUrl } from '@/config';
import { apiUrl, host as configHost } from '@/config';
import { byteify, hexify } from '@/scripts/2fa';
import * as os from '@/os';
import { login } from '@/account';
@ -67,6 +68,7 @@ let user = $ref(null);
let username = $ref('');
let password = $ref('');
let token = $ref('');
let host = $ref(toUnicode(configHost));
let totpLogin = $ref(false);
let challengeData = $ref(null);
let queryingKey = $ref(false);

View file

@ -7,6 +7,7 @@
<MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:modelValue="onChangeUsername">
<template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="far fa-question-circle"></i></div></template>
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
<template #caption>
<span v-if="usernameState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ i18n.ts.checking }}</span>
<span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ i18n.ts.available }}</span>
@ -69,6 +70,7 @@ import MkButton from './ui/button.vue';
import MkCaptcha from './captcha.vue';
import MkInput from './form/input.vue';
import MkSwitch from './form/switch.vue';
import * as config from '@/config';
import * as os from '@/os';
import { login } from '@/account';
import { instance } from '@/instance';
@ -85,6 +87,8 @@ const emit = defineEmits<{
(ev: 'signupEmailPending'): void;
}>();
const host = toUnicode(config.host);
let hcaptcha = $ref();
let recaptcha = $ref();

View file

@ -32,10 +32,7 @@ export const api = ((endpoint: string, data: Record<string, any> = {}, token?: s
body: JSON.stringify(data),
credentials: 'omit',
cache: 'no-cache',
headers: {
'content-type': 'application/json',
...(authorization ? { authorization } : {}),
},
headers: authorization ? { authorization } : {},
}).then(async (res) => {
const body = res.status === 204 ? null : await res.json();
@ -72,10 +69,7 @@ export const apiGet = ((endpoint: string, data: Record<string, any> = {}, token?
method: 'GET',
credentials: 'omit',
cache: 'default',
headers: {
'content-type': 'application/json',
...(authorization ? { authorization } : {}),
},
headers: authorization ? { authorization } : {},
}).then(async (res) => {
const body = res.status === 204 ? null : await res.json();
@ -103,7 +97,7 @@ export const apiWithDialog = ((
promiseDialog(promise, null, (err) => {
alert({
type: 'error',
text: (err.message + '\n' + (err?.endpoint ?? '') + (err?.code ?? '')).trim(),
text: err.message + '\n' + (err as any).id,
});
});
@ -141,7 +135,7 @@ export function promiseDialog<T extends Promise<any>>(
}
});
// NOTE: dynamic import results in strange behaviour (showing is not reactive)
// NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない)
popup(MkWaitingDialog, {
success,
showing,

View file

@ -26,27 +26,8 @@
<FormSection v-if="iAmModerator">
<template #label>Moderation</template>
<FormSwitch
:model-value="suspended || isBlocked"
@update:model-value="newValue => {suspended = newValue; toggleSuspend() }"
:disabled="isBlocked"
class="_formBlock"
>
{{ i18n.ts.stopActivityDelivery }}
<template #caption>
{{ i18n.ts.stopActivityDeliveryDescription }}
</template>
</FormSwitch>
<FormSwitch
v-model="isBlocked"
@update:modelValue="toggleBlock"
class="_formBlock"
>
{{ i18n.ts.blockThisInstance }}
<template #caption>
{{ i18n.ts.blockThisInstanceDescription }}
</template>
</FormSwitch>
<FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</FormSwitch>
<FormSwitch v-model="isBlocked" class="_formBlock" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</FormSwitch>
<MkButton @click="refreshMetadata"><i class="fas fa-refresh"></i> Refresh metadata</MkButton>
</FormSection>
@ -171,7 +152,7 @@ const usersPagination = {
offsetMode: true,
};
async function fetch(): Promise<void> {
async function fetch() {
instance = await os.api('federation/show-instance', {
host: props.host,
});
@ -179,21 +160,21 @@ async function fetch(): Promise<void> {
isBlocked = instance.isBlocked;
}
async function toggleBlock(): Promise<void> {
async function toggleBlock(ev) {
if (meta == null) return;
await os.api('admin/update-meta', {
blockedHosts: isBlocked ? meta.blockedHosts.concat([instance.host]) : meta.blockedHosts.filter(x => x !== instance.host),
});
}
async function toggleSuspend(): Promise<void> {
async function toggleSuspend(v) {
await os.api('admin/federation/update-instance', {
host: instance.host,
isSuspended: suspended,
});
}
function refreshMetadata(): void {
function refreshMetadata() {
os.api('admin/federation/refresh-remote-instance-metadata', {
host: instance.host,
});

View file

@ -7,14 +7,14 @@
</template>
<script lang="ts" setup>
import { defineAsyncComponent } from 'vue';
import { defineAsyncComponent, ref } from 'vue';
import FormLink from '@/components/form/link.vue';
import FormButton from '@/components/ui/button.vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
const isDesktop = window.innerWidth >= 1100;
const isDesktop = ref(window.innerWidth >= 1100);
function generateToken() {
os.popup(defineAsyncComponent(() => import('@/components/token-generate-window.vue')), {}, {

View file

@ -12,7 +12,7 @@
<template #label>{{ i18n.ts._profile.name }}</template>
</FormInput>
<FormTextarea v-model="profile.description" :max="2048" tall manual-save class="_formBlock">
<FormTextarea v-model="profile.description" :max="500" tall manual-save class="_formBlock">
<template #label>{{ i18n.ts._profile.description }}</template>
<template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
</FormTextarea>

View file

@ -6,6 +6,7 @@
<MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username class="_formBlock">
<template #label>{{ i18n.ts.username }}</template>
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
</MkInput>
<MkInput v-model="password" type="password" data-cy-admin-password class="_formBlock">
<template #label>{{ i18n.ts.password }}</template>
@ -23,6 +24,7 @@
<script lang="ts" setup>
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import { host } from '@/config';
import * as os from '@/os';
import { login } from '@/account';
import { i18n } from '@/i18n';

View file

@ -30,6 +30,14 @@ async function composeNotification<K extends keyof pushNotificationDataMap>(data
const i18n = await swLang.i18n as I18n<any>;
const { t } = i18n;
switch (data.type) {
/*
case 'driveFileCreated': // TODO (Server Side)
return [t('_notification.fileUploaded'), {
body: body.name,
icon: body.url,
data
}];
*/
case 'notification':
switch (data.body.type) {
case 'follow':

View file

@ -1,6 +1,6 @@
declare var self: ServiceWorkerGlobalScope;
import { createNotification } from '@/scripts/create-notification';
import { createEmptyNotification, createNotification } from '@/scripts/create-notification';
import { swLang } from '@/scripts/lang';
import { swNotificationRead } from '@/scripts/notification-read';
import { pushNotificationDataMap } from '@/types';
@ -67,6 +67,8 @@ self.addEventListener('push', ev => {
}
break;
}
return createEmptyNotification();
}));
});

View file

@ -3743,7 +3743,7 @@ __metadata:
rss-parser: 3.12.0
sanitize-html: 2.7.0
semver: 7.3.7
sharp: 0.31.2
sharp: 0.30.7
speakeasy: 2.0.0
strict-event-emitter-types: 2.0.0
stringz: 2.1.0
@ -15189,17 +15189,6 @@ __metadata:
languageName: node
linkType: hard
"semver@npm:^7.3.8":
version: 7.3.8
resolution: "semver@npm:7.3.8"
dependencies:
lru-cache: ^6.0.0
bin:
semver: bin/semver.js
checksum: ba9c7cbbf2b7884696523450a61fee1a09930d888b7a8d7579025ad93d459b2d1949ee5bbfeb188b2be5f4ac163544c5e98491ad6152df34154feebc2cc337c1
languageName: node
linkType: hard
"serialize-javascript@npm:6.0.0":
version: 6.0.0
resolution: "serialize-javascript@npm:6.0.0"
@ -15261,20 +15250,20 @@ __metadata:
languageName: node
linkType: hard
"sharp@npm:0.31.2":
version: 0.31.2
resolution: "sharp@npm:0.31.2"
"sharp@npm:0.30.7":
version: 0.30.7
resolution: "sharp@npm:0.30.7"
dependencies:
color: ^4.2.3
detect-libc: ^2.0.1
node-addon-api: ^5.0.0
node-gyp: latest
prebuild-install: ^7.1.1
semver: ^7.3.8
semver: ^7.3.7
simple-get: ^4.0.1
tar-fs: ^2.1.1
tunnel-agent: ^0.6.0
checksum: 076717b7a073ea47bb522ff2931b74b6608daeb6f7ae334e4848d47fdf4d23bcb18cd49044fd5fb27ef27a1a4aa87d141894d67d1c4bb15a6e2e63cf4dbe329e
checksum: bbc63ca3c7ea8a5bff32cd77022cfea30e25a03f5bd031e935924bf6cf0e11e3388e8b0e22b3137bf8816aa73407f1e4fbeb190f3a35605c27ffca9f32b91601
languageName: node
linkType: hard