Compare commits
8 commits
main
...
quollkey/m
Author | SHA1 | Date | |
---|---|---|---|
f011152ede | |||
6b04cfdb67 | |||
6e64e3d38f | |||
09f51889ff | |||
fd45cb515f | |||
6a57cbb72d | |||
cb5f59e8cb | |||
b298411c04 |
215 changed files with 4977 additions and 1854 deletions
|
@ -115,8 +115,14 @@ db:
|
|||
user: postgres
|
||||
pass: ci
|
||||
|
||||
# Whether disable Caching queries
|
||||
#disableCache: true
|
||||
## Log a warning to the server console if any query takes longer than this to complete.
|
||||
## Measured in milliseconds; set to 0 to disable. (default: 300)
|
||||
#slowQueryThreshold: 300
|
||||
|
||||
# If false, then query results will be cached in redis.
|
||||
# If true (default), then queries will not be cached.
|
||||
# This will reduce database load at the cost of increased Redis traffic and risk of bugs and unpredictable behavior.
|
||||
#disableCache: false
|
||||
|
||||
# Extra Connection options
|
||||
#extra:
|
||||
|
|
|
@ -57,8 +57,14 @@ db:
|
|||
user: postgres
|
||||
pass: postgres
|
||||
|
||||
# Whether disable Caching queries
|
||||
#disableCache: true
|
||||
## Log a warning to the server console if any query takes longer than this to complete.
|
||||
## Measured in milliseconds; set to 0 to disable. (default: 300)
|
||||
#slowQueryThreshold: 300
|
||||
|
||||
# If false, then query results will be cached in redis.
|
||||
# If true (default), then queries will not be cached.
|
||||
# This will reduce database load at the cost of increased Redis traffic and risk of bugs and unpredictable behavior.
|
||||
#disableCache: false
|
||||
|
||||
# Extra Connection options
|
||||
#extra:
|
||||
|
|
|
@ -118,8 +118,14 @@ db:
|
|||
user: example-quollkey-user
|
||||
pass: example-quollkey-pass
|
||||
|
||||
# Whether disable Caching queries
|
||||
#disableCache: true
|
||||
## Log a warning to the server console if any query takes longer than this to complete.
|
||||
## Measured in milliseconds; set to 0 to disable. (default: 300)
|
||||
#slowQueryThreshold: 300
|
||||
|
||||
# If false, then query results will be cached in redis.
|
||||
# If true (default), then queries will not be cached.
|
||||
# This will reduce database load at the cost of increased Redis traffic and risk of bugs and unpredictable behavior.
|
||||
#disableCache: false
|
||||
|
||||
# Extra Connection options
|
||||
#extra:
|
||||
|
|
|
@ -121,8 +121,14 @@ db:
|
|||
user: quollkey
|
||||
pass: example-misskey-pass
|
||||
|
||||
# Whether disable Caching queries
|
||||
#disableCache: true
|
||||
## Log a warning to the server console if any query takes longer than this to complete.
|
||||
## Measured in milliseconds; set to 0 to disable. (default: 300)
|
||||
#slowQueryThreshold: 300
|
||||
|
||||
# If false, then query results will be cached in redis.
|
||||
# If true (default), then queries will not be cached.
|
||||
# This will reduce database load at the cost of increased Redis traffic and risk of bugs and unpredictable behavior.
|
||||
#disableCache: false
|
||||
|
||||
# Extra Connection options
|
||||
#extra:
|
||||
|
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -87,3 +87,7 @@ vite.config.local-dev.ts.timestamp-*
|
|||
|
||||
# Sharkey
|
||||
/packages/megalodon/lib
|
||||
|
||||
# TypeScript
|
||||
.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
|
|
|
@ -2,7 +2,8 @@
|
|||
"compilerOptions": {
|
||||
"lib": ["dom", "es5"],
|
||||
"target": "es5",
|
||||
"types": ["cypress", "node"]
|
||||
"types": ["cypress", "node"],
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
|
|
116
locales/index.d.ts
vendored
116
locales/index.d.ts
vendored
|
@ -12492,6 +12492,14 @@ export interface Locale extends ILocale {
|
|||
* Displays content centered.
|
||||
*/
|
||||
"centerDescription": string;
|
||||
/**
|
||||
* Unix Time
|
||||
*/
|
||||
"unixtime": string;
|
||||
/**
|
||||
* Displays a timestamp in the viewer's current timezone.
|
||||
*/
|
||||
"unixtimeDescription": string;
|
||||
/**
|
||||
* Code (Inline)
|
||||
*/
|
||||
|
@ -13069,6 +13077,26 @@ export interface Locale extends ILocale {
|
|||
* Users popular on {name}
|
||||
*/
|
||||
"popularUsersLocal": ParameterizedString<"name">;
|
||||
/**
|
||||
* Polls trending on {name}
|
||||
*/
|
||||
"pollsOnLocal": ParameterizedString<"name">;
|
||||
/**
|
||||
* Polls trending on the global network
|
||||
*/
|
||||
"pollsOnRemote": string;
|
||||
/**
|
||||
* Polls that have ended recently
|
||||
*/
|
||||
"pollsExpired": string;
|
||||
/**
|
||||
* Trending polls are disabled on this instance.
|
||||
*/
|
||||
"trendingPollsDisabled": string;
|
||||
/**
|
||||
* Please log in to view trending polls.
|
||||
*/
|
||||
"trendingPollsDisabledLogIn": string;
|
||||
/**
|
||||
* Silenced
|
||||
*/
|
||||
|
@ -13129,6 +13157,94 @@ export interface Locale extends ILocale {
|
|||
* Timeout in milliseconds for translation API requests.
|
||||
*/
|
||||
"translationTimeoutCaption": string;
|
||||
/**
|
||||
* Staff notes
|
||||
*/
|
||||
"staffNotes": string;
|
||||
/**
|
||||
* Icon of {name}
|
||||
*/
|
||||
"instanceIconAlt": ParameterizedString<"name">;
|
||||
/**
|
||||
* Attribution Domains
|
||||
*/
|
||||
"attributionDomains": string;
|
||||
/**
|
||||
* A list of domains whose content can be attributed to you on link previews, separated by new-line. Any subdomain will also be valid. The following needs to be on the webpage:
|
||||
*/
|
||||
"attributionDomainsDescription": string;
|
||||
/**
|
||||
* Written by {user}
|
||||
*/
|
||||
"writtenBy": ParameterizedString<"user">;
|
||||
/**
|
||||
* Following (Pub)
|
||||
*/
|
||||
"followingPub": string;
|
||||
/**
|
||||
* Followers (Sub)
|
||||
*/
|
||||
"followersSub": string;
|
||||
/**
|
||||
* Well-known resources
|
||||
*/
|
||||
"wellKnownResources": string;
|
||||
/**
|
||||
* Last posted: {at}
|
||||
*/
|
||||
"lastPosted": ParameterizedString<"at">;
|
||||
/**
|
||||
* NSFW
|
||||
*/
|
||||
"nsfw": string;
|
||||
/**
|
||||
* Raw
|
||||
*/
|
||||
"raw": string;
|
||||
/**
|
||||
* CW
|
||||
*/
|
||||
"cw": string;
|
||||
/**
|
||||
* Media Silenced
|
||||
*/
|
||||
"mediaSilenced": string;
|
||||
/**
|
||||
* Bubble
|
||||
*/
|
||||
"bubble": string;
|
||||
/**
|
||||
* Verified
|
||||
*/
|
||||
"verified": string;
|
||||
/**
|
||||
* Not Verified
|
||||
*/
|
||||
"notVerified": string;
|
||||
/**
|
||||
* Hibernated
|
||||
*/
|
||||
"hibernated": string;
|
||||
/**
|
||||
* When replying to a post with a Content Warning, automatically use the same CW for the reply.
|
||||
*/
|
||||
"keepCwDescription": string;
|
||||
/**
|
||||
* Disabled (do not copy CWs)
|
||||
*/
|
||||
"keepCwDisabled": string;
|
||||
/**
|
||||
* Enabled (copy CWs verbatim)
|
||||
*/
|
||||
"keepCwEnabled": string;
|
||||
/**
|
||||
* Enabled (copy CW and prepend "RE:")
|
||||
*/
|
||||
"keepCwPrependRe": string;
|
||||
/**
|
||||
* Note controls
|
||||
*/
|
||||
"noteFooterLabel": string;
|
||||
}
|
||||
declare const locales: {
|
||||
[lang: string]: Locale;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "quollkey",
|
||||
"version": "2025.5.0-Q",
|
||||
"version": "2025.6.2-dev",
|
||||
"codename": "quoll",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
@ -7,6 +7,22 @@ export class AddMissingIndexes1747938628395 {
|
|||
name = 'AddMissingIndexes1747938628395'
|
||||
|
||||
async up(queryRunner) {
|
||||
// Some instances have duplicate list entries
|
||||
await queryRunner.query(`
|
||||
DELETE FROM "user_list_membership"
|
||||
WHERE "id" NOT IN (
|
||||
SELECT MIN("id")
|
||||
FROM "user_list_membership"
|
||||
GROUP BY "userId", "userListId"
|
||||
)`);
|
||||
|
||||
// Some instances already have these indexes, for an unknown reason
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_e4f3094c43f2d665e6030b0337"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_cddcaf418dc4d392ecfcca842a"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_021015e6683570ae9f6b0c62be"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_58699f75b9cf904f5f007909cb"`);
|
||||
|
||||
// Now the actual migration
|
||||
await queryRunner.query(`CREATE INDEX "IDX_58699f75b9cf904f5f007909cb" ON "user_profile" ("birthday") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_021015e6683570ae9f6b0c62be" ON "user_list_membership" ("userId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_cddcaf418dc4d392ecfcca842a" ON "user_list_membership" ("userListId") `);
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: piuvas and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class AddAttributionDomains1748096357260 {
|
||||
name = 'AddAttributionDomains1748096357260'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "attributionDomains" text array NOT NULL DEFAULT '{}'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "attributionDomains"`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class IndexIDXInstanceHostKey1748104955717 {
|
||||
name = 'IndexIDXInstanceHostKey1748104955717'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_instance_host_key" ON "instance" (((lower(reverse("host")) || '.')::text) text_pattern_ops)`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "IDX_instance_host_key"`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
|
||||
* @typedef {{ blockedHosts: string[], silencedHosts: string[], mediaSilencedHosts: string[], federationHosts: string[], bubbleInstances: string[] }} Meta
|
||||
*/
|
||||
|
||||
/**
|
||||
* @class
|
||||
* @implements {MigrationInterface}
|
||||
*/
|
||||
export class AddInstanceBlockColumns1748105111513 {
|
||||
name = 'AddInstanceBlockColumns1748105111513'
|
||||
|
||||
async up(queryRunner) {
|
||||
// Schema migration
|
||||
await queryRunner.query(`ALTER TABLE "instance" ADD "isBlocked" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "instance"."isBlocked" IS 'True if this instance is blocked from federation.'`);
|
||||
await queryRunner.query(`ALTER TABLE "instance" ADD "isAllowListed" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "instance"."isAllowListed" IS 'True if this instance is allow-listed.'`);
|
||||
await queryRunner.query(`ALTER TABLE "instance" ADD "isBubbled" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "instance"."isBubbled" IS 'True if this instance is part of the local bubble.'`);
|
||||
await queryRunner.query(`ALTER TABLE "instance" ADD "isSilenced" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "instance"."isSilenced" IS 'True if this instance is silenced.'`);
|
||||
await queryRunner.query(`ALTER TABLE "instance" ADD "isMediaSilenced" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "instance"."isMediaSilenced" IS 'True if this instance is media-silenced.'`);
|
||||
|
||||
// Data migration
|
||||
/** @type {Meta[]} */
|
||||
const metas = await queryRunner.query(`SELECT "blockedHosts", "silencedHosts", "mediaSilencedHosts", "federationHosts", "bubbleInstances" FROM "meta"`);
|
||||
if (metas.length > 0) {
|
||||
/** @type {Meta} */
|
||||
const meta = metas[0];
|
||||
|
||||
// Blocked hosts
|
||||
if (meta.blockedHosts.length > 0) {
|
||||
const patterns = buildPatterns(meta.blockedHosts);
|
||||
await queryRunner.query(`UPDATE "instance" SET "isBlocked" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]);
|
||||
}
|
||||
|
||||
// Silenced hosts
|
||||
if (meta.silencedHosts.length > 0) {
|
||||
const patterns = buildPatterns(meta.silencedHosts);
|
||||
await queryRunner.query(`UPDATE "instance" SET "isSilenced" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]);
|
||||
}
|
||||
|
||||
// Media silenced hosts
|
||||
if (meta.mediaSilencedHosts.length > 0) {
|
||||
const patterns = buildPatterns(meta.mediaSilencedHosts);
|
||||
await queryRunner.query(`UPDATE "instance" SET "isMediaSilenced" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]);
|
||||
}
|
||||
|
||||
// Allow-listed hosts
|
||||
if (meta.federationHosts.length > 0) {
|
||||
const patterns = buildPatterns(meta.federationHosts);
|
||||
await queryRunner.query(`UPDATE "instance" SET "isAllowListed" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]);
|
||||
}
|
||||
|
||||
// Bubbled hosts
|
||||
if (meta.bubbleInstances.length > 0) {
|
||||
const patterns = buildPatterns(meta.bubbleInstances);
|
||||
await queryRunner.query(`UPDATE "instance" SET "isBubbled" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isMediaSilenced"`);
|
||||
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isSilenced"`);
|
||||
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isBubbled"`);
|
||||
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isAllowListed"`);
|
||||
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isBlocked"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} input
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function buildPatterns(input) {
|
||||
return input.map(i => i.toLowerCase().split('').reverse().join('') + '.%');
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
|
||||
*/
|
||||
|
||||
/**
|
||||
* @class
|
||||
* @implements {MigrationInterface}
|
||||
*/
|
||||
export class AddInstanceForeignKeys1748128176881 {
|
||||
name = 'AddInstanceForeignKeys1748128176881'
|
||||
|
||||
async up(queryRunner) {
|
||||
// Fix-up: Some older instances have users without a matching instance entry
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "instance" ("id", "host", "firstRetrievedAt")
|
||||
SELECT
|
||||
MIN("id"),
|
||||
"host",
|
||||
COALESCE(MIN("lastFetchedAt"), CURRENT_TIMESTAMP)
|
||||
FROM "user"
|
||||
WHERE
|
||||
"host" IS NOT NULL AND
|
||||
NOT EXISTS (select 1 from "instance" where "instance"."host" = "user"."host")
|
||||
GROUP BY "host"
|
||||
`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_user_host" FOREIGN KEY ("host") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_note_userHost" FOREIGN KEY ("userHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_note_replyUserHost" FOREIGN KEY ("replyUserHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_note_renoteUserHost" FOREIGN KEY ("renoteUserHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_note_renoteUserHost"`);
|
||||
await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_note_replyUserHost"`);
|
||||
await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_note_userHost"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_user_host"`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
|
||||
*/
|
||||
|
||||
/**
|
||||
* @class
|
||||
* @implements {MigrationInterface}
|
||||
*/
|
||||
export class AddInstanceForeignKeysToFollowing1748137683887 {
|
||||
name = 'AddInstanceForeignKeysToFollowing1748137683887'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "following" ADD CONSTRAINT "FK_following_followerHost" FOREIGN KEY ("followerHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "following" ADD CONSTRAINT "FK_following_followeeHost" FOREIGN KEY ("followeeHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "following" DROP CONSTRAINT "FK_following_followeeHost"`);
|
||||
await queryRunner.query(`ALTER TABLE "following" DROP CONSTRAINT "FK_following_followerHost"`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
|
||||
*/
|
||||
|
||||
/**
|
||||
* @class
|
||||
* @implements {MigrationInterface}
|
||||
*/
|
||||
export class AnalyzeInstanceUserNoteFollowing1748191631151 {
|
||||
name = 'AnalyzeInstanceUserNoteFollowing1748191631151'
|
||||
|
||||
async up(queryRunner) {
|
||||
// Refresh statistics for tables impacted by new indexes.
|
||||
// This helps the query planner to efficiently use them without waiting for the next full vacuum.
|
||||
await queryRunner.query(`ANALYZE "instance", "user", "following", "note"`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class ReplaceNoteUserHostIndex1748990452958 {
|
||||
name = 'ReplaceNoteUserHostIndex1748990452958'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_7125a826ab192eb27e11d358a5"`);
|
||||
await queryRunner.query(`
|
||||
create index "IDX_note_userHost_id"
|
||||
on "note" ("userHost", "id" desc)
|
||||
nulls not distinct`);
|
||||
await queryRunner.query(`comment on index "IDX_note_userHost_id" is 'User host with ID included'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`drop index if exists "IDX_note_userHost_id"`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_7125a826ab192eb27e11d358a5" ON "note" ("userHost") `);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class FixIDXInstanceHostKey1748990662839 {
|
||||
async up(queryRunner) {
|
||||
// must include host for index-only scans: https://www.postgresql.org/docs/current/indexes-index-only-scans.html
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_instance_host_key"`);
|
||||
await queryRunner.query(`
|
||||
create index "IDX_instance_host_key"
|
||||
on "instance" ((lower(reverse("host"::text)) || '.'::text) text_pattern_ops)
|
||||
include ("host")
|
||||
`);
|
||||
await queryRunner.query(`comment on index "IDX_instance_host_key" is 'Expression index for finding instances by base domain'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_instance_host_key"`);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_instance_host_key" ON "instance" (((lower(reverse("host")) || '.')::text) text_pattern_ops)`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class CreateIDXNoteForTimelines1748991828473 {
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`
|
||||
create index "IDX_note_for_timelines"
|
||||
on "note" ("id" desc, "channelId", "visibility", "userHost")
|
||||
include ("userId", "userHost", "replyId", "replyUserId", "replyUserHost", "renoteId", "renoteUserId", "renoteUserHost")
|
||||
NULLS NOT DISTINCT`);
|
||||
await queryRunner.query(`comment on index "IDX_note_for_timelines" is 'Covering index for timeline queries'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "IDX_note_for_timelines"`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class CreateIDXInstanceHostFilters1748992017688 {
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`
|
||||
create index "IDX_instance_host_filters"
|
||||
on "instance" ("host", "isBlocked", "isSilenced", "isMediaSilenced", "isAllowListed", "isBubbled", "suspensionState")`);
|
||||
await queryRunner.query(`comment on index "IDX_instance_host_filters" is 'Covering index for host filter queries'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "IDX_instance_host_filters"`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class CreateStatistics1748992128683 {
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE STATISTICS "STTS_instance_isBlocked_isBubbled" (mcv) ON "isBlocked", "isBubbled" FROM "instance"`);
|
||||
await queryRunner.query(`CREATE STATISTICS "STTS_instance_isBlocked_isSilenced" (mcv) ON "isBlocked", "isSilenced" FROM "instance"`);
|
||||
await queryRunner.query(`CREATE STATISTICS "STTS_note_replyId_replyUserId_replyUserHost" (dependencies) ON "replyId", "replyUserId", "replyUserHost" FROM "note"`)
|
||||
await queryRunner.query(`CREATE STATISTICS "STTS_note_renoteId_renoteUserId_renoteUserHost" (dependencies) ON "renoteId", "renoteUserId", "renoteUserHost" FROM "note"`);
|
||||
await queryRunner.query(`CREATE STATISTICS "STTS_note_userId_userHost" (mcv) ON "userId", "userHost" FROM "note"`);
|
||||
await queryRunner.query(`CREATE STATISTICS "STTS_note_replyUserId_replyUserHost" (mcv) ON "replyUserId", "replyUserHost" FROM "note"`);
|
||||
await queryRunner.query(`CREATE STATISTICS "STTS_note_renoteUserId_renoteUserHost" (mcv) ON "renoteUserId", "renoteUserHost" FROM "note"`);
|
||||
await queryRunner.query(`ANALYZE "note", "instance"`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP STATISTICS "STTS_instance_isBlocked_isBubbled"`);
|
||||
await queryRunner.query(`DROP STATISTICS "STTS_instance_isBlocked_isSilenced"`);
|
||||
await queryRunner.query(`DROP STATISTICS "STTS_note_replyId_replyUserId_replyUserHost"`);
|
||||
await queryRunner.query(`DROP STATISTICS "STTS_note_renoteId_renoteUserId_renoteUserHost"`);
|
||||
await queryRunner.query(`DROP STATISTICS "STTS_note_userId_userHost"`);
|
||||
await queryRunner.query(`DROP STATISTICS "STTS_note_replyUserId_replyUserHost"`);
|
||||
await queryRunner.query(`DROP STATISTICS "STTS_note_renoteUserId_renoteUserHost"`);
|
||||
await queryRunner.query(`ANALYZE "note", "instance"`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class FixIDXNoteForTimeline1749097536193 {
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query('drop index "IDX_note_for_timelines"');
|
||||
await queryRunner.query(`
|
||||
create index "IDX_note_for_timelines"
|
||||
on "note" ("id" desc, "channelId", "visibility", "userHost")
|
||||
include ("userId", "replyId", "replyUserId", "replyUserHost", "renoteId", "renoteUserId", "renoteUserHost", "threadId")
|
||||
NULLS NOT DISTINCT
|
||||
`);
|
||||
await queryRunner.query(`comment on index "IDX_note_for_timelines" is 'Covering index for timeline queries'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query('drop index "IDX_note_for_timelines"');
|
||||
await queryRunner.query(`
|
||||
create index "IDX_note_for_timelines"
|
||||
on "note" ("id" desc, "channelId", "visibility", "userHost")
|
||||
include ("userId", "userHost", "replyId", "replyUserId", "replyUserHost", "renoteId", "renoteUserId", "renoteUserHost")
|
||||
NULLS NOT DISTINCT
|
||||
`);
|
||||
await queryRunner.query(`comment on index "IDX_note_for_timelines" is 'Covering index for timeline queries'`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class CreateIDXNoteUrl1749229288946 {
|
||||
name = 'CreateIDXNoteUrl1749229288946'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_note_url" ON "note" ("url") `);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_note_url"`);
|
||||
}
|
||||
}
|
|
@ -10,6 +10,9 @@
|
|||
"start": "node ./built/boot/entry.js",
|
||||
"start:test": "cross-env NODE_ENV=test node ./built/boot/entry.js",
|
||||
"migrate": "pnpm typeorm migration:run -d ormconfig.js",
|
||||
"migrate:revert": "pnpm typeorm migration:revert -d ormconfig.js",
|
||||
"migrate:generate": "pnpm typeorm migration:generate -d ormconfig.js",
|
||||
"migrate:create": "pnpm typeorm migration:create",
|
||||
"revert": "pnpm typeorm migration:revert -d ormconfig.js",
|
||||
"check:connect": "node ./scripts/check_connect.js",
|
||||
"build": "swc src -d built -D --strip-leading-paths",
|
||||
|
|
|
@ -9,6 +9,7 @@ import { dirname, resolve } from 'node:path';
|
|||
import * as yaml from 'js-yaml';
|
||||
import { globSync } from 'glob';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import Logger from './logger.js';
|
||||
import type * as Sentry from '@sentry/node';
|
||||
import type * as SentryVue from '@sentry/vue';
|
||||
import type { RedisOptions } from 'ioredis';
|
||||
|
@ -40,6 +41,7 @@ type Source = {
|
|||
db?: string;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
slowQueryThreshold?: number;
|
||||
disableCache?: boolean;
|
||||
extra?: { [x: string]: string };
|
||||
};
|
||||
|
@ -155,6 +157,8 @@ type Source = {
|
|||
}
|
||||
};
|
||||
|
||||
const configLogger = new Logger('config');
|
||||
|
||||
export type PrivateNetworkSource = string | { network?: string, ports?: number[] };
|
||||
|
||||
export type PrivateNetwork = {
|
||||
|
@ -192,7 +196,7 @@ export function parsePrivateNetworks(patterns: PrivateNetworkSource[] | undefine
|
|||
}
|
||||
}
|
||||
|
||||
console.warn('[config] Skipping invalid entry in allowedPrivateNetworks: ', e);
|
||||
configLogger.warn('Skipping invalid entry in allowedPrivateNetworks: ', e);
|
||||
return null;
|
||||
})
|
||||
.filter(p => p != null);
|
||||
|
@ -222,6 +226,7 @@ export type Config = {
|
|||
db: string;
|
||||
user: string;
|
||||
pass: string;
|
||||
slowQueryThreshold?: number;
|
||||
disableCache?: boolean;
|
||||
extra?: { [x: string]: string };
|
||||
};
|
||||
|
@ -375,11 +380,14 @@ export function loadConfig(): Config {
|
|||
|
||||
if (configFiles.length === 0
|
||||
&& !process.env['MK_WARNED_ABOUT_CONFIG']) {
|
||||
console.log('No config files loaded, check if this is intentional');
|
||||
configLogger.warn('No config files loaded, check if this is intentional');
|
||||
process.env['MK_WARNED_ABOUT_CONFIG'] = '1';
|
||||
}
|
||||
|
||||
const config = configFiles.map(path => fs.readFileSync(path, 'utf-8'))
|
||||
const config = configFiles.map(path => {
|
||||
configLogger.info(`Reading configuration from ${path}`);
|
||||
return fs.readFileSync(path, 'utf-8');
|
||||
})
|
||||
.map(contents => yaml.load(contents) as Source)
|
||||
.reduce(
|
||||
(acc: Source, cur: Source) => Object.assign(acc, cur),
|
||||
|
@ -405,6 +413,10 @@ export function loadConfig(): Config {
|
|||
const internalMediaProxy = `${scheme}://${host}/proxy`;
|
||||
const redis = convertRedisOptions(config.redis, host);
|
||||
|
||||
// nullish => 300 (default)
|
||||
// 0 => undefined (disabled)
|
||||
const slowQueryThreshold = (config.db.slowQueryThreshold ?? 300) || undefined;
|
||||
|
||||
return {
|
||||
version,
|
||||
publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl,
|
||||
|
@ -423,7 +435,7 @@ export function loadConfig(): Config {
|
|||
apiUrl: `${scheme}://${host}/api`,
|
||||
authUrl: `${scheme}://${host}/auth`,
|
||||
driveUrl: `${scheme}://${host}/files`,
|
||||
db: { ...config.db, db: dbDb, user: dbUser, pass: dbPass },
|
||||
db: { ...config.db, db: dbDb, user: dbUser, pass: dbPass, slowQueryThreshold },
|
||||
dbReplications: config.dbReplications,
|
||||
dbSlaves: config.dbSlaves,
|
||||
fulltextSearch: config.fulltextSearch,
|
||||
|
@ -496,6 +508,10 @@ export function loadConfig(): Config {
|
|||
}
|
||||
|
||||
function tryCreateUrl(url: string) {
|
||||
if (!url) {
|
||||
throw new Error('Failed to load: no "url" property found in config. Please check the value of "MISSKEY_CONFIG_DIR" and "MISSKEY_CONFIG_YML", and verify that all configuration files are correct.');
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(url);
|
||||
} catch (e) {
|
||||
|
@ -627,7 +643,7 @@ function applyEnvOverrides(config: Source) {
|
|||
// these are all the settings that can be overridden
|
||||
|
||||
_apply_top([['url', 'port', 'address', 'socket', 'chmodSocket', 'disableHsts', 'id', 'dbReplications', 'websocketCompression']]);
|
||||
_apply_top(['db', ['host', 'port', 'db', 'user', 'pass', 'disableCache']]);
|
||||
_apply_top(['db', ['host', 'port', 'db', 'user', 'pass', 'slowQueryThreshold', 'disableCache']]);
|
||||
_apply_top(['dbSlaves', Array.from((config.dbSlaves ?? []).keys()), ['host', 'port', 'db', 'user', 'pass']]);
|
||||
_apply_top([
|
||||
['redis', 'redisForPubsub', 'redisForJobQueue', 'redisForTimelines', 'redisForReactions', 'redisForRateLimit'],
|
||||
|
|
|
@ -159,6 +159,14 @@ export class DriveService {
|
|||
// thunbnail, webpublic を必要なら生成
|
||||
const alts = await this.generateAlts(path, type, !file.uri);
|
||||
|
||||
if (type && type.startsWith('video/')) {
|
||||
try {
|
||||
await this.videoProcessingService.webOptimizeVideo(path, type);
|
||||
} catch (err) {
|
||||
this.registerLogger.warn(`Video optimization failed: ${err instanceof Error ? err.message : String(err)}`, { error: err });
|
||||
}
|
||||
}
|
||||
|
||||
if (this.meta.useObjectStorage) {
|
||||
//#region ObjectStorage params
|
||||
let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) ?? ['']);
|
||||
|
|
|
@ -136,10 +136,10 @@ export class FanoutTimelineEndpointService {
|
|||
const parentFilter = filter;
|
||||
filter = (note) => {
|
||||
if (!ps.ignoreAuthorFromInstanceBlock) {
|
||||
if (this.utilityService.isBlockedHost(this.meta.blockedHosts, note.userHost)) return false;
|
||||
if (note.userInstance?.isBlocked) return false;
|
||||
}
|
||||
if (note.userId !== note.renoteUserId && this.utilityService.isBlockedHost(this.meta.blockedHosts, note.renoteUserHost)) return false;
|
||||
if (note.userId !== note.replyUserId && this.utilityService.isBlockedHost(this.meta.blockedHosts, note.replyUserHost)) return false;
|
||||
if (note.userId !== note.renoteUserId && note.renoteUserInstance?.isBlocked) return false;
|
||||
if (note.userId !== note.replyUserId && note.replyUserInstance?.isBlocked) return false;
|
||||
|
||||
return parentFilter(note);
|
||||
};
|
||||
|
@ -194,7 +194,10 @@ export class FanoutTimelineEndpointService {
|
|||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
.leftJoinAndSelect('note.channel', 'channel')
|
||||
.leftJoinAndSelect('note.userInstance', 'userInstance')
|
||||
.leftJoinAndSelect('note.replyUserInstance', 'replyUserInstance')
|
||||
.leftJoinAndSelect('note.renoteUserInstance', 'renoteUserInstance');
|
||||
|
||||
const notes = (await query.getMany()).filter(noteFilter);
|
||||
|
||||
|
|
|
@ -5,23 +5,24 @@
|
|||
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
import type { InstancesRepository } from '@/models/_.js';
|
||||
import type { InstancesRepository, MiMeta } from '@/models/_.js';
|
||||
import type { MiInstance } from '@/models/Instance.js';
|
||||
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
||||
import { MemoryKVCache } from '@/misc/cache.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import { Serialized } from '@/types.js';
|
||||
import { diffArrays, diffArraysSimple } from '@/misc/diff-arrays.js';
|
||||
|
||||
@Injectable()
|
||||
export class FederatedInstanceService implements OnApplicationShutdown {
|
||||
public federatedInstanceCache: RedisKVCache<MiInstance | null>;
|
||||
private readonly federatedInstanceCache: MemoryKVCache<MiInstance | null>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
@ -29,67 +30,46 @@ export class FederatedInstanceService implements OnApplicationShutdown {
|
|||
private utilityService: UtilityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
this.federatedInstanceCache = new RedisKVCache<MiInstance | null>(this.redisClient, 'federatedInstance', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
memoryCacheLifetime: 1000 * 60 * 3, // 3m
|
||||
fetcher: (key) => this.instancesRepository.findOneBy({ host: key }),
|
||||
toRedisConverter: (value) => JSON.stringify(value),
|
||||
fromRedisConverter: (value) => {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed == null) return null;
|
||||
return {
|
||||
...parsed,
|
||||
firstRetrievedAt: new Date(parsed.firstRetrievedAt),
|
||||
latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null,
|
||||
infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null,
|
||||
notRespondingSince: parsed.notRespondingSince ? new Date(parsed.notRespondingSince) : null,
|
||||
};
|
||||
},
|
||||
});
|
||||
this.federatedInstanceCache = new MemoryKVCache(1000 * 60 * 3); // 3m
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetchOrRegister(host: string): Promise<MiInstance> {
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
const cached = await this.federatedInstanceCache.get(host);
|
||||
const cached = this.federatedInstanceCache.get(host);
|
||||
if (cached) return cached;
|
||||
|
||||
const index = await this.instancesRepository.findOneBy({ host });
|
||||
|
||||
let index = await this.instancesRepository.findOneBy({ host });
|
||||
if (index == null) {
|
||||
let i;
|
||||
try {
|
||||
i = await this.instancesRepository.insertOne({
|
||||
await this.instancesRepository.createQueryBuilder('instance')
|
||||
.insert()
|
||||
.values({
|
||||
id: this.idService.gen(),
|
||||
host,
|
||||
firstRetrievedAt: new Date(),
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof QueryFailedError) {
|
||||
if (isDuplicateKeyValueError(e)) {
|
||||
i = await this.instancesRepository.findOneBy({ host });
|
||||
}
|
||||
isBlocked: this.utilityService.isBlockedHost(host),
|
||||
isSilenced: this.utilityService.isSilencedHost(host),
|
||||
isMediaSilenced: this.utilityService.isMediaSilencedHost(host),
|
||||
isAllowListed: this.utilityService.isAllowListedHost(host),
|
||||
isBubbled: this.utilityService.isBubbledHost(host),
|
||||
})
|
||||
.orIgnore()
|
||||
.execute();
|
||||
|
||||
index = await this.instancesRepository.findOneByOrFail({ host });
|
||||
}
|
||||
|
||||
if (i == null) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
this.federatedInstanceCache.set(host, i);
|
||||
return i;
|
||||
} else {
|
||||
this.federatedInstanceCache.set(host, index);
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetch(host: string): Promise<MiInstance | null> {
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
const cached = await this.federatedInstanceCache.get(host);
|
||||
const cached = this.federatedInstanceCache.get(host);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const index = await this.instancesRepository.findOneBy({ host });
|
||||
|
@ -117,8 +97,35 @@ export class FederatedInstanceService implements OnApplicationShutdown {
|
|||
this.federatedInstanceCache.set(result.host, result);
|
||||
}
|
||||
|
||||
private syncCache(before: Serialized<MiMeta | undefined>, after: Serialized<MiMeta>): void {
|
||||
const changed =
|
||||
diffArraysSimple(before?.blockedHosts, after.blockedHosts) ||
|
||||
diffArraysSimple(before?.silencedHosts, after.silencedHosts) ||
|
||||
diffArraysSimple(before?.mediaSilencedHosts, after.mediaSilencedHosts) ||
|
||||
diffArraysSimple(before?.federationHosts, after.federationHosts) ||
|
||||
diffArraysSimple(before?.bubbleInstances, after.bubbleInstances);
|
||||
|
||||
if (changed) {
|
||||
// We have to clear the whole thing, otherwise subdomains won't be synced.
|
||||
this.federatedInstanceCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onMessage(_: string, data: string): Promise<void> {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
if (type === 'metaUpdated') {
|
||||
this.syncCache(body.before, body.after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
this.federatedInstanceCache.dispose();
|
||||
}
|
||||
|
||||
|
|
|
@ -235,7 +235,7 @@ export class HttpRequestService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObjectWithId> {
|
||||
public async getActivityJson(url: string, isLocalAddressAllowed = false, allowAnonymous = false): Promise<IObjectWithId> {
|
||||
this.apUtilityService.assertApUrl(url);
|
||||
|
||||
const res = await this.send(url, {
|
||||
|
@ -255,7 +255,11 @@ export class HttpRequestService {
|
|||
|
||||
// Make sure the object ID matches the final URL (which is where it actually exists).
|
||||
// The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match.
|
||||
if (allowAnonymous && activity.id == null) {
|
||||
activity.id = res.url;
|
||||
} else {
|
||||
this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url);
|
||||
}
|
||||
|
||||
return activity as IObjectWithId;
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { DI } from '@/di-symbols.js';
|
|||
import type { LatestNotesRepository, NotesRepository } from '@/models/_.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import Logger from '@/logger.js';
|
||||
import { QueryService } from './QueryService.js';
|
||||
|
||||
@Injectable()
|
||||
export class LatestNoteService {
|
||||
|
@ -14,11 +15,12 @@ export class LatestNoteService {
|
|||
|
||||
constructor(
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
private readonly notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.latestNotesRepository)
|
||||
private latestNotesRepository: LatestNotesRepository,
|
||||
private readonly latestNotesRepository: LatestNotesRepository,
|
||||
|
||||
private readonly queryService: QueryService,
|
||||
loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = loggerService.getLogger('LatestNoteService');
|
||||
|
@ -91,7 +93,7 @@ export class LatestNoteService {
|
|||
|
||||
// Find the newest remaining note for the user.
|
||||
// We exclude DMs and pure renotes.
|
||||
const nextLatest = await this.notesRepository
|
||||
const query = this.notesRepository
|
||||
.createQueryBuilder('note')
|
||||
.select()
|
||||
.where({
|
||||
|
@ -106,18 +108,11 @@ export class LatestNoteService {
|
|||
? Not(null)
|
||||
: null,
|
||||
})
|
||||
.andWhere(`
|
||||
(
|
||||
note."renoteId" IS NULL
|
||||
OR note.text IS NOT NULL
|
||||
OR note.cw IS NOT NULL
|
||||
OR note."replyId" IS NOT NULL
|
||||
OR note."hasPoll"
|
||||
OR note."fileIds" != '{}'
|
||||
)
|
||||
`)
|
||||
.orderBy({ id: 'DESC' })
|
||||
.getOne();
|
||||
.orderBy({ id: 'DESC' });
|
||||
|
||||
this.queryService.andIsNotRenote(query, 'note');
|
||||
|
||||
const nextLatest = await query.getOne();
|
||||
if (!nextLatest) return;
|
||||
|
||||
// Record it as the latest
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { MiMeta } from '@/models/Meta.js';
|
||||
|
@ -12,6 +12,9 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
import { MiInstance } from '@/models/Instance.js';
|
||||
import { diffArrays } from '@/misc/diff-arrays.js';
|
||||
import type { MetasRepository } from '@/models/_.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
|
@ -26,6 +29,9 @@ export class MetaService implements OnApplicationShutdown {
|
|||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.metasRepository)
|
||||
private readonly metasRepository: MetasRepository,
|
||||
|
||||
private featuredService: FeaturedService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
|
@ -67,35 +73,35 @@ export class MetaService implements OnApplicationShutdown {
|
|||
public async fetch(noCache = false): Promise<MiMeta> {
|
||||
if (!noCache && this.cache) return this.cache;
|
||||
|
||||
return await this.db.transaction(async transactionalEntityManager => {
|
||||
// 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する
|
||||
const metas = await transactionalEntityManager.find(MiMeta, {
|
||||
order: {
|
||||
let meta = await this.metasRepository.createQueryBuilder('meta')
|
||||
.select()
|
||||
.orderBy({
|
||||
id: 'DESC',
|
||||
},
|
||||
});
|
||||
})
|
||||
.limit(1)
|
||||
.getOne();
|
||||
|
||||
const meta = metas[0];
|
||||
if (!meta) {
|
||||
await this.metasRepository.createQueryBuilder('meta')
|
||||
.insert()
|
||||
.values({
|
||||
id: 'x',
|
||||
})
|
||||
.orIgnore()
|
||||
.execute();
|
||||
|
||||
meta = await this.metasRepository.createQueryBuilder('meta')
|
||||
.select()
|
||||
.orderBy({
|
||||
id: 'DESC',
|
||||
})
|
||||
.limit(1)
|
||||
.getOneOrFail();
|
||||
}
|
||||
|
||||
if (meta) {
|
||||
this.cache = meta;
|
||||
return meta;
|
||||
} else {
|
||||
// metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う
|
||||
const saved = await transactionalEntityManager
|
||||
.upsert(
|
||||
MiMeta,
|
||||
{
|
||||
id: 'x',
|
||||
},
|
||||
['id'],
|
||||
)
|
||||
.then((x) => transactionalEntityManager.findOneByOrFail(MiMeta, x.identifiers[0]));
|
||||
|
||||
this.cache = saved;
|
||||
return saved;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -103,7 +109,7 @@ export class MetaService implements OnApplicationShutdown {
|
|||
let before: MiMeta | undefined;
|
||||
|
||||
const updated = await this.db.transaction(async transactionalEntityManager => {
|
||||
const metas = await transactionalEntityManager.find(MiMeta, {
|
||||
const metas: (MiMeta | undefined)[] = await transactionalEntityManager.find(MiMeta, {
|
||||
order: {
|
||||
id: 'DESC',
|
||||
},
|
||||
|
@ -126,6 +132,10 @@ export class MetaService implements OnApplicationShutdown {
|
|||
},
|
||||
});
|
||||
|
||||
// Propagate changes to blockedHosts, silencedHosts, mediaSilencedHosts, federationInstances, and bubbleInstances to the relevant instance rows
|
||||
// Do this inside the transaction to avoid potential race condition (when an instance gets registered while we're updating).
|
||||
await this.persistBlocks(transactionalEntityManager, before ?? {}, afters[0]);
|
||||
|
||||
return afters[0];
|
||||
});
|
||||
|
||||
|
@ -159,4 +169,49 @@ export class MetaService implements OnApplicationShutdown {
|
|||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
|
||||
private async persistBlocks(tem: EntityManager, before: Partial<MiMeta>, after: Partial<MiMeta>): Promise<void> {
|
||||
await this.persistBlock(tem, before.blockedHosts, after.blockedHosts, 'isBlocked');
|
||||
await this.persistBlock(tem, before.silencedHosts, after.silencedHosts, 'isSilenced');
|
||||
await this.persistBlock(tem, before.mediaSilencedHosts, after.mediaSilencedHosts, 'isMediaSilenced');
|
||||
await this.persistBlock(tem, before.federationHosts, after.federationHosts, 'isAllowListed');
|
||||
await this.persistBlock(tem, before.bubbleInstances, after.bubbleInstances, 'isBubbled');
|
||||
}
|
||||
|
||||
private async persistBlock(tem: EntityManager, before: string[] | undefined, after: string[] | undefined, field: keyof MiInstance): Promise<void> {
|
||||
const { added, removed } = diffArrays(before, after);
|
||||
|
||||
if (removed.length > 0) {
|
||||
await this.updateInstancesByHost(tem, field, false, removed);
|
||||
}
|
||||
|
||||
if (added.length > 0) {
|
||||
await this.updateInstancesByHost(tem, field, true, added);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateInstancesByHost(tem: EntityManager, field: keyof MiInstance, value: boolean, hosts: string[]): Promise<void> {
|
||||
// Use non-array queries when possible, as they are indexed and can be much faster.
|
||||
if (hosts.length === 1) {
|
||||
const pattern = genHostPattern(hosts[0]);
|
||||
await tem
|
||||
.createQueryBuilder(MiInstance, 'instance')
|
||||
.update()
|
||||
.set({ [field]: value })
|
||||
.where('(lower(reverse("host")) || \'.\') LIKE :pattern', { pattern })
|
||||
.execute();
|
||||
} else if (hosts.length > 1) {
|
||||
const patterns = hosts.map(host => genHostPattern(host));
|
||||
await tem
|
||||
.createQueryBuilder(MiInstance, 'instance')
|
||||
.update()
|
||||
.set({ [field]: value })
|
||||
.where('(lower(reverse("host")) || \'.\') LIKE ANY (:patterns)', { patterns })
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function genHostPattern(host: string): string {
|
||||
return host.toLowerCase().split('').reverse().join('') + '.%';
|
||||
}
|
||||
|
|
|
@ -4,13 +4,14 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Brackets, ObjectLiteral } from 'typeorm';
|
||||
import { Brackets, Not, WhereExpressionBuilder } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta } from '@/models/_.js';
|
||||
import { MiInstance } from '@/models/Instance.js';
|
||||
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta, InstancesRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { SelectQueryBuilder } from 'typeorm';
|
||||
import type { SelectQueryBuilder, ObjectLiteral } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class QueryService {
|
||||
|
@ -36,6 +37,9 @@ export class QueryService {
|
|||
@Inject(DI.renoteMutingsRepository)
|
||||
private renoteMutingsRepository: RenoteMutingsRepository,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private readonly instancesRepository: InstancesRepository,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
|
@ -72,218 +76,483 @@ export class QueryService {
|
|||
|
||||
// ここでいうBlockedは被Blockedの意
|
||||
@bindThis
|
||||
public generateBlockedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
||||
.select('blocking.blockerId')
|
||||
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
|
||||
|
||||
public generateBlockedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||
// 投稿の作者にブロックされていない かつ
|
||||
// 投稿の返信先の作者にブロックされていない かつ
|
||||
// 投稿の引用元の作者にブロックされていない
|
||||
q
|
||||
.andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`)
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyUserId IS NULL')
|
||||
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.renoteUserId IS NULL')
|
||||
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
}));
|
||||
|
||||
q.setParameters(blockingQuery.getParameters());
|
||||
return this
|
||||
.andNotBlockingUser(q, 'note.userId', ':meId')
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotBlockingUser(qb, 'note.replyUserId', ':meId')
|
||||
.orWhere('note.replyUserId IS NULL')))
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotBlockingUser(qb, 'note.renoteUserId', ':meId')
|
||||
.orWhere('note.renoteUserId IS NULL')))
|
||||
.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateBlockQueryForUsers(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
||||
.select('blocking.blockeeId')
|
||||
.where('blocking.blockerId = :blockerId', { blockerId: me.id });
|
||||
|
||||
const blockedQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
||||
.select('blocking.blockerId')
|
||||
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
|
||||
|
||||
q.andWhere(`user.id NOT IN (${ blockingQuery.getQuery() })`);
|
||||
q.setParameters(blockingQuery.getParameters());
|
||||
|
||||
q.andWhere(`user.id NOT IN (${ blockedQuery.getQuery() })`);
|
||||
q.setParameters(blockedQuery.getParameters());
|
||||
public generateBlockQueryForUsers<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||
this.andNotBlockingUser(q, ':meId', 'user.id');
|
||||
this.andNotBlockingUser(q, 'user.id', ':me.id');
|
||||
return q.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
|
||||
.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.setParameters(mutedQuery.getParameters());
|
||||
public generateMutedNoteThreadQuery<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||
return this
|
||||
.andNotMutingThread(q, ':meId', 'note.id')
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotMutingThread(qb, ':meId', 'note.threadId')
|
||||
.orWhere('note.threadId IS NULL')))
|
||||
.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void {
|
||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('muting.muteeId')
|
||||
.where('muting.muterId = :muterId', { muterId: me.id });
|
||||
|
||||
if (exclude) {
|
||||
mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id });
|
||||
}
|
||||
|
||||
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
|
||||
.select('user_profile.mutedInstances')
|
||||
.where('user_profile.userId = :muterId', { muterId: me.id });
|
||||
|
||||
public generateMutedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||
// 投稿の作者をミュートしていない かつ
|
||||
// 投稿の返信先の作者をミュートしていない かつ
|
||||
// 投稿の引用元の作者をミュートしていない
|
||||
q
|
||||
.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`)
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyUserId IS NULL')
|
||||
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.renoteUserId IS NULL')
|
||||
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
}))
|
||||
return this
|
||||
.andNotMutingUser(q, ':meId', 'note.userId', exclude)
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotMutingUser(qb, ':meId', 'note.replyUserId', exclude)
|
||||
.orWhere('note.replyUserId IS NULL')))
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotMutingUser(qb, ':meId', 'note.renoteUserId', exclude)
|
||||
.orWhere('note.renoteUserId IS NULL')))
|
||||
// TODO exclude should also pass a host to skip these instances
|
||||
// mute instances
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.andWhere('note.userHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.renoteUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
|
||||
}));
|
||||
|
||||
q.setParameters(mutingQuery.getParameters());
|
||||
q.setParameters(mutingInstanceQuery.getParameters());
|
||||
.andWhere(new Brackets(qb => this
|
||||
.andNotMutingInstance(qb, ':meId', 'note.userHost')
|
||||
.orWhere('note.userHost IS NULL')))
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotMutingInstance(qb, ':meId', 'note.replyUserHost')
|
||||
.orWhere('note.replyUserHost IS NULL')))
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotMutingInstance(qb, ':meId', 'note.renoteUserHost')
|
||||
.orWhere('note.renoteUserHost IS NULL')))
|
||||
.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedUserQueryForUsers(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('muting.muteeId')
|
||||
.where('muting.muterId = :muterId', { muterId: me.id });
|
||||
|
||||
q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`);
|
||||
|
||||
q.setParameters(mutingQuery.getParameters());
|
||||
public generateMutedUserQueryForUsers<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||
return this
|
||||
.andNotMutingUser(q, ':meId', 'user.id')
|
||||
.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
// This intentionally skips isSuspended, isDeleted, makeNotesFollowersOnlyBefore, makeNotesHiddenBefore, and requireSigninToViewContents.
|
||||
// NoteEntityService checks these automatically and calls hideNote() to hide them without breaking threads.
|
||||
// For moderation purposes, you can set isSilenced to forcibly hide existing posts by a user.
|
||||
@bindThis
|
||||
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void {
|
||||
public generateVisibilityQuery<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null): SelectQueryBuilder<E> {
|
||||
// This code must always be synchronized with the checks in Notes.isVisibleForMe.
|
||||
if (me == null) {
|
||||
q.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.visibility = \'public\'')
|
||||
return q.andWhere(new Brackets(qb => {
|
||||
// Public post
|
||||
qb.orWhere('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}));
|
||||
} else {
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :meId');
|
||||
|
||||
q.andWhere(new Brackets(qb => {
|
||||
if (me != null) {
|
||||
qb
|
||||
// 公開投稿である
|
||||
.where(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}))
|
||||
// または 自分自身
|
||||
.orWhere('note.userId = :meId')
|
||||
// または 自分宛て
|
||||
// My post
|
||||
.orWhere(':meId = note.userId')
|
||||
// Reply to me
|
||||
.orWhere(':meId = note.replyUserId')
|
||||
// DM to me
|
||||
.orWhere(':meIdAsList <@ note.visibleUserIds')
|
||||
.orWhere(new Brackets(qb => {
|
||||
qb
|
||||
// または フォロワー宛ての投稿であり、
|
||||
.where('note.visibility = \'followers\'')
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
// 自分がフォロワーである
|
||||
.where(`note.userId IN (${ followingQuery.getQuery() })`)
|
||||
// または 自分の投稿へのリプライ
|
||||
.orWhere('note.replyUserId = :meId')
|
||||
.orWhere(':meIdAsList <@ note.mentions');
|
||||
}));
|
||||
}));
|
||||
}));
|
||||
// Mentions me
|
||||
.orWhere(':meIdAsList <@ note.mentions')
|
||||
// Followers-only post
|
||||
.orWhere(new Brackets(qb => this
|
||||
.andFollowingUser(qb, ':meId', 'note.userId')
|
||||
.andWhere('note.visibility = \'followers\'')));
|
||||
|
||||
q.setParameters({ meId: me.id, meIdAsList: [me.id] });
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting')
|
||||
.select('renote_muting.muteeId')
|
||||
.where('renote_muting.muterId = :muterId', { muterId: me.id });
|
||||
|
||||
q.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where(new Brackets(qb => {
|
||||
qb.where('note.renoteId IS NOT NULL');
|
||||
qb.andWhere('note.text IS NULL');
|
||||
qb.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
}))
|
||||
.orWhere('note.renoteId IS NULL')
|
||||
.orWhere('note.text IS NOT NULL');
|
||||
}));
|
||||
|
||||
q.setParameters(mutingQuery.getParameters());
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateBlockedHostQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean): void {
|
||||
let nonBlockedHostQuery: (part: string) => string;
|
||||
if (this.meta.blockedHosts.length === 0) {
|
||||
nonBlockedHostQuery = () => '1=1';
|
||||
} else {
|
||||
nonBlockedHostQuery = (match: string) => `('.' || ${match}) NOT ILIKE ALL(select '%.' || x from (select unnest("blockedHosts") as x from "meta") t)`;
|
||||
public generateMutedUserRenotesQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||
return q
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotMutingRenote(qb, ':meId', 'note.userId')
|
||||
.orWhere('note.renoteId IS NULL')
|
||||
.orWhere('note.text IS NOT NULL')
|
||||
.orWhere('note.cw IS NOT NULL')
|
||||
.orWhere('note.replyId IS NOT NULL')
|
||||
.orWhere('note.hasPoll = true')
|
||||
.orWhere('note.fileIds != \'{}\'')))
|
||||
.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateExcludedRenotesQueryForNotes<Q extends WhereExpressionBuilder>(q: Q): Q {
|
||||
return this.andIsNotRenote(q, 'note');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateBlockedHostQueryForNote<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, excludeAuthor?: boolean): SelectQueryBuilder<E> {
|
||||
const checkFor = (key: 'user' | 'replyUser' | 'renoteUser') => this
|
||||
.leftJoinInstance(q, `note.${key}Instance`, `${key}Instance`)
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.orWhere(`"${key}Instance" IS NULL`) // local
|
||||
.orWhere(`"${key}Instance"."isBlocked" = false`); // not blocked
|
||||
|
||||
if (excludeAuthor) {
|
||||
const instanceSuspension = (user: string) => new Brackets(qb => qb
|
||||
.where(`note.${user}Id IS NULL`) // no corresponding user
|
||||
.orWhere(`note.userId = note.${user}Id`)
|
||||
.orWhere(`note.${user}Host IS NULL`) // local
|
||||
.orWhere(nonBlockedHostQuery(`note.${user}Host`)));
|
||||
qb.orWhere(`note.userId = note.${key}Id`); // author
|
||||
}
|
||||
}));
|
||||
|
||||
q
|
||||
.andWhere(instanceSuspension('replyUser'))
|
||||
.andWhere(instanceSuspension('renoteUser'));
|
||||
} else {
|
||||
const instanceSuspension = (user: string) => new Brackets(qb => qb
|
||||
.where(`note.${user}Id IS NULL`) // no corresponding user
|
||||
.orWhere(`note.${user}Host IS NULL`) // local
|
||||
.orWhere(nonBlockedHostQuery(`note.${user}Host`)));
|
||||
if (!excludeAuthor) {
|
||||
checkFor('user');
|
||||
}
|
||||
checkFor('replyUser');
|
||||
checkFor('renoteUser');
|
||||
|
||||
q
|
||||
.andWhere(instanceSuspension('user'))
|
||||
.andWhere(instanceSuspension('replyUser'))
|
||||
.andWhere(instanceSuspension('renoteUser'));
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateSilencedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null): SelectQueryBuilder<E> {
|
||||
if (!me) {
|
||||
return q.andWhere('user.isSilenced = false');
|
||||
}
|
||||
|
||||
return this
|
||||
.leftJoinInstance(q, 'note.userInstance', 'userInstance')
|
||||
.andWhere(new Brackets(qb => this
|
||||
// case 1: we are following the user
|
||||
.orFollowingUser(qb, ':meId', 'note.userId')
|
||||
// case 2: user not silenced AND instance not silenced
|
||||
.orWhere(new Brackets(qbb => qbb
|
||||
.andWhere(new Brackets(qbbb => qbbb
|
||||
.orWhere('"userInstance"."isSilenced" = false')
|
||||
.orWhere('"userInstance" IS NULL')))
|
||||
.andWhere('user.isSilenced = false')))))
|
||||
.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Left-joins an instance in to the query with a given alias and optional condition.
|
||||
* These calls are de-duplicated - multiple uses of the same alias are skipped.
|
||||
*/
|
||||
@bindThis
|
||||
public leftJoinInstance<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, relation: string | typeof MiInstance, alias: string, condition?: string): SelectQueryBuilder<E> {
|
||||
// Skip if it's already joined, otherwise we'll get an error
|
||||
if (!q.expressionMap.joinAttributes.some(j => j.alias.name === alias)) {
|
||||
q.leftJoin(relation, alias, condition);
|
||||
}
|
||||
|
||||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OR condition that noteProp (note ID) refers to a quote.
|
||||
* The prop should be an expression, not a raw value.
|
||||
*/
|
||||
@bindThis
|
||||
public orIsQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||
return this.addIsQuote(q, noteProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that noteProp (note ID) refers to a quote.
|
||||
* The prop should be an expression, not a raw value.
|
||||
*/
|
||||
@bindThis
|
||||
public andIsQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||
return this.addIsQuote(q, noteProp, 'andWhere');
|
||||
}
|
||||
|
||||
private addIsQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
return q[join](new Brackets(qb => qb
|
||||
.andWhere(`${noteProp}.renoteId IS NOT NULL`)
|
||||
.andWhere(new Brackets(qbb => qbb
|
||||
.orWhere(`${noteProp}.text IS NOT NULL`)
|
||||
.orWhere(`${noteProp}.cw IS NOT NULL`)
|
||||
.orWhere(`${noteProp}.replyId IS NOT NULL`)
|
||||
.orWhere(`${noteProp}.hasPoll = true`)
|
||||
.orWhere(`${noteProp}.fileIds != '{}'`)))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OR condition that noteProp (note ID) does not refer to a quote.
|
||||
* The prop should be an expression, not a raw value.
|
||||
*/
|
||||
@bindThis
|
||||
public orIsNotQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||
return this.addIsNotQuote(q, noteProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that noteProp (note ID) does not refer to a quote.
|
||||
* The prop should be an expression, not a raw value.
|
||||
*/
|
||||
@bindThis
|
||||
public andIsNotQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||
return this.addIsNotQuote(q, noteProp, 'andWhere');
|
||||
}
|
||||
|
||||
private addIsNotQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
return q[join](new Brackets(qb => qb
|
||||
.orWhere(`${noteProp}.renoteId IS NULL`)
|
||||
.orWhere(new Brackets(qb => qb
|
||||
.andWhere(`${noteProp}.text IS NULL`)
|
||||
.andWhere(`${noteProp}.cw IS NULL`)
|
||||
.andWhere(`${noteProp}.replyId IS NULL`)
|
||||
.andWhere(`${noteProp}.hasPoll = false`)
|
||||
.andWhere(`${noteProp}.fileIds = '{}'`)))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OR condition that noteProp (note ID) refers to a renote.
|
||||
* The prop should be an expression, not a raw value.
|
||||
*/
|
||||
@bindThis
|
||||
public orIsRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||
return this.addIsRenote(q, noteProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that noteProp (note ID) refers to a renote.
|
||||
* The prop should be an expression, not a raw value.
|
||||
*/
|
||||
@bindThis
|
||||
public andIsRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||
return this.addIsRenote(q, noteProp, 'andWhere');
|
||||
}
|
||||
|
||||
private addIsRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
return q[join](new Brackets(qb => qb
|
||||
.andWhere(`${noteProp}.renoteId IS NOT NULL`)
|
||||
.andWhere(`${noteProp}.text IS NULL`)
|
||||
.andWhere(`${noteProp}.cw IS NULL`)
|
||||
.andWhere(`${noteProp}.replyId IS NULL`)
|
||||
.andWhere(`${noteProp}.hasPoll = false`)
|
||||
.andWhere(`${noteProp}.fileIds = '{}'`)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OR condition that noteProp (note ID) does not refer to a renote.
|
||||
* The prop should be an expression, not a raw value.
|
||||
*/
|
||||
@bindThis
|
||||
public orIsNotRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||
return this.addIsNotRenote(q, noteProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that noteProp (note ID) does not refer to a renote.
|
||||
* The prop should be an expression, not a raw value.
|
||||
*/
|
||||
@bindThis
|
||||
public andIsNotRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||
return this.addIsNotRenote(q, noteProp, 'andWhere');
|
||||
}
|
||||
|
||||
private addIsNotRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
return q[join](new Brackets(qb => qb
|
||||
.orWhere(`${noteProp}.renoteId IS NULL`)
|
||||
.orWhere(`${noteProp}.text IS NOT NULL`)
|
||||
.orWhere(`${noteProp}.cw IS NOT NULL`)
|
||||
.orWhere(`${noteProp}.replyId IS NOT NULL`)
|
||||
.orWhere(`${noteProp}.hasPoll = true`)
|
||||
.orWhere(`${noteProp}.fileIds != '{}'`)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OR condition that followerProp (user ID) is following followeeProp (user ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public orFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
|
||||
return this.addFollowingUser(q, followerProp, followeeProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that followerProp (user ID) is following followeeProp (user ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public andFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
|
||||
return this.addFollowingUser(q, followerProp, followeeProp, 'andWhere');
|
||||
}
|
||||
|
||||
private addFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('1')
|
||||
.andWhere(`following.followerId = ${followerProp}`)
|
||||
.andWhere(`following.followeeId = ${followeeProp}`);
|
||||
|
||||
return q[join](`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters());
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds OR condition that followerProp (user ID) is following followeeProp (channel ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public orFollowingChannel<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
|
||||
return this.addFollowingChannel(q, followerProp, followeeProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that followerProp (user ID) is following followeeProp (channel ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public andFollowingChannel<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
|
||||
return this.addFollowingChannel(q, followerProp, followeeProp, 'andWhere');
|
||||
}
|
||||
|
||||
private addFollowingChannel<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
const followingQuery = this.channelFollowingsRepository.createQueryBuilder('following')
|
||||
.select('1')
|
||||
.andWhere(`following.followerId = ${followerProp}`)
|
||||
.andWhere(`following.followeeId = ${followeeProp}`);
|
||||
|
||||
return q[join](`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OR condition that blockerProp (user ID) is not blocking blockeeProp (user ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public orNotBlockingUser<Q extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string): Q {
|
||||
return this.excludeBlockingUser(q, blockerProp, blockeeProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that blockerProp (user ID) is not blocking blockeeProp (user ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public andNotBlockingUser<Q extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string): Q {
|
||||
return this.excludeBlockingUser(q, blockerProp, blockeeProp, 'andWhere');
|
||||
}
|
||||
|
||||
private excludeBlockingUser<Q extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
||||
.select('1')
|
||||
.andWhere(`blocking.blockerId = ${blockerProp}`)
|
||||
.andWhere(`blocking.blockeeId = ${blockeeProp}`);
|
||||
|
||||
return q[join](`NOT EXISTS (${blockingQuery.getQuery()})`, blockingQuery.getParameters());
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds OR condition that muterProp (user ID) is not muting muteeProp (user ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public orNotMutingUser<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q {
|
||||
return this.excludeMutingUser(q, muterProp, muteeProp, 'orWhere', exclude);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that muterProp (user ID) is not muting muteeProp (user ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public andNotMutingUser<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q {
|
||||
return this.excludeMutingUser(q, muterProp, muteeProp, 'andWhere', exclude);
|
||||
}
|
||||
|
||||
private excludeMutingUser<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere', exclude?: { id: MiUser['id'] }): Q {
|
||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('1')
|
||||
.andWhere(`muting.muterId = ${muterProp}`)
|
||||
.andWhere(`muting.muteeId = ${muteeProp}`);
|
||||
|
||||
if (exclude) {
|
||||
mutingQuery.andWhere({ muteeId: Not(exclude.id) });
|
||||
}
|
||||
|
||||
return q[join](`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OR condition that muterProp (user ID) is not muting renotes by muteeProp (user ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public orNotMutingRenote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
|
||||
return this.excludeMutingRenote(q, muterProp, muteeProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that muterProp (user ID) is not muting renotes by muteeProp (user ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public andNotMutingRenote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
|
||||
return this.excludeMutingRenote(q, muterProp, muteeProp, 'andWhere');
|
||||
}
|
||||
|
||||
private excludeMutingRenote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting')
|
||||
.select('1')
|
||||
.andWhere(`renote_muting.muterId = ${muterProp}`)
|
||||
.andWhere(`renote_muting.muteeId = ${muteeProp}`);
|
||||
|
||||
return q[join](`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters());
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds OR condition that muterProp (user ID) is not muting muteeProp (instance host).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public orNotMutingInstance<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
|
||||
return this.excludeMutingInstance(q, muterProp, muteeProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that muterProp (user ID) is not muting muteeProp (instance host).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public andNotMutingInstance<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
|
||||
return this.excludeMutingInstance(q, muterProp, muteeProp, 'andWhere');
|
||||
}
|
||||
|
||||
private excludeMutingInstance<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
|
||||
.select('1')
|
||||
.andWhere(`user_profile.userId = ${muterProp}`)
|
||||
.andWhere(`"user_profile"."mutedInstances"::jsonb ? ${muteeProp}`);
|
||||
|
||||
return q[join](`NOT EXISTS (${mutingInstanceQuery.getQuery()})`, mutingInstanceQuery.getParameters());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OR condition that muterProp (user ID) is not muting muteeProp (note ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public orNotMutingThread<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
|
||||
return this.excludeMutingThread(q, muterProp, muteeProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that muterProp (user ID) is not muting muteeProp (note ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public andNotMutingThread<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
|
||||
return this.excludeMutingThread(q, muterProp, muteeProp, 'andWhere');
|
||||
}
|
||||
|
||||
private excludeMutingThread<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
const threadMutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
|
||||
.select('1')
|
||||
.andWhere(`threadMuted.userId = ${muterProp}`)
|
||||
.andWhere(`threadMuted.threadId = ${muteeProp}`);
|
||||
|
||||
return q[join](`NOT EXISTS (${threadMutedQuery.getQuery()})`, threadMutedQuery.getParameters());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import { IdentifiableError } from '@/misc/identifiable-error.js';
|
|||
import type { MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { MiNoteReaction } from '@/models/NoteReaction.js';
|
||||
import { MiNoteReaction } from '@/models/NoteReaction.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
|
@ -31,6 +31,7 @@ import { isQuote, isRenote } from '@/misc/is-renote.js';
|
|||
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
|
||||
import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
const FALLBACK = '\u2764';
|
||||
|
||||
|
@ -89,6 +90,9 @@ export class ReactionService {
|
|||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
@Inject(DI.db)
|
||||
private readonly db: DataSource,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private customEmojiService: CustomEmojiService,
|
||||
private roleService: RoleService,
|
||||
|
@ -176,26 +180,28 @@ export class ReactionService {
|
|||
reaction,
|
||||
};
|
||||
|
||||
try {
|
||||
await this.noteReactionsRepository.insert(record);
|
||||
} catch (e) {
|
||||
if (isDuplicateKeyValueError(e)) {
|
||||
const exists = await this.noteReactionsRepository.findOneByOrFail({
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
const result = await this.db.transaction(async tem => {
|
||||
await tem.createQueryBuilder(MiNoteReaction, 'noteReaction')
|
||||
.insert()
|
||||
.values(record)
|
||||
.orIgnore()
|
||||
.execute();
|
||||
|
||||
return await tem.createQueryBuilder(MiNoteReaction, 'noteReaction')
|
||||
.select()
|
||||
.where({ noteId: note.id, userId: user.id })
|
||||
.getOneOrFail();
|
||||
});
|
||||
|
||||
if (exists.reaction !== reaction) {
|
||||
if (result.id !== record.id) {
|
||||
// Conflict with the same ID => nothing to do.
|
||||
if (result.reaction === record.reaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 別のリアクションがすでにされていたら置き換える
|
||||
await this.delete(user, note);
|
||||
await this.noteReactionsRepository.insert(record);
|
||||
} else {
|
||||
// 同じリアクションがすでにされていたらエラー
|
||||
throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298');
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Increment reactions count
|
||||
|
|
|
@ -587,6 +587,8 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
|||
lastActiveDate: parsed.user1.lastActiveDate != null ? new Date(parsed.user1.lastActiveDate) : null,
|
||||
lastFetchedAt: parsed.user1.lastFetchedAt != null ? new Date(parsed.user1.lastFetchedAt) : null,
|
||||
movedAt: parsed.user1.movedAt != null ? new Date(parsed.user1.movedAt) : null,
|
||||
instance: null,
|
||||
userProfile: null,
|
||||
} : null,
|
||||
user2: parsed.user2 != null ? {
|
||||
...parsed.user2,
|
||||
|
@ -597,6 +599,8 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
|||
lastActiveDate: parsed.user2.lastActiveDate != null ? new Date(parsed.user2.lastActiveDate) : null,
|
||||
lastFetchedAt: parsed.user2.lastFetchedAt != null ? new Date(parsed.user2.lastFetchedAt) : null,
|
||||
movedAt: parsed.user2.movedAt != null ? new Date(parsed.user2.movedAt) : null,
|
||||
instance: null,
|
||||
userProfile: null,
|
||||
} : null,
|
||||
};
|
||||
} else {
|
||||
|
|
|
@ -86,10 +86,10 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||
canManageCustomEmojis: false,
|
||||
canManageAvatarDecorations: false,
|
||||
canSearchNotes: false,
|
||||
canUseTranslator: true,
|
||||
canUseTranslator: false,
|
||||
canHideAds: false,
|
||||
driveCapacityMb: 100,
|
||||
maxFileSizeMb: 10,
|
||||
maxFileSizeMb: 25,
|
||||
alwaysMarkNsfw: false,
|
||||
canUpdateBioMedia: true,
|
||||
pinLimit: 5,
|
||||
|
|
|
@ -49,22 +49,49 @@ export class UtilityService {
|
|||
return regexp.test(email);
|
||||
}
|
||||
|
||||
public isBlockedHost(host: string | null): boolean;
|
||||
public isBlockedHost(blockedHosts: string[], host: string | null): boolean;
|
||||
@bindThis
|
||||
public isBlockedHost(blockedHosts: string[], host: string | null): boolean {
|
||||
public isBlockedHost(blockedHostsOrHost: string[] | string | null, host?: string | null): boolean {
|
||||
const blockedHosts = Array.isArray(blockedHostsOrHost) ? blockedHostsOrHost : this.meta.blockedHosts;
|
||||
host = Array.isArray(blockedHostsOrHost) ? host : blockedHostsOrHost;
|
||||
|
||||
if (host == null) return false;
|
||||
return blockedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||
}
|
||||
|
||||
public isSilencedHost(host: string | null): boolean;
|
||||
public isSilencedHost(silencedHosts: string[], host: string | null): boolean;
|
||||
@bindThis
|
||||
public isSilencedHost(silencedHosts: string[] | undefined, host: string | null): boolean {
|
||||
if (!silencedHosts || host == null) return false;
|
||||
public isSilencedHost(silencedHostsOrHost: string[] | string | null, host?: string | null): boolean {
|
||||
const silencedHosts = Array.isArray(silencedHostsOrHost) ? silencedHostsOrHost : this.meta.silencedHosts;
|
||||
host = Array.isArray(silencedHostsOrHost) ? host : silencedHostsOrHost;
|
||||
|
||||
if (host == null) return false;
|
||||
return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||
}
|
||||
|
||||
public isMediaSilencedHost(host: string | null): boolean;
|
||||
public isMediaSilencedHost(silencedHosts: string[], host: string | null): boolean;
|
||||
@bindThis
|
||||
public isMediaSilencedHost(silencedHosts: string[] | undefined, host: string | null): boolean {
|
||||
if (!silencedHosts || host == null) return false;
|
||||
return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||
public isMediaSilencedHost(mediaSilencedHostsOrHost: string[] | string | null, host?: string | null): boolean {
|
||||
const mediaSilencedHosts = Array.isArray(mediaSilencedHostsOrHost) ? mediaSilencedHostsOrHost : this.meta.mediaSilencedHosts;
|
||||
host = Array.isArray(mediaSilencedHostsOrHost) ? host : mediaSilencedHostsOrHost;
|
||||
|
||||
if (host == null) return false;
|
||||
return mediaSilencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isAllowListedHost(host: string | null): boolean {
|
||||
if (host == null) return false;
|
||||
return this.meta.federationHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isBubbledHost(host: string | null): boolean {
|
||||
if (host == null) return false;
|
||||
return this.meta.bubbleInstances.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
@ -3,24 +3,41 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import FFmpeg from 'fluent-ffmpeg';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { ImageProcessingService } from '@/core/ImageProcessingService.js';
|
||||
import type { IImage } from '@/core/ImageProcessingService.js';
|
||||
import { createTempDir } from '@/misc/create-temp.js';
|
||||
import { createTemp, createTempDir } from '@/misc/create-temp.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { appendQuery, query } from '@/misc/prelude/url.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
|
||||
// faststart is only supported for MP4, M4A, M4W and MOV files (the MOV family).
|
||||
// WebM (and Matroska) files always support faststart-like behavior.
|
||||
const supportedMimeTypes = new Map([
|
||||
['video/mp4', 'mp4'],
|
||||
['video/m4a', 'mp4'],
|
||||
['video/m4v', 'mp4'],
|
||||
['video/quicktime', 'mov'],
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class VideoProcessingService {
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private imageProcessingService: ImageProcessingService,
|
||||
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('video-processing');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -60,5 +77,50 @@ export class VideoProcessingService {
|
|||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize video for web playback by adding faststart flag.
|
||||
* This allows the video to start playing before it is fully downloaded.
|
||||
* The original file is modified in-place.
|
||||
* @param source Path to the video file
|
||||
* @param mimeType The MIME type of the video
|
||||
* @returns Promise that resolves when optimization is complete
|
||||
*/
|
||||
@bindThis
|
||||
public async webOptimizeVideo(source: string, mimeType: string): Promise<void> {
|
||||
const outputFormat = supportedMimeTypes.get(mimeType);
|
||||
if (!outputFormat) {
|
||||
this.logger.debug(`Skipping web optimization for unsupported MIME type: ${mimeType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const [tempPath, cleanup] = await createTemp();
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
FFmpeg(source)
|
||||
.format(outputFormat) // Specify output format
|
||||
.addOutputOptions('-c copy') // Copy streams without re-encoding
|
||||
.addOutputOptions('-movflags +faststart')
|
||||
.on('error', reject)
|
||||
.on('end', async () => {
|
||||
try {
|
||||
// Replace original file with optimized version
|
||||
await fs.copyFile(tempPath, source);
|
||||
this.logger.info(`Web-optimized video: ${source}`);
|
||||
resolve();
|
||||
} catch (copyError) {
|
||||
reject(copyError);
|
||||
}
|
||||
})
|
||||
.save(tempPath);
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to web-optimize video: ${source}`, { error });
|
||||
throw error;
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -63,6 +63,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
|
|||
emojis: [],
|
||||
score: 0,
|
||||
host: null,
|
||||
instance: null,
|
||||
inbox: null,
|
||||
sharedInbox: null,
|
||||
featured: null,
|
||||
|
@ -76,6 +77,8 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
|
|||
mandatoryCW: null,
|
||||
rejectQuotes: false,
|
||||
allowUnsignedFetch: 'staff',
|
||||
userProfile: null,
|
||||
attributionDomains: [],
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
@ -114,10 +117,13 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote {
|
|||
channelId: null,
|
||||
channel: null,
|
||||
userHost: null,
|
||||
userInstance: null,
|
||||
replyUserId: null,
|
||||
replyUserHost: null,
|
||||
replyUserInstance: null,
|
||||
renoteUserId: null,
|
||||
renoteUserHost: null,
|
||||
renoteUserInstance: null,
|
||||
updatedAt: null,
|
||||
processErrors: [],
|
||||
...override,
|
||||
|
@ -358,8 +364,10 @@ export class WebhookTestService {
|
|||
id: 'dummy-abuse-report1',
|
||||
targetUserId: 'dummy-target-user',
|
||||
targetUser: null,
|
||||
targetUserInstance: null,
|
||||
reporterId: 'dummy-reporter-user',
|
||||
reporter: null,
|
||||
reporterInstance: null,
|
||||
assigneeId: null,
|
||||
assignee: null,
|
||||
resolved: false,
|
||||
|
@ -449,6 +457,7 @@ export class WebhookTestService {
|
|||
isAdmin: false,
|
||||
isModerator: false,
|
||||
isSystem: false,
|
||||
instance: undefined,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ import InstanceChart from '@/core/chart/charts/instance.js';
|
|||
import FederationChart from '@/core/chart/charts/federation.js';
|
||||
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
|
||||
import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js';
|
||||
import { getApHrefNullable, getApId, getApIds, getApType, getNullableApId, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isDislike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost, isActivity, IObjectWithId } from './type.js';
|
||||
import { getApHrefNullable, getApId, getApIds, getApType, getNullableApId, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isDislike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost, isActivity, IObjectWithId } from './type.js';
|
||||
import { ApNoteService } from './models/ApNoteService.js';
|
||||
import { ApLoggerService } from './ApLoggerService.js';
|
||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||
|
@ -106,22 +106,25 @@ export class ApInboxService {
|
|||
let result = undefined as string | void;
|
||||
if (isCollectionOrOrderedCollection(activity)) {
|
||||
const results = [] as [string, string | void][];
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems);
|
||||
if (items.length >= resolver.getRecursionLimit()) {
|
||||
throw new Error(`skipping activity: collection would surpass recursion limit: ${this.utilityService.extractDbHost(actor.uri)}`);
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const act = await resolver.resolve(item);
|
||||
if (act.id == null || this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) {
|
||||
this.logger.debug('skipping activity: activity id is null or mismatching');
|
||||
const items = await resolver.resolveCollectionItems(activity);
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const act = items[i];
|
||||
if (act.id != null) {
|
||||
if (this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) {
|
||||
this.logger.warn('skipping activity: activity id mismatch');
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Activity ID should only be string or undefined.
|
||||
act.id = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
results.push([getApId(item), await this.performOneActivity(actor, act, resolver)]);
|
||||
const id = getNullableApId(act) ?? `${getNullableApId(activity)}#${i}`;
|
||||
const result = await this.performOneActivity(actor, act, resolver);
|
||||
results.push([id, result]);
|
||||
} catch (err) {
|
||||
if (err instanceof Error || typeof err === 'string') {
|
||||
this.logger.error(err);
|
||||
|
@ -217,6 +220,10 @@ export class ApInboxService {
|
|||
const note = await this.apNoteService.resolveNote(object, { resolver });
|
||||
if (!note) return `skip: target note not found ${targetUri}`;
|
||||
|
||||
if (note.userHost == null && note.localOnly) {
|
||||
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot react to local-only note');
|
||||
}
|
||||
|
||||
await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null);
|
||||
|
||||
try {
|
||||
|
@ -371,6 +378,10 @@ export class ApInboxService {
|
|||
return 'skip: invalid actor for this activity';
|
||||
}
|
||||
|
||||
if (renote.userHost == null && renote.localOnly) {
|
||||
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot renote a local-only note');
|
||||
}
|
||||
|
||||
this.logger.info(`Creating the (Re)Note: ${uri}`);
|
||||
|
||||
const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc, resolver);
|
||||
|
|
|
@ -613,6 +613,7 @@ export class ApRendererService {
|
|||
enableRss: user.enableRss,
|
||||
speakAsCat: user.speakAsCat,
|
||||
attachment: attachment.length ? attachment : undefined,
|
||||
attributionDomains: user.attributionDomains,
|
||||
};
|
||||
|
||||
if (user.movedToUri) {
|
||||
|
|
|
@ -184,10 +184,11 @@ export class ApRequestService {
|
|||
* Get AP object with http-signature
|
||||
* @param user http-signature user
|
||||
* @param url URL to fetch
|
||||
* @param followAlternate
|
||||
* @param allowAnonymous If a fetched object lacks an ID, then it will be auto-generated from the final URL. (default: false)
|
||||
* @param followAlternate Whether to resolve HTML responses to their referenced canonical AP endpoint. (default: true)
|
||||
*/
|
||||
@bindThis
|
||||
public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<IObjectWithId> {
|
||||
public async signedGet(url: string, user: { id: MiUser['id'] }, allowAnonymous = false, followAlternate?: boolean): Promise<IObjectWithId> {
|
||||
this.apUtilityService.assertApUrl(url);
|
||||
|
||||
const _followAlternate = followAlternate ?? true;
|
||||
|
@ -258,7 +259,7 @@ export class ApRequestService {
|
|||
if (alternate) {
|
||||
const href = alternate.getAttribute('href');
|
||||
if (href && this.apUtilityService.haveSameAuthority(url, href)) {
|
||||
return await this.signedGet(href, user, false);
|
||||
return await this.signedGet(href, user, allowAnonymous, false);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
@ -275,7 +276,11 @@ export class ApRequestService {
|
|||
|
||||
// Make sure the object ID matches the final URL (which is where it actually exists).
|
||||
// The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match.
|
||||
if (allowAnonymous && activity.id == null) {
|
||||
activity.id = res.url;
|
||||
} else {
|
||||
this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url);
|
||||
}
|
||||
|
||||
return activity as IObjectWithId;
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import promiseLimit from 'promise-limit';
|
||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta, SkApFetchLog } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
@ -19,11 +20,12 @@ import { ApLogService, calculateDurationSince, extractObjectContext } from '@/co
|
|||
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { getApId, getNullableApId, IObjectWithId, isCollectionOrOrderedCollection } from './type.js';
|
||||
import { toArray } from '@/misc/prelude/array.js';
|
||||
import { AnyCollection, getApId, getNullableApId, IObjectWithId, isCollection, isCollectionOrOrderedCollection, isCollectionPage, isOrderedCollection, isOrderedCollectionPage } from './type.js';
|
||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||
import { ApRendererService } from './ApRendererService.js';
|
||||
import { ApRequestService } from './ApRequestService.js';
|
||||
import type { IObject, ICollection, IOrderedCollection, ApObject } from './type.js';
|
||||
import type { IObject, ApObject, IAnonymousObject } from './type.js';
|
||||
|
||||
export class Resolver {
|
||||
private history: Set<string>;
|
||||
|
@ -63,11 +65,16 @@ export class Resolver {
|
|||
return this.recursionLimit;
|
||||
}
|
||||
|
||||
public async resolveCollection(value: string | IObjectWithId, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection & IObjectWithId>;
|
||||
public async resolveCollection(value: string | IObject, allowAnonymous: boolean | undefined, sentFromUri: string): Promise<AnyCollection & IObjectWithId>;
|
||||
public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection>;
|
||||
@bindThis
|
||||
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
|
||||
public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection> {
|
||||
const collection = typeof value === 'string'
|
||||
? await this.resolve(value)
|
||||
: value;
|
||||
? sentFromUri
|
||||
? await this.secureResolve(value, sentFromUri, allowAnonymous)
|
||||
: await this.resolve(value, allowAnonymous)
|
||||
: value; // TODO try and remove this eventually, as it's a major security foot-gun
|
||||
|
||||
if (isCollectionOrOrderedCollection(collection)) {
|
||||
return collection;
|
||||
|
@ -76,20 +83,110 @@ export class Resolver {
|
|||
}
|
||||
}
|
||||
|
||||
public async resolveCollectionItems(collection: IAnonymousObject, limit?: number | null, allowAnonymousItems?: true, concurrency?: number): Promise<IAnonymousObject[]>;
|
||||
public async resolveCollectionItems(collection: string | IObjectWithId, limit?: number | null, allowAnonymousItems?: boolean, concurrency?: number): Promise<IObjectWithId[]>;
|
||||
public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean, concurrency?: number): Promise<IObject[]>;
|
||||
/**
|
||||
* Recursively resolves items from a collection.
|
||||
* Stops when reaching the resolution limit or an optional item limit - whichever is lower.
|
||||
* This method supports Collection, OrderedCollection, and individual pages of either type.
|
||||
* Malformed collections (mixing Ordered and un-Ordered types) are also supported.
|
||||
* @param collection Collection to resolve from - can be a URL or object of any supported collection type.
|
||||
* @param limit Maximum number of items to resolve. If null or undefined (default), then items will be resolved until reaching the recursion limit.
|
||||
* @param allowAnonymousItems If true, collection items can be anonymous (lack an ID). If false (default), then an error is thrown when reaching an item without ID.
|
||||
* @param concurrency Maximum number of items to resolve at once. (default: 4)
|
||||
*/
|
||||
@bindThis
|
||||
public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean, concurrency = 4): Promise<IObject[]> {
|
||||
const resolvedItems: IObject[] = [];
|
||||
|
||||
// This is pulled up to avoid code duplication below
|
||||
const iterate = async(items: ApObject, current: AnyCollection) => {
|
||||
const sentFrom = current.id;
|
||||
const itemArr = toArray(items);
|
||||
const itemLimit = limit ?? Number.MAX_SAFE_INTEGER;
|
||||
const allowAnonymous = allowAnonymousItems ?? false;
|
||||
await this.resolveItemArray(itemArr, sentFrom, itemLimit, concurrency, allowAnonymous, resolvedItems);
|
||||
};
|
||||
|
||||
let current: AnyCollection | null = await this.resolveCollection(collection);
|
||||
do {
|
||||
// Iterate all items in the current page
|
||||
if (current.items) {
|
||||
await iterate(current.items, current);
|
||||
}
|
||||
if (current.orderedItems) {
|
||||
await iterate(current.orderedItems, current);
|
||||
}
|
||||
|
||||
if (this.history.size >= this.recursionLimit) {
|
||||
// Stop when we reach the fetch limit
|
||||
current = null;
|
||||
} else if (limit != null && resolvedItems.length >= limit) {
|
||||
// Stop when we reach the item limit
|
||||
current = null;
|
||||
} else if (isCollection(current) || isOrderedCollection(current)) {
|
||||
// Continue to first page
|
||||
current = current.first ? await this.resolveCollection(current.first, true, current.id) : null;
|
||||
} else if (isCollectionPage(current) || isOrderedCollectionPage(current)) {
|
||||
// Continue to next page
|
||||
current = current.next ? await this.resolveCollection(current.next, true, current.id) : null;
|
||||
} else {
|
||||
// Stop in all other conditions
|
||||
current = null;
|
||||
}
|
||||
} while (current != null);
|
||||
|
||||
return resolvedItems;
|
||||
}
|
||||
|
||||
private async resolveItemArray(source: (string | IObject)[], sentFrom: undefined, itemLimit: number, concurrency: number, allowAnonymousItems: true, destination: IAnonymousObject[]): Promise<void>;
|
||||
private async resolveItemArray(source: (string | IObject)[], sentFrom: string, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObjectWithId[]): Promise<void>;
|
||||
private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[]): Promise<void>;
|
||||
private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[]): Promise<void> {
|
||||
const recursionLimit = this.recursionLimit - this.history.size;
|
||||
const batchLimit = Math.min(source.length, recursionLimit, itemLimit);
|
||||
|
||||
const limiter = promiseLimit<IObject>(concurrency);
|
||||
const batch = await Promise.all(source
|
||||
.slice(0, batchLimit)
|
||||
.map(item => limiter(async () => {
|
||||
if (sentFrom) {
|
||||
// Use secureResolve to avoid re-fetching items that were included inline.
|
||||
return await this.secureResolve(item, sentFrom, allowAnonymousItems);
|
||||
} else if (allowAnonymousItems) {
|
||||
return await this.resolveAnonymous(item);
|
||||
} else {
|
||||
// ID is required if we have neither sentFrom not allowAnonymousItems
|
||||
const id = getApId(item);
|
||||
return await this.resolve(id);
|
||||
}
|
||||
})));
|
||||
|
||||
destination.push(...batch);
|
||||
};
|
||||
|
||||
/**
|
||||
* Securely resolves an AP object or URL that has been sent from another instance.
|
||||
* An input object is trusted if and only if its ID matches the authority of sentFromUri.
|
||||
* In all other cases, the object is re-fetched from remote by input string or object ID.
|
||||
* @param input The input object or URL to resolve
|
||||
* @param sentFromUri The URL where this object originated. This MUST be accurate - all security checks depend on this value!
|
||||
* @param allowAnonymous If true, anonymous objects are allowed and will have their ID set to sentFromUri. If false (default) then anonymous objects will be rejected with an error.
|
||||
*/
|
||||
@bindThis
|
||||
public async secureResolve(input: ApObject, sentFromUri: string): Promise<IObjectWithId> {
|
||||
public async secureResolve(input: string | IObject | [string | IObject], sentFromUri: string, allowAnonymous?: boolean): Promise<IObjectWithId> {
|
||||
// Unpack arrays to get the value element.
|
||||
const value = fromTuple(input);
|
||||
if (value == null) {
|
||||
throw new IdentifiableError('20058164-9de1-4573-8715-425753a21c1d', 'Cannot resolve null input');
|
||||
|
||||
// If anonymous input is allowed, then any object is automatically valid if we set the ID.
|
||||
// We can short-circuit here and avoid un-necessary checks.
|
||||
if (allowAnonymous && typeof(value) === 'object' && value.id == null) {
|
||||
value.id = sentFromUri;
|
||||
return value as IObjectWithId;
|
||||
}
|
||||
|
||||
// This will throw if the input has no ID, which is good because we can't verify an anonymous object anyway.
|
||||
// This ensures the input has a string ID, protecting against type confusion and rejecting anonymous objects.
|
||||
const id = getApId(value);
|
||||
|
||||
// Check if we can use the provided object as-is.
|
||||
|
@ -100,28 +197,52 @@ export class Resolver {
|
|||
}
|
||||
|
||||
// If the checks didn't pass, then we must fetch the object and use that.
|
||||
return await this.resolve(id);
|
||||
return await this.resolve(id, allowAnonymous);
|
||||
}
|
||||
|
||||
public async resolve(value: string | [string]): Promise<IObjectWithId>;
|
||||
public async resolve(value: string | IObject | [string | IObject]): Promise<IObject>;
|
||||
/**
|
||||
* Resolves an anonymous object.
|
||||
* The returned value will not have any ID present.
|
||||
* If one is provided in the response, it will be removed automatically.
|
||||
*/
|
||||
@bindThis
|
||||
public async resolve(value: string | IObject | [string | IObject]): Promise<IObject> {
|
||||
public async resolveAnonymous(value: string | IObject | [string | IObject]): Promise<IAnonymousObject> {
|
||||
value = fromTuple(value);
|
||||
|
||||
const object = await this.resolve(value);
|
||||
object.id = undefined;
|
||||
|
||||
return object as IAnonymousObject;
|
||||
}
|
||||
|
||||
public async resolve(value: string | [string], allowAnonymous?: boolean): Promise<IObjectWithId>;
|
||||
public async resolve(value: string | IObjectWithId | [string | IObjectWithId], allowAnonymous?: boolean): Promise<IObjectWithId>;
|
||||
public async resolve(value: string | IObject | [string | IObject], allowAnonymous?: boolean): Promise<IObject>;
|
||||
/**
|
||||
* Resolves a URL or object to an AP object.
|
||||
* Tuples are expanded to their first element before anything else, and non-string inputs are returned as-is.
|
||||
* Otherwise, the string URL is fetched and validated to represent a valid ActivityPub object.
|
||||
* @param value The input value to resolve
|
||||
* @param allowAnonymous Determines what to do if a response object lacks an ID field. If false (default), then an exception is thrown. If true, then the ID is populated from the final response URL.
|
||||
*/
|
||||
@bindThis
|
||||
public async resolve(value: string | IObject | [string | IObject], allowAnonymous = false): Promise<IObject> {
|
||||
value = fromTuple(value);
|
||||
|
||||
// TODO try and remove this eventually, as it's a major security foot-gun
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const host = this.utilityService.extractDbHost(value);
|
||||
if (this.config.activityLogging.enabled && !this.utilityService.isSelfHost(host)) {
|
||||
return await this._resolveLogged(value, host);
|
||||
return await this._resolveLogged(value, host, allowAnonymous);
|
||||
} else {
|
||||
return await this._resolve(value, host);
|
||||
return await this._resolve(value, host, allowAnonymous);
|
||||
}
|
||||
}
|
||||
|
||||
private async _resolveLogged(requestUri: string, host: string): Promise<IObjectWithId> {
|
||||
private async _resolveLogged(requestUri: string, host: string, allowAnonymous: boolean): Promise<IObjectWithId> {
|
||||
const startTime = process.hrtime.bigint();
|
||||
|
||||
const log = await this.apLogService.createFetchLog({
|
||||
|
@ -130,7 +251,7 @@ export class Resolver {
|
|||
});
|
||||
|
||||
try {
|
||||
const result = await this._resolve(requestUri, host, log);
|
||||
const result = await this._resolve(requestUri, host, allowAnonymous, log);
|
||||
|
||||
log.accepted = true;
|
||||
log.result = 'ok';
|
||||
|
@ -150,7 +271,7 @@ export class Resolver {
|
|||
}
|
||||
}
|
||||
|
||||
private async _resolve(value: string, host: string, log?: SkApFetchLog): Promise<IObjectWithId> {
|
||||
private async _resolve(value: string, host: string, allowAnonymous: boolean, log?: SkApFetchLog): Promise<IObjectWithId> {
|
||||
if (value.includes('#')) {
|
||||
// URLs with fragment parts cannot be resolved correctly because
|
||||
// the fragment part does not get transmitted over HTTP(S).
|
||||
|
@ -181,8 +302,8 @@ export class Resolver {
|
|||
}
|
||||
|
||||
const object = (this.user
|
||||
? await this.apRequestService.signedGet(value, this.user)
|
||||
: await this.httpRequestService.getActivityJson(value));
|
||||
? await this.apRequestService.signedGet(value, this.user, allowAnonymous)
|
||||
: await this.httpRequestService.getActivityJson(value, false, allowAnonymous));
|
||||
|
||||
if (log) {
|
||||
const { object: objectOnly, context, contextHash } = extractObjectContext(object);
|
||||
|
|
|
@ -80,7 +80,6 @@ export class ApUtilityService {
|
|||
/**
|
||||
* Verifies that a provided URL is in a format acceptable for federation.
|
||||
* @throws {IdentifiableError} If URL cannot be parsed
|
||||
* @throws {IdentifiableError} If URL contains a fragment
|
||||
* @throws {IdentifiableError} If URL is not HTTPS
|
||||
*/
|
||||
public assertApUrl(url: string | URL): void {
|
||||
|
@ -93,11 +92,6 @@ export class ApUtilityService {
|
|||
}
|
||||
}
|
||||
|
||||
// Hash component breaks federation
|
||||
if (url.hash) {
|
||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: contains a fragment (#)`);
|
||||
}
|
||||
|
||||
// Must be HTTPS
|
||||
if (!this.checkHttps(url)) {
|
||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: unsupported protocol ${url.protocol}`);
|
||||
|
|
|
@ -546,6 +546,10 @@ const extension_context_definition = {
|
|||
featured: 'toot:featured',
|
||||
discoverable: 'toot:discoverable',
|
||||
indexable: 'toot:indexable',
|
||||
attributionDomains: {
|
||||
'@id': 'toot:attributionDomains',
|
||||
'@type': '@id',
|
||||
},
|
||||
// schema
|
||||
schema: 'http://schema.org#',
|
||||
PropertyValue: 'schema:PropertyValue',
|
||||
|
|
|
@ -285,6 +285,13 @@ export class ApNoteService {
|
|||
const quote = await this.getQuote(note, entryUri, resolver);
|
||||
const processErrors = quote === null ? ['quoteUnavailable'] : null;
|
||||
|
||||
if (reply && reply.userHost == null && reply.localOnly) {
|
||||
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot reply to local-only note');
|
||||
}
|
||||
if (quote && quote.userHost == null && quote.localOnly) {
|
||||
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot quote a local-only note');
|
||||
}
|
||||
|
||||
// vote
|
||||
if (reply && reply.hasPoll) {
|
||||
const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id });
|
||||
|
@ -482,6 +489,10 @@ export class ApNoteService {
|
|||
const quote = await this.getQuote(note, entryUri, resolver);
|
||||
const processErrors = quote === null ? ['quoteUnavailable'] : null;
|
||||
|
||||
if (quote && quote.userHost == null && quote.localOnly) {
|
||||
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot quote a local-only note');
|
||||
}
|
||||
|
||||
// vote
|
||||
if (reply && reply.hasPoll) {
|
||||
const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id });
|
||||
|
|
|
@ -356,8 +356,8 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
|
||||
const [followingVisibility, followersVisibility] = await Promise.all(
|
||||
[
|
||||
this.isPublicCollection(person.following, resolver),
|
||||
this.isPublicCollection(person.followers, resolver),
|
||||
this.isPublicCollection(person.following, resolver, uri),
|
||||
this.isPublicCollection(person.followers, resolver, uri),
|
||||
].map((p): Promise<'public' | 'private'> => p
|
||||
.then(isPublic => isPublic ? 'public' : 'private')
|
||||
.catch(err => {
|
||||
|
@ -393,10 +393,18 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
//#endregion
|
||||
|
||||
//#region resolve counts
|
||||
const _resolver = resolver ?? this.apResolverService.createResolver();
|
||||
const outboxcollection = await _resolver.resolveCollection(person.outbox).catch(() => { return null; });
|
||||
const followerscollection = await _resolver.resolveCollection(person.followers!).catch(() => { return null; });
|
||||
const followingcollection = await _resolver.resolveCollection(person.following!).catch(() => { return null; });
|
||||
const outboxCollection = person.outbox
|
||||
? await resolver.resolveCollection(person.outbox, true, uri).catch(() => { return null; })
|
||||
: null;
|
||||
const followersCollection = person.followers
|
||||
? await resolver.resolveCollection(person.followers, true, uri).catch(() => { return null; })
|
||||
: null;
|
||||
const followingCollection = person.following
|
||||
? await resolver.resolveCollection(person.following, true, uri).catch(() => { return null; })
|
||||
: null;
|
||||
|
||||
// Register the instance first, to avoid FK errors
|
||||
await this.federatedInstanceService.fetchOrRegister(host);
|
||||
|
||||
try {
|
||||
// Start transaction
|
||||
|
@ -423,9 +431,9 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
host,
|
||||
inbox: person.inbox,
|
||||
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
|
||||
notesCount: outboxcollection?.totalItems ?? 0,
|
||||
followersCount: followerscollection?.totalItems ?? 0,
|
||||
followingCount: followingcollection?.totalItems ?? 0,
|
||||
notesCount: outboxCollection?.totalItems ?? 0,
|
||||
followersCount: followersCollection?.totalItems ?? 0,
|
||||
followingCount: followingCollection?.totalItems ?? 0,
|
||||
followersUri: person.followers ? getApId(person.followers) : undefined,
|
||||
featured: person.featured ? getApId(person.featured) : undefined,
|
||||
uri: person.id,
|
||||
|
@ -437,6 +445,11 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null,
|
||||
makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null,
|
||||
emojis,
|
||||
attributionDomains: Array.isArray(person.attributionDomains)
|
||||
? person.attributionDomains
|
||||
.filter((a: unknown) => typeof(a) === 'string' && a.length > 0 && a.length <= 128)
|
||||
.slice(0, 32)
|
||||
: [],
|
||||
})) as MiRemoteUser;
|
||||
|
||||
let _description: string | null = null;
|
||||
|
@ -574,8 +587,8 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
|
||||
const [followingVisibility, followersVisibility] = await Promise.all(
|
||||
[
|
||||
this.isPublicCollection(person.following, resolver),
|
||||
this.isPublicCollection(person.followers, resolver),
|
||||
this.isPublicCollection(person.following, resolver, exist.uri),
|
||||
this.isPublicCollection(person.followers, resolver, exist.uri),
|
||||
].map((p): Promise<'public' | 'private' | undefined> => p
|
||||
.then(isPublic => isPublic ? 'public' : 'private')
|
||||
.catch(err => {
|
||||
|
@ -620,6 +633,11 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
// We use "!== false" to handle incorrect types, missing / null values, and "default to true" logic.
|
||||
hideOnlineStatus: person.hideOnlineStatus !== false,
|
||||
isExplorable: person.discoverable !== false,
|
||||
attributionDomains: Array.isArray(person.attributionDomains)
|
||||
? person.attributionDomains
|
||||
.filter((a: unknown) => typeof(a) === 'string' && a.length > 0 && a.length <= 128)
|
||||
.slice(0, 32)
|
||||
: [],
|
||||
...(await this.resolveAvatarAndBanner(exist, person.icon, person.image, person.backgroundUrl).catch(() => ({}))),
|
||||
} as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'speakAsCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
|
||||
|
||||
|
@ -799,13 +817,13 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
const _resolver = resolver ?? this.apResolverService.createResolver();
|
||||
|
||||
// Resolve to (Ordered)Collection Object
|
||||
const collection = await _resolver.resolveCollection(user.featured).catch(err => {
|
||||
const collection = user.featured ? await _resolver.resolveCollection(user.featured, true, user.uri).catch(err => {
|
||||
if (err instanceof AbortError || err instanceof StatusError) {
|
||||
this.logger.warn(`Failed to update featured notes: ${err.name}: ${err.message}`);
|
||||
} else {
|
||||
this.logger.error('Failed to update featured notes:', err);
|
||||
}
|
||||
});
|
||||
}) : null;
|
||||
if (!collection) return;
|
||||
|
||||
if (!isCollectionOrOrderedCollection(collection)) throw new UnrecoverableError(`featured ${user.featured} is not Collection or OrderedCollection in ${user.uri}`);
|
||||
|
@ -891,13 +909,15 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async isPublicCollection(collection: string | ICollection | IOrderedCollection | undefined, resolver: Resolver): Promise<boolean> {
|
||||
private async isPublicCollection(collection: string | ICollection | IOrderedCollection | undefined, resolver: Resolver, sentFrom: string): Promise<boolean> {
|
||||
if (collection) {
|
||||
const resolved = await resolver.resolveCollection(collection);
|
||||
const resolved = await resolver.resolveCollection(collection, true, sentFrom).catch(() => null);
|
||||
if (resolved) {
|
||||
if (resolved.first || (resolved as ICollection).items || (resolved as IOrderedCollection).orderedItems) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -43,6 +43,18 @@ export interface IObjectWithId extends IObject {
|
|||
id: string;
|
||||
}
|
||||
|
||||
export function isObjectWithId(object: IObject): object is IObjectWithId {
|
||||
return typeof(object.id) === 'string';
|
||||
}
|
||||
|
||||
export interface IAnonymousObject extends IObject {
|
||||
id: undefined;
|
||||
}
|
||||
|
||||
export function isAnonymousObject(object: IObject): object is IAnonymousObject {
|
||||
return object.id === undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get array of ActivityStreams Objects id
|
||||
*/
|
||||
|
@ -63,24 +75,31 @@ export function getOneApId(value: ApObject): string {
|
|||
/**
|
||||
* Get ActivityStreams Object id
|
||||
*/
|
||||
export function getApId(value: string | IObject | [string | IObject]): string {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
value = fromTuple(value);
|
||||
export function getApId(source: string | IObject | [string | IObject]): string {
|
||||
const value = getNullableApId(source);
|
||||
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value.id === 'string') return value.id;
|
||||
throw new IdentifiableError('ad2dc287-75c1-44c4-839d-3d2e64576675', `invalid AP object ${value}: missing id`);
|
||||
if (value == null) {
|
||||
throw new IdentifiableError('ad2dc287-75c1-44c4-839d-3d2e64576675', `invalid AP object ${value}: missing or invalid id`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ActivityStreams Object id, or null if not present
|
||||
*/
|
||||
export function getNullableApId(value: string | IObject | [string | IObject]): string | null {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
value = fromTuple(value);
|
||||
export function getNullableApId(source: string | IObject | [string | IObject]): string | null {
|
||||
const value: unknown = fromTuple(source);
|
||||
|
||||
if (value != null) {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof (value) === 'object' && 'id' in value && typeof (value.id) === 'string') {
|
||||
return value.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value.id === 'string') return value.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -125,48 +144,46 @@ export interface IActivity extends IObject {
|
|||
};
|
||||
}
|
||||
|
||||
export interface ICollection extends IObject {
|
||||
export interface CollectionBase extends IObject {
|
||||
totalItems?: number;
|
||||
first?: IObject | string;
|
||||
last?: IObject | string;
|
||||
current?: IObject | string;
|
||||
partOf?: IObject | string;
|
||||
next?: IObject | string;
|
||||
prev?: IObject | string;
|
||||
items?: ApObject;
|
||||
orderedItems?: ApObject;
|
||||
}
|
||||
|
||||
export interface ICollection extends CollectionBase {
|
||||
type: 'Collection';
|
||||
totalItems: number;
|
||||
first?: IObject | string;
|
||||
last?: IObject | string;
|
||||
current?: IObject | string;
|
||||
items?: ApObject;
|
||||
orderedItems?: undefined;
|
||||
}
|
||||
|
||||
export interface IOrderedCollection extends IObject {
|
||||
export interface IOrderedCollection extends CollectionBase {
|
||||
type: 'OrderedCollection';
|
||||
totalItems: number;
|
||||
first?: IObject | string;
|
||||
last?: IObject | string;
|
||||
current?: IObject | string;
|
||||
items?: undefined;
|
||||
orderedItems?: ApObject;
|
||||
}
|
||||
|
||||
export interface ICollectionPage extends IObject {
|
||||
export interface ICollectionPage extends CollectionBase {
|
||||
type: 'CollectionPage';
|
||||
totalItems: number;
|
||||
first?: IObject | string;
|
||||
last?: IObject | string;
|
||||
current?: IObject | string;
|
||||
partOf?: IObject | string;
|
||||
next?: IObject | string;
|
||||
prev?: IObject | string;
|
||||
items?: ApObject;
|
||||
orderedItems?: undefined;
|
||||
}
|
||||
|
||||
export interface IOrderedCollectionPage extends IObject {
|
||||
export interface IOrderedCollectionPage extends CollectionBase {
|
||||
type: 'OrderedCollectionPage';
|
||||
totalItems: number;
|
||||
first?: IObject | string;
|
||||
last?: IObject | string;
|
||||
current?: IObject | string;
|
||||
partOf?: IObject | string;
|
||||
next?: IObject | string;
|
||||
prev?: IObject | string;
|
||||
items?: undefined;
|
||||
orderedItems?: ApObject;
|
||||
}
|
||||
|
||||
export type AnyCollection = ICollection | IOrderedCollection | ICollectionPage | IOrderedCollectionPage;
|
||||
|
||||
export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event'];
|
||||
|
||||
export const isPost = (object: IObject): object is IPost => {
|
||||
|
@ -255,6 +272,7 @@ export interface IActor extends IObject {
|
|||
enableRss?: boolean;
|
||||
listenbrainz?: string;
|
||||
backgroundUrl?: string;
|
||||
attributionDomains?: string[];
|
||||
}
|
||||
|
||||
export const isCollection = (object: IObject): object is ICollection =>
|
||||
|
@ -269,7 +287,7 @@ export const isCollectionPage = (object: IObject): object is ICollectionPage =>
|
|||
export const isOrderedCollectionPage = (object: IObject): object is IOrderedCollectionPage =>
|
||||
getApType(object) === 'OrderedCollectionPage';
|
||||
|
||||
export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection =>
|
||||
export const isCollectionOrOrderedCollection = (object: IObject): object is AnyCollection =>
|
||||
isCollection(object) || isOrderedCollection(object) || isCollectionPage(object) || isOrderedCollectionPage(object);
|
||||
|
||||
export interface IApPropertyValue extends IObject {
|
||||
|
|
|
@ -44,10 +44,6 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
|
|||
}
|
||||
|
||||
protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> {
|
||||
const suspendedInstancesQuery = this.instancesRepository.createQueryBuilder('instance')
|
||||
.select('instance.host')
|
||||
.where('instance.suspensionState != \'none\'');
|
||||
|
||||
const pubsubSubQuery = this.followingsRepository.createQueryBuilder('f')
|
||||
.select('f.followerHost')
|
||||
.where('f.followerHost IS NOT NULL');
|
||||
|
@ -64,22 +60,25 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
|
|||
this.followingsRepository.createQueryBuilder('following')
|
||||
.select('COUNT(DISTINCT following.followeeHost)')
|
||||
.where('following.followeeHost IS NOT NULL')
|
||||
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || following.followeeHost) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)')
|
||||
.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
|
||||
.innerJoin('following.followeeInstance', 'followeeInstance')
|
||||
.andWhere('followeeInstance.suspensionState = \'none\'')
|
||||
.andWhere('followeeInstance.isBlocked = false')
|
||||
.getRawOne()
|
||||
.then(x => parseInt(x.count, 10)),
|
||||
this.followingsRepository.createQueryBuilder('following')
|
||||
.select('COUNT(DISTINCT following.followerHost)')
|
||||
.where('following.followerHost IS NOT NULL')
|
||||
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || following.followerHost) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)')
|
||||
.andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
|
||||
.innerJoin('following.followerInstance', 'followerInstance')
|
||||
.andWhere('followerInstance.isBlocked = false')
|
||||
.andWhere('followerInstance.suspensionState = \'none\'')
|
||||
.getRawOne()
|
||||
.then(x => parseInt(x.count, 10)),
|
||||
this.followingsRepository.createQueryBuilder('following')
|
||||
.select('COUNT(DISTINCT following.followeeHost)')
|
||||
.where('following.followeeHost IS NOT NULL')
|
||||
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || following.followeeHost) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)')
|
||||
.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
|
||||
.innerJoin('following.followeeInstance', 'followeeInstance')
|
||||
.andWhere('followeeInstance.isBlocked = false')
|
||||
.andWhere('followeeInstance.suspensionState = \'none\'')
|
||||
.andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`)
|
||||
.setParameters(pubsubSubQuery.getParameters())
|
||||
.getRawOne()
|
||||
|
@ -87,7 +86,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
|
|||
this.instancesRepository.createQueryBuilder('instance')
|
||||
.select('COUNT(instance.id)')
|
||||
.where(`instance.host IN (${ subInstancesQuery.getQuery() })`)
|
||||
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || instance.host) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)')
|
||||
.andWhere('instance.isBlocked = false')
|
||||
.andWhere('instance.suspensionState = \'none\'')
|
||||
.andWhere('instance.isNotResponding = false')
|
||||
.getRawOne()
|
||||
|
@ -95,7 +94,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
|
|||
this.instancesRepository.createQueryBuilder('instance')
|
||||
.select('COUNT(instance.id)')
|
||||
.where(`instance.host IN (${ pubInstancesQuery.getQuery() })`)
|
||||
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || instance.host) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)')
|
||||
.andWhere('instance.isBlocked = false')
|
||||
.andWhere('instance.suspensionState = \'none\'')
|
||||
.andWhere('instance.isNotResponding = false')
|
||||
.getRawOne()
|
||||
|
|
|
@ -5,13 +5,14 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { AbuseUserReportsRepository } from '@/models/_.js';
|
||||
import type { AbuseUserReportsRepository, InstancesRepository, MiInstance, MiUser } from '@/models/_.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { UserEntityService } from './UserEntityService.js';
|
||||
import { InstanceEntityService } from './InstanceEntityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class AbuseUserReportEntityService {
|
||||
|
@ -19,6 +20,10 @@ export class AbuseUserReportEntityService {
|
|||
@Inject(DI.abuseUserReportsRepository)
|
||||
private abuseUserReportsRepository: AbuseUserReportsRepository,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
private readonly instanceEntityService: InstanceEntityService,
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
|
@ -30,11 +35,14 @@ export class AbuseUserReportEntityService {
|
|||
hint?: {
|
||||
packedReporter?: Packed<'UserDetailedNotMe'>,
|
||||
packedTargetUser?: Packed<'UserDetailedNotMe'>,
|
||||
packedTargetInstance?: Packed<'FederationInstance'>,
|
||||
packedAssignee?: Packed<'UserDetailedNotMe'>,
|
||||
},
|
||||
me?: MiUser | null,
|
||||
) {
|
||||
const report = typeof src === 'object' ? src : await this.abuseUserReportsRepository.findOneByOrFail({ id: src });
|
||||
|
||||
// noinspection ES6MissingAwait
|
||||
return await awaitAll({
|
||||
id: report.id,
|
||||
createdAt: this.idService.parse(report.id).date.toISOString(),
|
||||
|
@ -43,13 +51,22 @@ export class AbuseUserReportEntityService {
|
|||
reporterId: report.reporterId,
|
||||
targetUserId: report.targetUserId,
|
||||
assigneeId: report.assigneeId,
|
||||
reporter: hint?.packedReporter ?? this.userEntityService.pack(report.reporter ?? report.reporterId, null, {
|
||||
reporter: hint?.packedReporter ?? this.userEntityService.pack(report.reporter ?? report.reporterId, me, {
|
||||
schema: 'UserDetailedNotMe',
|
||||
}),
|
||||
targetUser: hint?.packedTargetUser ?? this.userEntityService.pack(report.targetUser ?? report.targetUserId, null, {
|
||||
targetUser: hint?.packedTargetUser ?? this.userEntityService.pack(report.targetUser ?? report.targetUserId, me, {
|
||||
schema: 'UserDetailedNotMe',
|
||||
}),
|
||||
assignee: report.assigneeId ? hint?.packedAssignee ?? this.userEntityService.pack(report.assignee ?? report.assigneeId, null, {
|
||||
// return hint, or pack by relation, or fetch and pack by id, or null
|
||||
targetInstance: hint?.packedTargetInstance ?? (
|
||||
report.targetUserInstance
|
||||
? this.instanceEntityService.pack(report.targetUserInstance, me)
|
||||
: report.targetUserHost
|
||||
? this.instancesRepository.findOneBy({ host: report.targetUserHost }).then(instance => instance
|
||||
? this.instanceEntityService.pack(instance, me)
|
||||
: null)
|
||||
: null),
|
||||
assignee: report.assigneeId ? hint?.packedAssignee ?? this.userEntityService.pack(report.assignee ?? report.assigneeId, me, {
|
||||
schema: 'UserDetailedNotMe',
|
||||
}) : null,
|
||||
forwarded: report.forwarded,
|
||||
|
@ -61,21 +78,28 @@ export class AbuseUserReportEntityService {
|
|||
@bindThis
|
||||
public async packMany(
|
||||
reports: MiAbuseUserReport[],
|
||||
me?: MiUser | null,
|
||||
) {
|
||||
const _reporters = reports.map(({ reporter, reporterId }) => reporter ?? reporterId);
|
||||
const _targetUsers = reports.map(({ targetUser, targetUserId }) => targetUser ?? targetUserId);
|
||||
const _assignees = reports.map(({ assignee, assigneeId }) => assignee ?? assigneeId).filter(x => x != null);
|
||||
const _userMap = await this.userEntityService.packMany(
|
||||
[..._reporters, ..._targetUsers, ..._assignees],
|
||||
null,
|
||||
me,
|
||||
{ schema: 'UserDetailedNotMe' },
|
||||
).then(users => new Map(users.map(u => [u.id, u])));
|
||||
const _targetInstances = reports
|
||||
.map(({ targetUserInstance, targetUserHost }) => targetUserInstance ?? targetUserHost)
|
||||
.filter((i): i is MiInstance | string => i != null);
|
||||
const _instanceMap = await this.instanceEntityService.packMany(await this.instanceEntityService.fetchInstancesByHost(_targetInstances), me)
|
||||
.then(instances => new Map(instances.map(i => [i.host, i])));
|
||||
return Promise.all(
|
||||
reports.map(report => {
|
||||
const packedReporter = _userMap.get(report.reporterId);
|
||||
const packedTargetUser = _userMap.get(report.targetUserId);
|
||||
const packedTargetInstance = report.targetUserHost ? _instanceMap.get(report.targetUserHost) : undefined;
|
||||
const packedAssignee = report.assigneeId != null ? _userMap.get(report.assigneeId) : undefined;
|
||||
return this.pack(report, { packedReporter, packedTargetUser, packedAssignee });
|
||||
return this.pack(report, { packedReporter, packedTargetUser, packedAssignee, packedTargetInstance }, me);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { MiInstance } from '@/models/Instance.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
@ -11,7 +12,7 @@ import { UtilityService } from '@/core/UtilityService.js';
|
|||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { MiUser } from '@/models/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { MiMeta } from '@/models/_.js';
|
||||
import type { InstancesRepository, MiMeta } from '@/models/_.js';
|
||||
|
||||
@Injectable()
|
||||
export class InstanceEntityService {
|
||||
|
@ -19,6 +20,9 @@ export class InstanceEntityService {
|
|||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private readonly instancesRepository: InstancesRepository,
|
||||
|
||||
private roleService: RoleService,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
|
@ -43,7 +47,7 @@ export class InstanceEntityService {
|
|||
isNotResponding: instance.isNotResponding,
|
||||
isSuspended: instance.suspensionState !== 'none',
|
||||
suspensionState: instance.suspensionState,
|
||||
isBlocked: this.utilityService.isBlockedHost(this.meta.blockedHosts, instance.host),
|
||||
isBlocked: instance.isBlocked,
|
||||
softwareName: instance.softwareName,
|
||||
softwareVersion: instance.softwareVersion,
|
||||
openRegistrations: instance.openRegistrations,
|
||||
|
@ -51,8 +55,8 @@ export class InstanceEntityService {
|
|||
description: instance.description,
|
||||
maintainerName: instance.maintainerName,
|
||||
maintainerEmail: instance.maintainerEmail,
|
||||
isSilenced: this.utilityService.isSilencedHost(this.meta.silencedHosts, instance.host),
|
||||
isMediaSilenced: this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, instance.host),
|
||||
isSilenced: instance.isSilenced,
|
||||
isMediaSilenced: instance.isMediaSilenced,
|
||||
iconUrl: instance.iconUrl,
|
||||
faviconUrl: instance.faviconUrl,
|
||||
themeColor: instance.themeColor,
|
||||
|
@ -62,6 +66,7 @@ export class InstanceEntityService {
|
|||
rejectReports: instance.rejectReports,
|
||||
rejectQuotes: instance.rejectQuotes,
|
||||
moderationNote: iAmModerator ? instance.moderationNote : null,
|
||||
isBubbled: this.utilityService.isBubbledHost(instance.host),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -72,5 +77,28 @@ export class InstanceEntityService {
|
|||
) {
|
||||
return Promise.all(instances.map(x => this.pack(x, me)));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetchInstancesByHost(instances: (MiInstance | MiInstance['host'])[]): Promise<MiInstance[]> {
|
||||
const result: MiInstance[] = [];
|
||||
|
||||
const toFetch: string[] = [];
|
||||
for (const instance of instances) {
|
||||
if (typeof(instance) === 'string') {
|
||||
toFetch.push(instance);
|
||||
} else {
|
||||
result.push(instance);
|
||||
}
|
||||
}
|
||||
|
||||
if (toFetch.length > 0) {
|
||||
const fetched = await this.instancesRepository.findBy({
|
||||
host: In(toFetch),
|
||||
});
|
||||
result.push(...fetched);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -487,7 +487,10 @@ export class UserEntityService implements OnModuleInit {
|
|||
includeSecrets: false,
|
||||
}, options);
|
||||
|
||||
const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src });
|
||||
const user = typeof src === 'object' ? src : await this.usersRepository.findOneOrFail({
|
||||
where: { id: src },
|
||||
relations: { userProfile: true },
|
||||
});
|
||||
|
||||
// migration
|
||||
if (user.avatarId != null && user.avatarUrl === null) {
|
||||
|
@ -521,7 +524,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false;
|
||||
|
||||
const profile = isDetailed
|
||||
? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }))
|
||||
? (opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }))
|
||||
: null;
|
||||
|
||||
let relation: UserRelation | null = null;
|
||||
|
@ -556,7 +559,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
}
|
||||
}
|
||||
|
||||
const mastoapi = !isDetailed ? opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null;
|
||||
const mastoapi = !isDetailed ? opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null;
|
||||
|
||||
const followingCount = profile == null ? null :
|
||||
(profile.followingVisibility === 'public') || isMe || iAmModerator ? user.followingCount :
|
||||
|
@ -603,19 +606,21 @@ export class UserEntityService implements OnModuleInit {
|
|||
enableRss: user.enableRss,
|
||||
mandatoryCW: user.mandatoryCW,
|
||||
rejectQuotes: user.rejectQuotes,
|
||||
attributionDomains: user.attributionDomains,
|
||||
isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
|
||||
speakAsCat: user.speakAsCat ?? false,
|
||||
approved: user.approved,
|
||||
requireSigninToViewContents: user.requireSigninToViewContents === false ? undefined : true,
|
||||
makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore ?? undefined,
|
||||
makeNotesHiddenBefore: user.makeNotesHiddenBefore ?? undefined,
|
||||
instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? {
|
||||
instance: user.host ? this.federatedInstanceService.fetch(user.host).then(instance => instance ? {
|
||||
name: instance.name,
|
||||
softwareName: instance.softwareName,
|
||||
softwareVersion: instance.softwareVersion,
|
||||
iconUrl: instance.iconUrl,
|
||||
faviconUrl: instance.faviconUrl,
|
||||
themeColor: instance.themeColor,
|
||||
isSilenced: instance.isSilenced,
|
||||
} : undefined) : undefined,
|
||||
followersCount: followersCount ?? 0,
|
||||
followingCount: followingCount ?? 0,
|
||||
|
@ -783,8 +788,13 @@ export class UserEntityService implements OnModuleInit {
|
|||
const _users = users.filter((user): user is MiUser => typeof user !== 'string');
|
||||
if (_users.length !== users.length) {
|
||||
_users.push(
|
||||
...await this.usersRepository.findBy({
|
||||
...await this.usersRepository.find({
|
||||
where: {
|
||||
id: In(users.filter((user): user is string => typeof user === 'string')),
|
||||
},
|
||||
relations: {
|
||||
userProfile: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
@ -798,8 +808,20 @@ export class UserEntityService implements OnModuleInit {
|
|||
let pinNotes: Map<MiUser['id'], MiUserNotePining[]> = new Map();
|
||||
|
||||
if (options?.schema !== 'UserLite') {
|
||||
profilesMap = await this.userProfilesRepository.findBy({ userId: In(_userIds) })
|
||||
.then(profiles => new Map(profiles.map(p => [p.userId, p])));
|
||||
const _profiles: MiUserProfile[] = [];
|
||||
const _profilesToFetch: string[] = [];
|
||||
for (const user of _users) {
|
||||
if (user.userProfile) {
|
||||
_profiles.push(user.userProfile);
|
||||
} else {
|
||||
_profilesToFetch.push(user.id);
|
||||
}
|
||||
}
|
||||
if (_profilesToFetch.length > 0) {
|
||||
const fetched = await this.userProfilesRepository.findBy({ userId: In(_profilesToFetch) });
|
||||
_profiles.push(...fetched);
|
||||
}
|
||||
profilesMap = new Map(_profiles.map(p => [p.userId, p]));
|
||||
|
||||
const meId = me ? me.id : null;
|
||||
if (meId) {
|
||||
|
|
|
@ -11,6 +11,7 @@ const envOption = {
|
|||
verbose: false,
|
||||
withLogTime: false,
|
||||
quiet: false,
|
||||
hideWorkerId: false,
|
||||
};
|
||||
|
||||
for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) {
|
||||
|
|
|
@ -71,7 +71,9 @@ export default class Logger {
|
|||
level === 'info' ? message :
|
||||
null;
|
||||
|
||||
let log = `${l} ${worker}\t[${contexts.join(' ')}]\t${m}`;
|
||||
let log = envOption.hideWorkerId
|
||||
? `${l}\t[${contexts.join(' ')}]\t\t${m}`
|
||||
: `${l} ${worker}\t[${contexts.join(' ')}]\t\t${m}`;
|
||||
if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log;
|
||||
|
||||
const args: unknown[] = [important ? chalk.bold(log) : log];
|
||||
|
|
|
@ -308,8 +308,17 @@ export class MemoryKVCache<T> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all entries from the cache, but does not dispose it.
|
||||
*/
|
||||
@bindThis
|
||||
public clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.clear();
|
||||
clearInterval(this.gcIntervalHandle);
|
||||
}
|
||||
|
||||
|
|
102
packages/backend/src/misc/diff-arrays.ts
Normal file
102
packages/backend/src/misc/diff-arrays.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export interface DiffResult<T> {
|
||||
added: T[];
|
||||
removed: T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the difference between two snapshots of data.
|
||||
* Null, undefined, and empty arrays are supported, and duplicate values are ignored.
|
||||
* Result sets are de-duplicated, and will be empty if no data was added or removed (respectively).
|
||||
* The inputs are treated as un-ordered, so a re-ordering of the same data will NOT be considered a change.
|
||||
* @param dataBefore Array containing data before the change
|
||||
* @param dataAfter Array containing data after the change
|
||||
*/
|
||||
export function diffArrays<T>(dataBefore: T[] | null | undefined, dataAfter: T[] | null | undefined): DiffResult<T> {
|
||||
const before = dataBefore ? new Set(dataBefore) : null;
|
||||
const after = dataAfter ? new Set(dataAfter) : null;
|
||||
|
||||
// data before AND after => changed
|
||||
if (before?.size && after?.size) {
|
||||
const added: T[] = [];
|
||||
const removed: T[] = [];
|
||||
|
||||
for (const host of before) {
|
||||
// before and NOT after => removed
|
||||
// delete operation removes duplicates to speed up the "after" loop
|
||||
if (!after.delete(host)) {
|
||||
removed.push(host);
|
||||
}
|
||||
}
|
||||
|
||||
for (const host of after) {
|
||||
// after and NOT before => added
|
||||
if (!before.has(host)) {
|
||||
added.push(host);
|
||||
}
|
||||
}
|
||||
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
// data ONLY before => all removed
|
||||
if (before?.size) {
|
||||
return { added: [], removed: Array.from(before) };
|
||||
}
|
||||
|
||||
// data ONLY after => all added
|
||||
if (after?.size) {
|
||||
return { added: Array.from(after), removed: [] };
|
||||
}
|
||||
|
||||
// data NEITHER before nor after => no change
|
||||
return { added: [], removed: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for any difference between two snapshots of data.
|
||||
* Null, undefined, and empty arrays are supported, and duplicate values are ignored.
|
||||
* The inputs are treated as un-ordered, so a re-ordering of the same data will NOT be considered a change.
|
||||
* @param dataBefore Array containing data before the change
|
||||
* @param dataAfter Array containing data after the change
|
||||
*/
|
||||
export function diffArraysSimple<T>(dataBefore: T[] | null | undefined, dataAfter: T[] | null | undefined): boolean {
|
||||
const before = dataBefore ? new Set(dataBefore) : null;
|
||||
const after = dataAfter ? new Set(dataAfter) : null;
|
||||
|
||||
if (before?.size && after?.size) {
|
||||
// different size => changed
|
||||
if (before.size !== after.size) return true;
|
||||
|
||||
// removed => changed
|
||||
for (const host of before) {
|
||||
// delete operation removes duplicates to speed up the "after" loop
|
||||
if (!after.delete(host)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// added => changed
|
||||
for (const host of after) {
|
||||
if (!before.has(host)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// identical values => no change
|
||||
return false;
|
||||
}
|
||||
|
||||
// before and NOT after => change
|
||||
if (before?.size) return true;
|
||||
|
||||
// after and NOT before => change
|
||||
if (after?.size) return true;
|
||||
|
||||
// NEITHER before nor after => no change
|
||||
return false;
|
||||
}
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { MiInstance } from '@/models/Instance.js';
|
||||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
|
||||
|
@ -88,11 +89,31 @@ export class MiAbuseUserReport {
|
|||
})
|
||||
public targetUserHost: string | null;
|
||||
|
||||
@ManyToOne(() => MiInstance, {
|
||||
// TODO create a foreign key constraint after hazelnoot/labs/persisted-instance-blocks is merged
|
||||
createForeignKeyConstraints: false,
|
||||
})
|
||||
@JoinColumn({
|
||||
name: 'targetUserHost',
|
||||
referencedColumnName: 'host',
|
||||
})
|
||||
public targetUserInstance: MiInstance | null;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
comment: '[Denormalized]',
|
||||
})
|
||||
public reporterHost: string | null;
|
||||
|
||||
@ManyToOne(() => MiInstance, {
|
||||
// TODO create a foreign key constraint after hazelnoot/labs/persisted-instance-blocks is merged
|
||||
createForeignKeyConstraints: false,
|
||||
})
|
||||
@JoinColumn({
|
||||
name: 'reporterHost',
|
||||
referencedColumnName: 'host',
|
||||
})
|
||||
public reporterInstance: MiInstance | null;
|
||||
//#endregion
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { MiInstance } from '@/models/Instance.js';
|
||||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
|
||||
|
@ -66,6 +67,16 @@ export class MiFollowing {
|
|||
})
|
||||
public followerHost: string | null;
|
||||
|
||||
@ManyToOne(() => MiInstance, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({
|
||||
name: 'followerHost',
|
||||
foreignKeyConstraintName: 'FK_following_followerHost',
|
||||
referencedColumnName: 'host',
|
||||
})
|
||||
public followerInstance: MiInstance | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
comment: '[Denormalized]',
|
||||
|
@ -85,6 +96,16 @@ export class MiFollowing {
|
|||
})
|
||||
public followeeHost: string | null;
|
||||
|
||||
@ManyToOne(() => MiInstance, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({
|
||||
name: 'followeeHost',
|
||||
foreignKeyConstraintName: 'FK_following_followeeHost',
|
||||
referencedColumnName: 'host',
|
||||
})
|
||||
public followeeInstance: MiInstance | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
comment: '[Denormalized]',
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
import { Entity, PrimaryColumn, Index, Column } from 'typeorm';
|
||||
import { id } from './util/id.js';
|
||||
|
||||
@Index('IDX_instance_host_key', { synchronize: false }) // ((lower(reverse("host"::text)) || '.'::text)
|
||||
@Index('IDX_instance_host_filters', { synchronize: false }) // ("host", "isBlocked", "isSilenced", "isMediaSilenced", "isAllowListed", "isBubbled", "suspensionState")
|
||||
@Entity('instance')
|
||||
export class MiInstance {
|
||||
@PrimaryColumn(id())
|
||||
|
@ -98,6 +100,56 @@ export class MiInstance {
|
|||
})
|
||||
public suspensionState: 'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding';
|
||||
|
||||
/**
|
||||
* True if this instance is blocked from federation.
|
||||
*/
|
||||
@Column('boolean', {
|
||||
nullable: false,
|
||||
default: false,
|
||||
comment: 'True if this instance is blocked from federation.',
|
||||
})
|
||||
public isBlocked: boolean;
|
||||
|
||||
/**
|
||||
* True if this instance is allow-listed.
|
||||
*/
|
||||
@Column('boolean', {
|
||||
nullable: false,
|
||||
default: false,
|
||||
comment: 'True if this instance is allow-listed.',
|
||||
})
|
||||
public isAllowListed: boolean;
|
||||
|
||||
/**
|
||||
* True if this instance is part of the local bubble.
|
||||
*/
|
||||
@Column('boolean', {
|
||||
nullable: false,
|
||||
default: false,
|
||||
comment: 'True if this instance is part of the local bubble.',
|
||||
})
|
||||
public isBubbled: boolean;
|
||||
|
||||
/**
|
||||
* True if this instance is silenced.
|
||||
*/
|
||||
@Column('boolean', {
|
||||
nullable: false,
|
||||
default: false,
|
||||
comment: 'True if this instance is silenced.',
|
||||
})
|
||||
public isSilenced: boolean;
|
||||
|
||||
/**
|
||||
* True if this instance is media-silenced.
|
||||
*/
|
||||
@Column('boolean', {
|
||||
nullable: false,
|
||||
default: false,
|
||||
comment: 'True if this instance is media-silenced.',
|
||||
})
|
||||
public isMediaSilenced: boolean;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 64, nullable: true,
|
||||
comment: 'The software of the Instance.',
|
||||
|
|
|
@ -5,12 +5,15 @@
|
|||
|
||||
import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
|
||||
import { noteVisibilities } from '@/types.js';
|
||||
import { MiInstance } from '@/models/Instance.js';
|
||||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
import { MiChannel } from './Channel.js';
|
||||
import type { MiDriveFile } from './DriveFile.js';
|
||||
|
||||
@Index('IDX_724b311e6f883751f261ebe378', ['userId', 'id'])
|
||||
@Index('IDX_note_userHost_id', { synchronize: false }) // (userHost, id desc)
|
||||
@Index('IDX_note_for_timelines', { synchronize: false }) // (id desc, channelId, visibility, userHost)
|
||||
@Entity('note')
|
||||
export class MiNote {
|
||||
@PrimaryColumn(id())
|
||||
|
@ -130,6 +133,7 @@ export class MiNote {
|
|||
})
|
||||
public uri: string | null;
|
||||
|
||||
@Index('IDX_note_url')
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
comment: 'The human readable url of a note. it will be null when the note is local.',
|
||||
|
@ -215,13 +219,22 @@ export class MiNote {
|
|||
public processErrors: string[] | null;
|
||||
|
||||
//#region Denormalized fields
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
comment: '[Denormalized]',
|
||||
})
|
||||
public userHost: string | null;
|
||||
|
||||
@ManyToOne(() => MiInstance, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({
|
||||
name: 'userHost',
|
||||
foreignKeyConstraintName: 'FK_note_userHost',
|
||||
referencedColumnName: 'host',
|
||||
})
|
||||
public userInstance: MiInstance | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
|
@ -235,6 +248,16 @@ export class MiNote {
|
|||
})
|
||||
public replyUserHost: string | null;
|
||||
|
||||
@ManyToOne(() => MiInstance, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({
|
||||
name: 'replyUserHost',
|
||||
foreignKeyConstraintName: 'FK_note_replyUserHost',
|
||||
referencedColumnName: 'host',
|
||||
})
|
||||
public replyUserInstance: MiInstance | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
|
@ -247,6 +270,16 @@ export class MiNote {
|
|||
comment: '[Denormalized]',
|
||||
})
|
||||
public renoteUserHost: string | null;
|
||||
|
||||
@ManyToOne(() => MiInstance, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({
|
||||
name: 'renoteUserHost',
|
||||
foreignKeyConstraintName: 'FK_note_renoteUserHost',
|
||||
referencedColumnName: 'host',
|
||||
})
|
||||
public renoteUserInstance: MiInstance | null;
|
||||
//#endregion
|
||||
|
||||
constructor(data: Partial<MiNote>) {
|
||||
|
|
|
@ -3,10 +3,12 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm';
|
||||
import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn, ManyToOne } from 'typeorm';
|
||||
import { type UserUnsignedFetchOption, userUnsignedFetchOptions } from '@/const.js';
|
||||
import { MiInstance } from '@/models/Instance.js';
|
||||
import { id } from './util/id.js';
|
||||
import { MiDriveFile } from './DriveFile.js';
|
||||
import type { MiUserProfile } from './UserProfile.js';
|
||||
|
||||
@Entity('user')
|
||||
@Index(['usernameLower', 'host'], { unique: true })
|
||||
|
@ -292,6 +294,16 @@ export class MiUser {
|
|||
})
|
||||
public host: string | null;
|
||||
|
||||
@ManyToOne(() => MiInstance, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({
|
||||
name: 'host',
|
||||
foreignKeyConstraintName: 'FK_user_host',
|
||||
referencedColumnName: 'host',
|
||||
})
|
||||
public instance: MiInstance | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
comment: 'The inbox URL of the User. It will be null if the origin of the user is local.',
|
||||
|
@ -378,6 +390,15 @@ export class MiUser {
|
|||
})
|
||||
public allowUnsignedFetch: UserUnsignedFetchOption;
|
||||
|
||||
@Column('text', {
|
||||
name: 'attributionDomains',
|
||||
array: true, default: '{}',
|
||||
})
|
||||
public attributionDomains: string[];
|
||||
|
||||
@OneToOne('user_profile', (profile: MiUserProfile) => profile.user)
|
||||
public userProfile: MiUserProfile | null;
|
||||
|
||||
constructor(data: Partial<MiUser>) {
|
||||
if (data == null) return;
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ export class MiUserProfile {
|
|||
@PrimaryColumn(id())
|
||||
public userId: MiUser['id'];
|
||||
|
||||
@OneToOne(type => MiUser, {
|
||||
@OneToOne(() => MiUser, user => user.userProfile, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
|
|
|
@ -135,5 +135,9 @@ export const packedFederationInstanceSchema = {
|
|||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
},
|
||||
isBubbled: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
|
@ -200,6 +200,10 @@ export const packedUserLiteSchema = {
|
|||
type: 'string',
|
||||
nullable: true, optional: false,
|
||||
},
|
||||
isSilenced: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
emojis: {
|
||||
|
@ -236,6 +240,14 @@ export const packedUserLiteSchema = {
|
|||
},
|
||||
},
|
||||
},
|
||||
attributionDomains: {
|
||||
type: 'array',
|
||||
nullable: false, optional: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -98,9 +98,12 @@ pg.types.setTypeParser(20, Number);
|
|||
export const dbLogger = new MisskeyLogger('db');
|
||||
|
||||
const sqlLogger = dbLogger.createSubLogger('sql', 'gray');
|
||||
const sqlMigrateLogger = sqlLogger.createSubLogger('migrate');
|
||||
const sqlSchemaLogger = sqlLogger.createSubLogger('schema');
|
||||
|
||||
export type LoggerProps = {
|
||||
disableQueryTruncation?: boolean;
|
||||
enableQueryLogging?: boolean;
|
||||
enableQueryParamLogging?: boolean;
|
||||
printReplicationMode?: boolean,
|
||||
};
|
||||
|
@ -112,7 +115,7 @@ function highlightSql(sql: string) {
|
|||
}
|
||||
|
||||
function truncateSql(sql: string) {
|
||||
return sql.length > 100 ? `${sql.substring(0, 100)}...` : sql;
|
||||
return sql.length > 100 ? `${sql.substring(0, 100)} [truncated]` : sql;
|
||||
}
|
||||
|
||||
function stringifyParameter(param: any) {
|
||||
|
@ -136,13 +139,16 @@ class MyCustomLogger implements Logger {
|
|||
modded = truncateSql(modded);
|
||||
}
|
||||
|
||||
return highlightSql(modded);
|
||||
return this.props.enableQueryLogging ? highlightSql(modded) : modded;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private transformParameters(parameters?: any[]) {
|
||||
if (this.props.enableQueryParamLogging && parameters && parameters.length > 0) {
|
||||
return parameters.map(stringifyParameter);
|
||||
return parameters.reduce((params, p, i) => {
|
||||
params[`$${i + 1}`] = stringifyParameter(p);
|
||||
return params;
|
||||
}, {} as Record<string, string>);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
@ -150,10 +156,13 @@ class MyCustomLogger implements Logger {
|
|||
|
||||
@bindThis
|
||||
public logQuery(query: string, parameters?: any[], queryRunner?: QueryRunner) {
|
||||
if (!this.props.enableQueryLogging) return;
|
||||
|
||||
const prefix = (this.props.printReplicationMode && queryRunner)
|
||||
? `[${queryRunner.getReplicationMode()}] `
|
||||
: undefined;
|
||||
sqlLogger.info(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters));
|
||||
const transformed = this.transformQueryLog(query, { prefix });
|
||||
sqlLogger.debug(`Query run: ${transformed}`, this.transformParameters(parameters));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -161,7 +170,8 @@ class MyCustomLogger implements Logger {
|
|||
const prefix = (this.props.printReplicationMode && queryRunner)
|
||||
? `[${queryRunner.getReplicationMode()}] `
|
||||
: undefined;
|
||||
sqlLogger.error(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters));
|
||||
const transformed = this.transformQueryLog(query, { prefix });
|
||||
sqlLogger.error(`Query error (${error}): ${transformed}`, this.transformParameters(parameters));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -169,22 +179,32 @@ class MyCustomLogger implements Logger {
|
|||
const prefix = (this.props.printReplicationMode && queryRunner)
|
||||
? `[${queryRunner.getReplicationMode()}] `
|
||||
: undefined;
|
||||
sqlLogger.warn(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters));
|
||||
const transformed = this.transformQueryLog(query, { prefix });
|
||||
sqlLogger.warn(`Query is slow (${time}ms): ${transformed}`, this.transformParameters(parameters));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public logSchemaBuild(message: string) {
|
||||
sqlLogger.info(message);
|
||||
sqlSchemaLogger.debug(message);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public log(message: string) {
|
||||
public log(level: 'log' | 'info' | 'warn', message: string) {
|
||||
switch (level) {
|
||||
case 'log':
|
||||
case 'info': {
|
||||
sqlLogger.info(message);
|
||||
break;
|
||||
}
|
||||
case 'warn': {
|
||||
sqlLogger.warn(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public logMigration(message: string) {
|
||||
sqlLogger.info(message);
|
||||
sqlMigrateLogger.debug(message);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -306,7 +326,7 @@ export function createPostgresDataSource(config: Config) {
|
|||
} : {}),
|
||||
synchronize: process.env.NODE_ENV === 'test',
|
||||
dropSchema: process.env.NODE_ENV === 'test',
|
||||
cache: !config.db.disableCache && process.env.NODE_ENV !== 'test' ? { // dbをcloseしても何故かredisのコネクションが内部的に残り続けるようで、テストの際に支障が出るため無効にする(キャッシュも含めてテストしたいため本当は有効にしたいが...)
|
||||
cache: config.db.disableCache === false && process.env.NODE_ENV !== 'test' ? { // dbをcloseしても何故かredisのコネクションが内部的に残り続けるようで、テストの際に支障が出るため無効にする(キャッシュも含めてテストしたいため本当は有効にしたいが...)
|
||||
type: 'ioredis',
|
||||
options: {
|
||||
...config.redis,
|
||||
|
@ -314,14 +334,13 @@ export function createPostgresDataSource(config: Config) {
|
|||
},
|
||||
} : false,
|
||||
logging: log,
|
||||
logger: log
|
||||
? new MyCustomLogger({
|
||||
logger: new MyCustomLogger({
|
||||
disableQueryTruncation: config.logging?.sql?.disableQueryTruncation,
|
||||
enableQueryLogging: log,
|
||||
enableQueryParamLogging: config.logging?.sql?.enableQueryParamLogging,
|
||||
printReplicationMode: !!config.dbReplications,
|
||||
})
|
||||
: undefined,
|
||||
maxQueryExecutionTime: 300,
|
||||
}),
|
||||
maxQueryExecutionTime: config.db.slowQueryThreshold,
|
||||
entities: entities,
|
||||
migrations: ['../../migration/*.js'],
|
||||
});
|
||||
|
|
|
@ -125,6 +125,14 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
|||
return `Old keyId is no longer supported. ${keyIdLower}`;
|
||||
}
|
||||
|
||||
if (activity.actor as unknown == null || (Array.isArray(activity.actor) && activity.actor.length < 1)) {
|
||||
return 'skip: activity has no actor';
|
||||
}
|
||||
if (typeof(activity.actor) !== 'string' && typeof(activity.actor) !== 'object') {
|
||||
return `skip: activity actor has invalid type: ${typeof(activity.actor)}`;
|
||||
}
|
||||
const actorId = getApId(activity.actor);
|
||||
|
||||
// HTTP-Signature keyIdを元にDBから取得
|
||||
let authUser: {
|
||||
user: MiRemoteUser;
|
||||
|
@ -134,26 +142,26 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
|||
// keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得
|
||||
if (authUser == null) {
|
||||
try {
|
||||
authUser = await this.apDbResolverService.getAuthUserFromApId(getApId(activity.actor));
|
||||
authUser = await this.apDbResolverService.getAuthUserFromApId(actorId);
|
||||
} catch (err) {
|
||||
// 対象が4xxならスキップ
|
||||
if (err instanceof StatusError) {
|
||||
if (!err.isRetryable) {
|
||||
throw new Bull.UnrecoverableError(`skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`);
|
||||
throw new Bull.UnrecoverableError(`skip: Ignored deleted actors on both ends ${actorId} - ${err.statusCode}`);
|
||||
}
|
||||
throw new Error(`Error in actor ${activity.actor} - ${err.statusCode}`);
|
||||
throw new Error(`Error in actor ${actorId} - ${err.statusCode}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// それでもわからなければ終了
|
||||
if (authUser == null) {
|
||||
throw new Bull.UnrecoverableError(`skip: failed to resolve user ${getApId(activity.actor)}`);
|
||||
throw new Bull.UnrecoverableError(`skip: failed to resolve user ${actorId}`);
|
||||
}
|
||||
|
||||
// publicKey がなくても終了
|
||||
if (authUser.key == null) {
|
||||
throw new Bull.UnrecoverableError(`skip: failed to resolve user publicKey ${getApId(activity.actor)}`);
|
||||
throw new Bull.UnrecoverableError(`skip: failed to resolve user publicKey ${actorId}`);
|
||||
}
|
||||
|
||||
// HTTP-Signatureの検証
|
||||
|
@ -168,7 +176,7 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
// また、signatureのsignerは、activity.actorと一致する必要がある
|
||||
if (!httpSignatureValidated || authUser.user.uri !== getApId(activity.actor)) {
|
||||
if (!httpSignatureValidated || authUser.user.uri !== actorId) {
|
||||
// 一致しなくても、でもLD-Signatureがありそうならそっちも見る
|
||||
const ldSignature = activity.signature;
|
||||
if (ldSignature) {
|
||||
|
@ -213,8 +221,8 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
|||
activity.signature = ldSignature;
|
||||
|
||||
// もう一度actorチェック
|
||||
if (authUser.user.uri !== activity.actor) {
|
||||
throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`);
|
||||
if (authUser.user.uri !== actorId) {
|
||||
throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${actorId})`);
|
||||
}
|
||||
|
||||
const ldHost = this.utilityService.extractDbHost(authUser.user.uri);
|
||||
|
|
|
@ -344,14 +344,14 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
if (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin) {
|
||||
if (user == null) {
|
||||
if (user == null && ep.meta.requireCredential !== 'optional') {
|
||||
throw new ApiError({
|
||||
message: 'Credential required.',
|
||||
code: 'CREDENTIAL_REQUIRED',
|
||||
id: '1384574d-a912-4b81-8601-c7b1c4085df1',
|
||||
httpStatusCode: 401,
|
||||
});
|
||||
} else if (user!.isSuspended) {
|
||||
} else if (user?.isSuspended) {
|
||||
throw new ApiError({
|
||||
message: 'Your account has been suspended.',
|
||||
code: 'YOUR_ACCOUNT_SUSPENDED',
|
||||
|
@ -372,8 +372,8 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
if ((ep.meta.requireModerator || ep.meta.requireAdmin) && (this.meta.rootUserId !== user!.id)) {
|
||||
const myRoles = await this.roleService.getUserRoles(user!.id);
|
||||
if ((ep.meta.requireModerator || ep.meta.requireAdmin) && (this.meta.rootUserId !== user?.id)) {
|
||||
const myRoles = user ? await this.roleService.getUserRoles(user) : [];
|
||||
if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) {
|
||||
throw new ApiError({
|
||||
message: 'You are not assigned to a moderator role.',
|
||||
|
@ -392,9 +392,9 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
if (ep.meta.requiredRolePolicy != null && (this.meta.rootUserId !== user!.id)) {
|
||||
const myRoles = await this.roleService.getUserRoles(user!.id);
|
||||
const policies = await this.roleService.getUserPolicies(user!.id);
|
||||
if (ep.meta.requiredRolePolicy != null && (this.meta.rootUserId !== user?.id)) {
|
||||
const myRoles = user ? await this.roleService.getUserRoles(user) : [];
|
||||
const policies = await this.roleService.getUserPolicies(user ?? null);
|
||||
if (!policies[ep.meta.requiredRolePolicy] && !myRoles.some(r => r.isAdministrator)) {
|
||||
throw new ApiError({
|
||||
message: 'You are not assigned to a required role.',
|
||||
|
@ -418,7 +418,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||
// Cast non JSON input
|
||||
if ((ep.meta.requireFile || request.method === 'GET') && ep.params.properties) {
|
||||
for (const k of Object.keys(ep.params.properties)) {
|
||||
const param = ep.params.properties![k];
|
||||
const param = ep.params.properties[k];
|
||||
if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') {
|
||||
try {
|
||||
data[k] = JSON.parse(data[k]);
|
||||
|
|
|
@ -92,7 +92,7 @@ export type IEndpointMeta = (Omit<IEndpointMetaBase, 'requireCrential' | 'requir
|
|||
}) | (Omit<IEndpointMetaBase, 'secure'> & {
|
||||
secure: true,
|
||||
}) | (Omit<IEndpointMetaBase, 'requireCredential' | 'kind'> & {
|
||||
requireCredential: true,
|
||||
requireCredential: true | 'optional',
|
||||
kind: (typeof permissions)[number],
|
||||
}) | (Omit<IEndpointMetaBase, 'requireModerator' | 'kind'> & {
|
||||
requireModerator: true,
|
||||
|
|
|
@ -69,6 +69,11 @@ export const meta = {
|
|||
nullable: false, optional: false,
|
||||
ref: 'UserDetailedNotMe',
|
||||
},
|
||||
targetInstance: {
|
||||
type: 'object',
|
||||
nullable: true, optional: false,
|
||||
ref: 'FederationInstance',
|
||||
},
|
||||
assignee: {
|
||||
type: 'object',
|
||||
nullable: true, optional: false,
|
||||
|
@ -115,7 +120,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private queryService: QueryService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.abuseUserReportsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId);
|
||||
const query = this.queryService.makePaginationQuery(this.abuseUserReportsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId)
|
||||
.leftJoinAndSelect('report.targetUser', 'targetUser')
|
||||
.leftJoinAndSelect('targetUser.userProfile', 'targetUserProfile')
|
||||
.leftJoinAndSelect('report.targetUserInstance', 'targetUserInstance')
|
||||
.leftJoinAndSelect('report.reporter', 'reporter')
|
||||
.leftJoinAndSelect('reporter.userProfile', 'reporterProfile')
|
||||
.leftJoinAndSelect('report.assignee', 'assignee')
|
||||
.leftJoinAndSelect('assignee.userProfile', 'assigneeProfile')
|
||||
;
|
||||
|
||||
switch (ps.state) {
|
||||
case 'resolved': query.andWhere('report.resolved = TRUE'); break;
|
||||
|
@ -134,7 +147,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
const reports = await query.limit(ps.limit).getMany();
|
||||
|
||||
return await this.abuseUserReportEntityService.packMany(reports);
|
||||
return await this.abuseUserReportEntityService.packMany(reports, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -122,6 +122,10 @@ export const meta = {
|
|||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isAdministrator: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isSystem: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
|
@ -257,6 +261,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
|
||||
const isModerator = await this.roleService.isModerator(user);
|
||||
const isAdministrator = await this.roleService.isAdministrator(user);
|
||||
const isSilenced = user.isSilenced || !(await this.roleService.getUserPolicies(user.id)).canPublicNote;
|
||||
|
||||
const _me = await this.usersRepository.findOneByOrFail({ id: me.id });
|
||||
|
@ -289,6 +294,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
mutedInstances: profile.mutedInstances,
|
||||
notificationRecieveConfig: profile.notificationRecieveConfig,
|
||||
isModerator: isModerator,
|
||||
isAdministrator: isAdministrator,
|
||||
isSystem: isSystemAccount(user),
|
||||
isSilenced: isSilenced,
|
||||
isSuspended: user.isSuspended,
|
||||
|
|
|
@ -14,6 +14,7 @@ import { IdService } from '@/core/IdService.js';
|
|||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -75,6 +76,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private queryService: QueryService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private readonly activeUsersChart: ActiveUsersChart,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
|
@ -106,7 +108,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
return [];
|
||||
}
|
||||
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
|
||||
ps.sinceId, ps.untilId)
|
||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
|
@ -121,13 +124,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
||||
const notes = await query.getMany();
|
||||
if (sinceId != null && untilId == null) {
|
||||
notes.sort((a, b) => a.id < b.id ? -1 : 1);
|
||||
} else {
|
||||
notes.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
}
|
||||
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
});
|
||||
|
||||
return await this.noteEntityService.packMany(notes, me);
|
||||
});
|
||||
|
|
|
@ -7,6 +7,7 @@ import { Injectable } from '@nestjs/common';
|
|||
import ms from 'ms';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ApResolverService } from '@/core/activitypub/ApResolverService.js';
|
||||
import { isCollectionOrOrderedCollection, isOrderedCollection, isOrderedCollectionPage } from '@/core/activitypub/type.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['federation'],
|
||||
|
@ -33,6 +34,9 @@ export const paramDef = {
|
|||
type: 'object',
|
||||
properties: {
|
||||
uri: { type: 'string' },
|
||||
expandCollectionItems: { type: 'boolean' },
|
||||
expandCollectionLimit: { type: 'integer', nullable: true },
|
||||
allowAnonymous: { type: 'boolean' },
|
||||
},
|
||||
required: ['uri'],
|
||||
} as const;
|
||||
|
@ -44,7 +48,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
const object = await resolver.resolve(ps.uri);
|
||||
const object = await resolver.resolve(ps.uri, ps.allowAnonymous ?? false);
|
||||
|
||||
if (ps.expandCollectionItems && isCollectionOrOrderedCollection(object)) {
|
||||
const items = await resolver.resolveCollectionItems(object, ps.expandCollectionLimit, ps.allowAnonymous ?? false);
|
||||
|
||||
if (isOrderedCollection(object) || isOrderedCollectionPage(object)) {
|
||||
object.orderedItems = items;
|
||||
} else {
|
||||
object.items = items;
|
||||
}
|
||||
}
|
||||
|
||||
return object;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -96,7 +96,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
|
||||
if (me) this.activeUsersChart.read(me);
|
||||
if (me) {
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.serverSettings.enableFanoutTimeline) {
|
||||
return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me), me);
|
||||
|
@ -135,29 +139,28 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
.leftJoinAndSelect('note.channel', 'channel')
|
||||
.limit(ps.limit);
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
}
|
||||
|
||||
if (ps.withRenotes === false) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteId IS NULL');
|
||||
qb.orWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.text IS NOT NULL');
|
||||
qb.orWhere('note.fileIds != \'{}\'');
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
if (ps.withFiles) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
}
|
||||
|
||||
if (!ps.withRenotes) {
|
||||
this.queryService.generateExcludedRenotesQueryForNotes(query);
|
||||
} else if (me) {
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
return await query.limit(ps.limit).getMany();
|
||||
return await query.getMany();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,11 +17,11 @@ export const meta = {
|
|||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// Burst up to 100, then 2/sec average
|
||||
// Burst up to 200, then 5/sec average
|
||||
limit: {
|
||||
type: 'bucket',
|
||||
size: 100,
|
||||
dripRate: 500,
|
||||
size: 200,
|
||||
dripRate: 200,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -17,11 +17,11 @@ export const meta = {
|
|||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// Burst up to 100, then 2/sec average
|
||||
// Burst up to 200, then 5/sec average
|
||||
limit: {
|
||||
type: 'bucket',
|
||||
size: 100,
|
||||
dripRate: 500,
|
||||
size: 200,
|
||||
dripRate: 200,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -17,11 +17,11 @@ export const meta = {
|
|||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// Burst up to 100, then 2/sec average
|
||||
// Burst up to 200, then 5/sec average
|
||||
limit: {
|
||||
type: 'bucket',
|
||||
size: 100,
|
||||
dripRate: 500,
|
||||
size: 200,
|
||||
dripRate: 200,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -17,11 +17,11 @@ export const meta = {
|
|||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// Burst up to 100, then 2/sec average
|
||||
// Burst up to 200, then 5/sec average
|
||||
limit: {
|
||||
type: 'bucket',
|
||||
size: 100,
|
||||
dripRate: 500,
|
||||
size: 200,
|
||||
dripRate: 200,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -17,11 +17,11 @@ export const meta = {
|
|||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// Burst up to 100, then 2/sec average
|
||||
// Burst up to 200, then 5/sec average
|
||||
limit: {
|
||||
type: 'bucket',
|
||||
size: 100,
|
||||
dripRate: 500,
|
||||
size: 200,
|
||||
dripRate: 200,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -17,11 +17,11 @@ export const meta = {
|
|||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// Burst up to 100, then 2/sec average
|
||||
// Burst up to 200, then 5/sec average
|
||||
limit: {
|
||||
type: 'bucket',
|
||||
size: 100,
|
||||
dripRate: 500,
|
||||
size: 200,
|
||||
dripRate: 200,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -17,11 +17,11 @@ export const meta = {
|
|||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// Burst up to 100, then 2/sec average
|
||||
// Burst up to 200, then 5/sec average
|
||||
limit: {
|
||||
type: 'bucket',
|
||||
size: 100,
|
||||
dripRate: 500,
|
||||
size: 200,
|
||||
dripRate: 200,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -17,11 +17,11 @@ export const meta = {
|
|||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// Burst up to 100, then 2/sec average
|
||||
// Burst up to 200, then 5/sec average
|
||||
limit: {
|
||||
type: 'bucket',
|
||||
size: 100,
|
||||
dripRate: 500,
|
||||
size: 200,
|
||||
dripRate: 200,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -17,11 +17,11 @@ export const meta = {
|
|||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// Burst up to 100, then 2/sec average
|
||||
// Burst up to 200, then 5/sec average
|
||||
limit: {
|
||||
type: 'bucket',
|
||||
size: 100,
|
||||
dripRate: 500,
|
||||
size: 200,
|
||||
dripRate: 200,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -17,11 +17,11 @@ export const meta = {
|
|||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// Burst up to 100, then 2/sec average
|
||||
// Burst up to 200, then 5/sec average
|
||||
limit: {
|
||||
type: 'bucket',
|
||||
size: 100,
|
||||
dripRate: 500,
|
||||
size: 200,
|
||||
dripRate: 200,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -17,11 +17,11 @@ export const meta = {
|
|||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// Burst up to 100, then 2/sec average
|
||||
// Burst up to 200, then 5/sec average
|
||||
limit: {
|
||||
type: 'bucket',
|
||||
size: 100,
|
||||
dripRate: 500,
|
||||
size: 200,
|
||||
dripRate: 200,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -17,11 +17,11 @@ export const meta = {
|
|||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// Burst up to 100, then 2/sec average
|
||||
// Burst up to 200, then 5/sec average
|
||||
limit: {
|
||||
type: 'bucket',
|
||||
size: 100,
|
||||
dripRate: 500,
|
||||
size: 200,
|
||||
dripRate: 200,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -92,10 +92,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.andWhere('clipNote.clipId = :clipId', { clipId: clip.id });
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
if (me) {
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
}
|
||||
|
||||
const notes = await query
|
||||
|
|
|
@ -81,10 +81,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.noSuchFile);
|
||||
}
|
||||
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId);
|
||||
query.andWhere(':file <@ note.fileIds', { file: [file.id] });
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.andWhere(':file <@ note.fileIds', { file: [file.id] })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.limit(ps.limit);
|
||||
|
||||
const notes = await query.limit(ps.limit).getMany();
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
||||
const notes = await query.getMany();
|
||||
|
||||
return await this.noteEntityService.packMany(notes, me, {
|
||||
detail: true,
|
||||
|
|
|
@ -263,6 +263,15 @@ export const paramDef = {
|
|||
enum: userUnsignedFetchOptions,
|
||||
nullable: false,
|
||||
},
|
||||
attributionDomains: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 128,
|
||||
},
|
||||
maxLength: 32,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@ -373,6 +382,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances;
|
||||
if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig;
|
||||
if (ps.attributionDomains !== undefined) updates.attributionDomains = ps.attributionDomains;
|
||||
if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked;
|
||||
if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable;
|
||||
if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus;
|
||||
|
@ -663,7 +673,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
// these two methods need to be kept in sync with
|
||||
// `ApRendererService.renderPerson`
|
||||
private userNeedsPublishing(oldUser: MiLocalUser, newUser: Partial<MiUser>): boolean {
|
||||
const basicFields: (keyof MiUser)[] = ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus', 'enableRss', 'requireSigninToViewContents', 'makeNotesFollowersOnlyBefore', 'makeNotesHiddenBefore'];
|
||||
const basicFields: (keyof MiUser)[] = ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus', 'enableRss', 'requireSigninToViewContents', 'makeNotesFollowersOnlyBefore', 'makeNotesHiddenBefore', 'attributionDomains'];
|
||||
for (const field of basicFields) {
|
||||
if ((field in newUser) && oldUser[field] !== newUser[field]) {
|
||||
return true;
|
||||
|
|
|
@ -64,7 +64,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.limit(ps.limit);
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
if (me) {
|
||||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
}
|
||||
|
||||
if (ps.local) {
|
||||
query.andWhere('note.userHost IS NULL');
|
||||
|
@ -75,7 +84,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
|
||||
if (ps.renote !== undefined) {
|
||||
query.andWhere(ps.renote ? 'note.renoteId IS NOT NULL' : 'note.renoteId IS NULL');
|
||||
if (ps.renote) {
|
||||
this.queryService.andIsRenote(query, 'note');
|
||||
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
}
|
||||
} else {
|
||||
this.queryService.andIsNotRenote(query, 'note');
|
||||
}
|
||||
}
|
||||
|
||||
if (ps.withFiles !== undefined) {
|
||||
|
@ -91,7 +108,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
// query.isBot = bot;
|
||||
//}
|
||||
|
||||
const notes = await query.limit(ps.limit).getMany();
|
||||
const notes = await query.getMany();
|
||||
|
||||
return await this.noteEntityService.packMany(notes);
|
||||
});
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: Marie and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Brackets } from 'typeorm';
|
||||
import type { NotesRepository, MiMeta } from '@/models/_.js';
|
||||
import type { NotesRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -56,9 +59,6 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.meta)
|
||||
private serverSettings: MiMeta,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
|
@ -66,7 +66,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private queryService: QueryService,
|
||||
private roleService: RoleService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const policies = await this.roleService.getUserPolicies(me ? me.id : null);
|
||||
|
@ -74,29 +73,34 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.btlDisabled);
|
||||
}
|
||||
|
||||
const [
|
||||
followings,
|
||||
] = me ? await Promise.all([
|
||||
this.cacheService.userFollowingsCache.fetch(me.id),
|
||||
]) : [undefined];
|
||||
|
||||
//#region Construct query
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
|
||||
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('note.visibility = \'public\'')
|
||||
.andWhere('note.channelId IS NULL')
|
||||
.andWhere('note.userHost IN (:...hosts)', { hosts: this.serverSettings.bubbleInstances })
|
||||
.andWhere('note.userHost IS NOT NULL')
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.limit(ps.limit);
|
||||
|
||||
// This subquery mess teaches postgres how to use the right indexes.
|
||||
// Using WHERE or ON conditions causes a fallback to full sequence scan, which times out.
|
||||
// Important: don't use a query builder here or TypeORM will get confused and stop quoting column names! (known, unfixed bug apparently)
|
||||
query
|
||||
.leftJoin('(select "host" from "instance" where "isBubbled" = true)', 'bubbleInstance', '"bubbleInstance"."host" = "note"."userHost"')
|
||||
.andWhere('"bubbleInstance" IS NOT NULL');
|
||||
this.queryService
|
||||
.leftJoinInstance(query, 'note.userInstance', 'userInstance', '"userInstance"."host" = "bubbleInstance"."host"');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
}
|
||||
|
||||
if (ps.withFiles) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
|
@ -104,29 +108,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
if (!ps.withBots) query.andWhere('user.isBot = FALSE');
|
||||
|
||||
if (ps.withRenotes === false) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.where('note.renoteId IS NULL');
|
||||
qb.orWhere(new Brackets(qb => {
|
||||
qb.where('note.text IS NOT NULL');
|
||||
qb.orWhere('note.fileIds != \'{}\'');
|
||||
}));
|
||||
}));
|
||||
if (!ps.withRenotes) {
|
||||
this.queryService.generateExcludedRenotesQueryForNotes(query);
|
||||
} else if (me) {
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
let timeline = await query.limit(ps.limit).getMany();
|
||||
const timeline = await query.getMany();
|
||||
|
||||
timeline = timeline.filter(note => {
|
||||
if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
process.nextTick(() => {
|
||||
if (me) {
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
});
|
||||
|
|
|
@ -57,26 +57,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyId = :noteId', { noteId: ps.noteId });
|
||||
qb.orWhere('note.replyId = :noteId');
|
||||
|
||||
if (ps.showQuotes) {
|
||||
qb.orWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.renoteId = :noteId', { noteId: ps.noteId })
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.text IS NOT NULL')
|
||||
.orWhere('note.fileIds != \'{}\'')
|
||||
.orWhere('note.hasPoll = TRUE');
|
||||
}));
|
||||
}));
|
||||
qb.orWhere(new Brackets(qbb => this.queryService
|
||||
.andIsQuote(qbb, 'note')
|
||||
.andWhere('note.renoteId = :noteId'),
|
||||
));
|
||||
}
|
||||
}))
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.setParameters({ noteId: ps.noteId })
|
||||
.limit(ps.limit);
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
|
@ -85,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
}
|
||||
|
||||
const notes = await query.limit(ps.limit).getMany();
|
||||
const notes = await query.getMany();
|
||||
|
||||
return await this.noteEntityService.packMany(notes, me);
|
||||
});
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
import { IsNull, ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
import { SkLatestNote, MiFollowing } from '@/models/_.js';
|
||||
import type { NotesRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
|
@ -12,6 +12,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
@ -76,8 +77,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private readonly noteEntityService: NoteEntityService,
|
||||
private readonly queryService: QueryService,
|
||||
private readonly activeUsersChart: ActiveUsersChart,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
if (ps.includeReplies && ps.filesOnly) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles);
|
||||
|
@ -85,7 +87,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
const query = this.notesRepository
|
||||
.createQueryBuilder('note')
|
||||
.setParameter('me', me.id)
|
||||
.setParameters({ meId: me.id })
|
||||
|
||||
// Limit to latest notes
|
||||
.innerJoin(
|
||||
|
@ -130,7 +132,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel')
|
||||
|
||||
// Exclude channel notes
|
||||
.andWhere({ channelId: IsNull() })
|
||||
;
|
||||
|
||||
// Limit to files, if requested
|
||||
|
@ -145,23 +149,26 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
// Hide blocked users / instances
|
||||
query.andWhere('"user"."isSuspended" = false');
|
||||
query.andWhere('("replyUser" IS NULL OR "replyUser"."isSuspended" = false)');
|
||||
query.andWhere('("renoteUser" IS NULL OR "renoteUser"."isSuspended" = false)');
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
|
||||
// Respect blocks and mutes
|
||||
// Respect blocks, mutes, and privacy
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
|
||||
// Support pagination
|
||||
this.queryService
|
||||
.makePaginationQuery(query, ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.orderBy('note.id', 'DESC')
|
||||
.take(ps.limit);
|
||||
|
||||
// Query and return the next page
|
||||
const notes = await query.getMany();
|
||||
return await this.noteEntityService.packMany(notes, me);
|
||||
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
});
|
||||
|
||||
return await this.noteEntityService.packMany(notes, me, { skipHide: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -170,14 +177,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
* Limit to followers (they follow us)
|
||||
*/
|
||||
function addFollower<T extends SelectQueryBuilder<ObjectLiteral>>(query: T): T {
|
||||
return query.innerJoin(MiFollowing, 'follower', 'follower."followerId" = latest.user_id AND follower."followeeId" = :me');
|
||||
return query.innerJoin(MiFollowing, 'follower', 'follower."followerId" = latest.user_id AND follower."followeeId" = :meId');
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit to followees (we follow them)
|
||||
*/
|
||||
function addFollowee<T extends SelectQueryBuilder<ObjectLiteral>>(query: T): T {
|
||||
return query.innerJoin(MiFollowing, 'followee', 'followee."followerId" = :me AND followee."followeeId" = latest.user_id');
|
||||
return query.innerJoin(MiFollowing, 'followee', 'followee."followerId" = :meId AND followee."followeeId" = latest.user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -12,7 +12,6 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
|||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -68,7 +67,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private queryService: QueryService,
|
||||
private roleService: RoleService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const policies = await this.roleService.getUserPolicies(me ? me.id : null);
|
||||
|
@ -76,8 +74,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.gtlDisabled);
|
||||
}
|
||||
|
||||
const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : {};
|
||||
|
||||
//#region Construct query
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
|
||||
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
|
@ -90,11 +86,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
|
||||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
}
|
||||
|
||||
if (ps.withFiles) {
|
||||
|
@ -103,29 +98,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
if (!ps.withBots) query.andWhere('user.isBot = FALSE');
|
||||
|
||||
if (ps.withRenotes === false) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.where('note.renoteId IS NULL');
|
||||
qb.orWhere(new Brackets(qb => {
|
||||
qb.where('note.text IS NOT NULL');
|
||||
qb.orWhere('note.fileIds != \'{}\'');
|
||||
}));
|
||||
}));
|
||||
if (!ps.withRenotes) {
|
||||
this.queryService.generateExcludedRenotesQueryForNotes(query);
|
||||
} else if (me) {
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
let timeline = await query.limit(ps.limit).getMany();
|
||||
const timeline = await query.limit(ps.limit).getMany();
|
||||
|
||||
timeline = timeline.filter(note => {
|
||||
if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
process.nextTick(() => {
|
||||
if (me) {
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
});
|
||||
|
|
|
@ -66,9 +66,6 @@ export const paramDef = {
|
|||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
|
||||
includeMyRenotes: { type: 'boolean', default: true },
|
||||
includeRenotedMyNotes: { type: 'boolean', default: true },
|
||||
includeLocalRenotes: { type: 'boolean', default: true },
|
||||
withFiles: { type: 'boolean', default: false },
|
||||
withRenotes: { type: 'boolean', default: true },
|
||||
withReplies: { type: 'boolean', default: false },
|
||||
|
@ -114,12 +111,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
untilId,
|
||||
sinceId,
|
||||
limit: ps.limit,
|
||||
includeMyRenotes: ps.includeMyRenotes,
|
||||
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
||||
includeLocalRenotes: ps.includeLocalRenotes,
|
||||
withFiles: ps.withFiles,
|
||||
withReplies: ps.withReplies,
|
||||
withBots: ps.withBots,
|
||||
withRenotes: ps.withRenotes,
|
||||
}, me);
|
||||
|
||||
process.nextTick(() => {
|
||||
|
@ -178,12 +173,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
untilId,
|
||||
sinceId,
|
||||
limit,
|
||||
includeMyRenotes: ps.includeMyRenotes,
|
||||
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
||||
includeLocalRenotes: ps.includeLocalRenotes,
|
||||
withFiles: ps.withFiles,
|
||||
withReplies: ps.withReplies,
|
||||
withBots: ps.withBots,
|
||||
withRenotes: ps.withRenotes,
|
||||
}, me),
|
||||
});
|
||||
|
||||
|
@ -199,103 +192,58 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
untilId: string | null,
|
||||
sinceId: string | null,
|
||||
limit: number,
|
||||
includeMyRenotes: boolean,
|
||||
includeRenotedMyNotes: boolean,
|
||||
includeLocalRenotes: boolean,
|
||||
withFiles: boolean,
|
||||
withReplies: boolean,
|
||||
withBots: boolean,
|
||||
withRenotes: boolean,
|
||||
}, me: MiLocalUser) {
|
||||
const followees = await this.userFollowingService.getFollowees(me.id);
|
||||
const followingChannels = await this.channelFollowingsRepository.find({
|
||||
where: {
|
||||
followerId: me.id,
|
||||
},
|
||||
});
|
||||
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.andWhere(new Brackets(qb => {
|
||||
if (followees.length > 0) {
|
||||
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
||||
qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
|
||||
qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
|
||||
} else {
|
||||
qb.where('note.userId = :meId', { meId: me.id });
|
||||
qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
|
||||
}
|
||||
}))
|
||||
// 1. by a user I follow, 2. a public local post, 3. my own post
|
||||
.andWhere(new Brackets(qb => this.queryService
|
||||
.orFollowingUser(qb, ':meId', 'note.userId')
|
||||
.orWhere(new Brackets(qbb => qbb
|
||||
.andWhere('note.visibility = \'public\'')
|
||||
.andWhere('note.userHost IS NULL')))
|
||||
.orWhere(':meId = note.userId')))
|
||||
// 1. in a channel I follow, 2. not in a channel
|
||||
.andWhere(new Brackets(qb => this.queryService
|
||||
.orFollowingChannel(qb, ':meId', 'note.channelId')
|
||||
.orWhere('note.channelId IS NULL')))
|
||||
.setParameters({ meId: me.id })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
if (followingChannels.length > 0) {
|
||||
const followingChannelIds = followingChannels.map(x => x.followeeId);
|
||||
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
|
||||
qb.orWhere('note.channelId IS NULL');
|
||||
}));
|
||||
} else {
|
||||
query.andWhere('note.channelId IS NULL');
|
||||
}
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.limit(ps.limit);
|
||||
|
||||
if (!ps.withReplies) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyId IS NULL') // 返信ではない
|
||||
.orWhere(new Brackets(qb => {
|
||||
qb // 返信だけど投稿者自身への返信
|
||||
.where('note.replyId IS NOT NULL')
|
||||
.andWhere('note.replyUserId = note.userId');
|
||||
}));
|
||||
}));
|
||||
query
|
||||
// 1. Not a reply, 2. a self-reply
|
||||
.andWhere(new Brackets(qb => qb
|
||||
.orWhere('note.replyId IS NULL') // 返信ではない
|
||||
.orWhere('note.replyUserId = note.userId')));
|
||||
}
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
||||
if (ps.includeMyRenotes === false) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.userId != :meId', { meId: me.id });
|
||||
qb.orWhere('note.renoteId IS NULL');
|
||||
qb.orWhere('note.text IS NOT NULL');
|
||||
qb.orWhere('note.fileIds != \'{}\'');
|
||||
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
||||
}));
|
||||
}
|
||||
|
||||
if (ps.includeRenotedMyNotes === false) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
|
||||
qb.orWhere('note.renoteId IS NULL');
|
||||
qb.orWhere('note.text IS NOT NULL');
|
||||
qb.orWhere('note.fileIds != \'{}\'');
|
||||
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
||||
}));
|
||||
}
|
||||
|
||||
if (ps.includeLocalRenotes === false) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteUserHost IS NOT NULL');
|
||||
qb.orWhere('note.renoteId IS NULL');
|
||||
qb.orWhere('note.text IS NOT NULL');
|
||||
qb.orWhere('note.fileIds != \'{}\'');
|
||||
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
||||
}));
|
||||
}
|
||||
|
||||
if (ps.withFiles) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
}
|
||||
|
||||
if (!ps.withBots) query.andWhere('user.isBot = FALSE');
|
||||
|
||||
if (!ps.withRenotes) {
|
||||
this.queryService.generateExcludedRenotesQueryForNotes(query);
|
||||
} else {
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
return await query.limit(ps.limit).getMany();
|
||||
return await query.getMany();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -103,13 +103,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
withFiles: ps.withFiles,
|
||||
withReplies: ps.withReplies,
|
||||
withBots: ps.withBots,
|
||||
withRenotes: ps.withRenotes,
|
||||
}, me);
|
||||
|
||||
process.nextTick(() => {
|
||||
if (me) {
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
}
|
||||
|
@ -136,14 +137,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
withFiles: ps.withFiles,
|
||||
withReplies: ps.withReplies,
|
||||
withBots: ps.withBots,
|
||||
withRenotes: ps.withRenotes,
|
||||
}, me),
|
||||
});
|
||||
|
||||
process.nextTick(() => {
|
||||
if (me) {
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return timeline;
|
||||
});
|
||||
|
@ -156,40 +158,47 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
withFiles: boolean,
|
||||
withReplies: boolean,
|
||||
withBots: boolean,
|
||||
withRenotes: boolean,
|
||||
}, me: MiLocalUser | null) {
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
|
||||
ps.sinceId, ps.untilId)
|
||||
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL) AND (note.channelId IS NULL)')
|
||||
.andWhere('note.visibility = \'public\'')
|
||||
.andWhere('note.channelId IS NULL')
|
||||
.andWhere('note.userHost IS NULL')
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.limit(ps.limit);
|
||||
|
||||
if (!ps.withReplies) {
|
||||
query
|
||||
// 1. Not a reply, 2. a self-reply
|
||||
.andWhere(new Brackets(qb => qb
|
||||
.orWhere('note.replyId IS NULL') // 返信ではない
|
||||
.orWhere('note.replyUserId = note.userId')));
|
||||
}
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
}
|
||||
|
||||
if (ps.withFiles) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
}
|
||||
|
||||
if (!ps.withReplies) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyId IS NULL') // 返信ではない
|
||||
.orWhere(new Brackets(qb => {
|
||||
qb // 返信だけど投稿者自身への返信
|
||||
.where('note.replyId IS NOT NULL')
|
||||
.andWhere('note.replyUserId = note.userId');
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
if (!ps.withBots) query.andWhere('user.isBot = FALSE');
|
||||
|
||||
return await query.limit(ps.limit).getMany();
|
||||
if (!ps.withRenotes) {
|
||||
this.queryService.generateExcludedRenotesQueryForNotes(query);
|
||||
} else if (me) {
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
}
|
||||
|
||||
return await query.getMany();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,10 +6,12 @@
|
|||
import { Brackets } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { NotesRepository, FollowingsRepository } from '@/models/_.js';
|
||||
import { MiNote } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
@ -57,42 +59,59 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private readonly activeUsersChart: ActiveUsersChart,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :followerId', { followerId: me.id });
|
||||
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb // このmeIdAsListパラメータはqueryServiceのgenerateVisibilityQueryでセットされる
|
||||
.where(':meIdAsList <@ note.mentions')
|
||||
.orWhere(':meIdAsList <@ note.visibleUserIds');
|
||||
}))
|
||||
// Avoid scanning primary key index
|
||||
.orderBy('CONCAT(note.id)', 'DESC')
|
||||
.innerJoin(qb => {
|
||||
qb
|
||||
.select('note.id', 'id')
|
||||
.from(qbb => qbb
|
||||
.select('note.id', 'id')
|
||||
.from(MiNote, 'note')
|
||||
.where(new Brackets(qbbb => qbbb
|
||||
// DM to me
|
||||
.orWhere(':meIdAsList <@ note.visibleUserIds')
|
||||
// Mentions me
|
||||
.orWhere(':meIdAsList <@ note.mentions'),
|
||||
))
|
||||
.setParameters({ meIdAsList: [me.id] })
|
||||
, 'source')
|
||||
.innerJoin(MiNote, 'note', 'note.id = source.id');
|
||||
|
||||
// Mentioned or visible users can always access
|
||||
//this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(qb);
|
||||
this.queryService.generateMutedUserQueryForNotes(qb, me);
|
||||
this.queryService.generateMutedNoteThreadQuery(qb, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(qb, me);
|
||||
// A renote can't mention a user, so it will never appear here anyway.
|
||||
//this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
||||
if (ps.visibility) {
|
||||
qb.andWhere('note.visibility = :visibility', { visibility: ps.visibility });
|
||||
}
|
||||
|
||||
if (ps.following) {
|
||||
this.queryService
|
||||
.andFollowingUser(qb, ':meId', 'note.userId')
|
||||
.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
return qb;
|
||||
}, 'source', 'source.id = note.id')
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.limit(ps.limit);
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedNoteThreadQuery(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
const mentions = await query.getMany();
|
||||
|
||||
if (ps.visibility) {
|
||||
query.andWhere('note.visibility = :visibility', { visibility: ps.visibility });
|
||||
}
|
||||
|
||||
if (ps.following) {
|
||||
query.andWhere(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id });
|
||||
query.setParameters(followingQuery.getParameters());
|
||||
}
|
||||
|
||||
const mentions = await query.limit(ps.limit).getMany();
|
||||
process.nextTick(() => {
|
||||
this.activeUsersChart.read(me);
|
||||
});
|
||||
|
||||
return await this.noteEntityService.packMany(mentions, me);
|
||||
});
|
||||
|
|
|
@ -9,13 +9,13 @@ import type { NotesRepository, MutingsRepository, PollsRepository, PollVotesRepo
|
|||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
||||
requireCredential: true,
|
||||
kind: 'read:account',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
|
@ -26,10 +26,24 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
|
||||
// 2 calls per second
|
||||
errors: {
|
||||
ltlDisabled: {
|
||||
message: 'Local timeline has been disabled.',
|
||||
code: 'LTL_DISABLED',
|
||||
id: '45a6eb02-7695-4393-b023-dd3be9aaaefd',
|
||||
},
|
||||
gtlDisabled: {
|
||||
message: 'Global timeline has been disabled.',
|
||||
code: 'GTL_DISABLED',
|
||||
id: '0332fc13-6ab2-4427-ae80-a9fadffd1a6b',
|
||||
},
|
||||
},
|
||||
|
||||
// Up to 10 calls, then 2 per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 2,
|
||||
type: 'bucket',
|
||||
size: 10,
|
||||
dripRate: 500,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@ -39,6 +53,8 @@ export const paramDef = {
|
|||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
offset: { type: 'integer', default: 0 },
|
||||
excludeChannels: { type: 'boolean', default: false },
|
||||
local: { type: 'boolean', nullable: true, default: null },
|
||||
expired: { type: 'boolean', default: false },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
@ -59,18 +75,54 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private readonly queryService: QueryService,
|
||||
private readonly roleService: RoleService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.pollsRepository.createQueryBuilder('poll')
|
||||
.where('poll.userHost IS NULL')
|
||||
.andWhere('poll.userId != :meId', { meId: me.id })
|
||||
.andWhere('poll.noteVisibility = \'public\'')
|
||||
.andWhere(new Brackets(qb => {
|
||||
.innerJoinAndSelect('poll.note', 'note')
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.andWhere('user.isExplorable = TRUE')
|
||||
;
|
||||
|
||||
if (me) {
|
||||
query.andWhere('poll.userId != :meId', { meId: me.id });
|
||||
}
|
||||
|
||||
if (ps.expired) {
|
||||
query.andWhere('poll.expiresAt IS NOT NULL');
|
||||
query.andWhere('poll.expiresAt <= :expiresMax', {
|
||||
expiresMax: new Date(),
|
||||
});
|
||||
query.andWhere('poll.expiresAt >= :expiresMin', {
|
||||
expiresMin: new Date(Date.now() - (1000 * 60 * 60 * 24 * 7)),
|
||||
});
|
||||
} else {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('poll.expiresAt IS NULL')
|
||||
.orWhere('poll.expiresAt > :now', { now: new Date() });
|
||||
}));
|
||||
}
|
||||
|
||||
const policies = await this.roleService.getUserPolicies(me?.id ?? null);
|
||||
if (ps.local != null) {
|
||||
if (ps.local) {
|
||||
if (!policies.ltlAvailable) throw new ApiError(meta.errors.ltlDisabled);
|
||||
query.andWhere('poll.userHost IS NULL');
|
||||
} else {
|
||||
if (!policies.gtlAvailable) throw new ApiError(meta.errors.gtlDisabled);
|
||||
query.andWhere('poll.userHost IS NOT NULL');
|
||||
}
|
||||
} else {
|
||||
if (!policies.gtlAvailable) throw new ApiError(meta.errors.gtlDisabled);
|
||||
}
|
||||
|
||||
/*
|
||||
//#region exclude arleady voted polls
|
||||
const votedQuery = this.pollVotesRepository.createQueryBuilder('vote')
|
||||
.select('vote.noteId')
|
||||
|
@ -81,16 +133,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
query.setParameters(votedQuery.getParameters());
|
||||
//#endregion
|
||||
*/
|
||||
|
||||
//#region mute
|
||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('muting.muteeId')
|
||||
.where('muting.muterId = :muterId', { muterId: me.id });
|
||||
|
||||
query
|
||||
.andWhere(`poll.userId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
|
||||
query.setParameters(mutingQuery.getParameters());
|
||||
//#region block/mute/vis
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
if (me) {
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region exclude channels
|
||||
|
@ -107,6 +158,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
if (polls.length === 0) return [];
|
||||
|
||||
/*
|
||||
const notes = await this.notesRepository.find({
|
||||
where: {
|
||||
id: In(polls.map(poll => poll.noteId)),
|
||||
|
@ -115,6 +167,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
id: 'DESC',
|
||||
},
|
||||
});
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const notes = polls.map(poll => poll.note!);
|
||||
|
||||
return await this.noteEntityService.packMany(notes, me, {
|
||||
detail: true,
|
||||
|
|
|
@ -47,7 +47,7 @@ export const paramDef = {
|
|||
type: 'object',
|
||||
properties: {
|
||||
noteId: { type: 'string', format: 'misskey:id' },
|
||||
userId: { type: "string", format: "misskey:id" },
|
||||
userId: { type: 'string', format: 'misskey:id' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
|
@ -81,19 +81,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
if (ps.userId) {
|
||||
query.andWhere("user.id = :userId", { userId: ps.userId });
|
||||
query.andWhere('user.id = :userId', { userId: ps.userId });
|
||||
}
|
||||
|
||||
if (ps.quote) {
|
||||
query.andWhere("note.text IS NOT NULL");
|
||||
this.queryService.andIsQuote(query, 'note');
|
||||
} else {
|
||||
query.andWhere("note.text IS NULL");
|
||||
this.queryService.andIsRenote(query, 'note');
|
||||
}
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
}
|
||||
|
||||
const renotes = await query.limit(ps.limit).getMany();
|
||||
|
||||
|
|
|
@ -59,14 +59,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.limit(ps.limit);
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
}
|
||||
|
||||
const timeline = await query.limit(ps.limit).getMany();
|
||||
const timeline = await query.getMany();
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
});
|
||||
|
|
|
@ -12,8 +12,6 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
|
|||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes', 'hashtags'],
|
||||
|
@ -82,26 +80,26 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private cacheService: CacheService,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.andWhere("note.visibility IN ('public', 'home')") // keep in sync with NoteCreateService call to `hashtagService.updateHashtags()`
|
||||
.andWhere(new Brackets(qb => qb
|
||||
.orWhere('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\''))) // keep in sync with NoteCreateService call to `hashtagService.updateHashtags()`
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.limit(ps.limit);
|
||||
|
||||
if (!this.serverSettings.enableBotTrending) query.andWhere('user.isBot = FALSE');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
||||
const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : {};
|
||||
if (!this.serverSettings.enableBotTrending) query.andWhere('user.isBot = FALSE');
|
||||
|
||||
try {
|
||||
if (ps.tag) {
|
||||
|
@ -134,9 +132,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
if (ps.renote != null) {
|
||||
if (ps.renote) {
|
||||
query.andWhere('note.renoteId IS NOT NULL');
|
||||
this.queryService.andIsRenote(query, 'note');
|
||||
} else {
|
||||
query.andWhere('note.renoteId IS NULL');
|
||||
this.queryService.andIsNotRenote(query, 'note');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -153,17 +151,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
|
||||
// Search notes
|
||||
let notes = await query.limit(ps.limit).getMany();
|
||||
|
||||
notes = notes.filter(note => {
|
||||
if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false;
|
||||
if (note.user?.isSuspended) return false;
|
||||
if (note.userHost) {
|
||||
if (!this.utilityService.isFederationAllowedHost(note.userHost)) return false;
|
||||
if (this.utilityService.isSilencedHost(this.serverSettings.silencedHosts, note.userHost)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const notes = await query.getMany();
|
||||
|
||||
return await this.noteEntityService.packMany(notes, me);
|
||||
});
|
||||
|
|
|
@ -49,9 +49,6 @@ export const paramDef = {
|
|||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
|
||||
includeMyRenotes: { type: 'boolean', default: true },
|
||||
includeRenotedMyNotes: { type: 'boolean', default: true },
|
||||
includeLocalRenotes: { type: 'boolean', default: true },
|
||||
withFiles: { type: 'boolean', default: false },
|
||||
withRenotes: { type: 'boolean', default: true },
|
||||
withBots: { type: 'boolean', default: true },
|
||||
|
@ -88,9 +85,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
untilId,
|
||||
sinceId,
|
||||
limit: ps.limit,
|
||||
includeMyRenotes: ps.includeMyRenotes,
|
||||
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
||||
includeLocalRenotes: ps.includeLocalRenotes,
|
||||
withFiles: ps.withFiles,
|
||||
withRenotes: ps.withRenotes,
|
||||
withBots: ps.withBots,
|
||||
|
@ -131,9 +125,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
untilId,
|
||||
sinceId,
|
||||
limit,
|
||||
includeMyRenotes: ps.includeMyRenotes,
|
||||
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
||||
includeLocalRenotes: ps.includeLocalRenotes,
|
||||
withFiles: ps.withFiles,
|
||||
withRenotes: ps.withRenotes,
|
||||
withBots: ps.withBots,
|
||||
|
@ -148,113 +139,48 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
});
|
||||
}
|
||||
|
||||
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; withBots: boolean; }, me: MiLocalUser) {
|
||||
const followees = await this.userFollowingService.getFollowees(me.id);
|
||||
const followingChannels = await this.channelFollowingsRepository.find({
|
||||
where: {
|
||||
followerId: me.id,
|
||||
},
|
||||
});
|
||||
|
||||
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; withFiles: boolean; withRenotes: boolean; withBots: boolean; }, me: MiLocalUser) {
|
||||
//#region Construct query
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
// 1. in a channel I follow, 2. my own post, 3. by a user I follow
|
||||
.andWhere(new Brackets(qb => this.queryService
|
||||
.orFollowingChannel(qb, ':meId', 'note.channelId')
|
||||
.orWhere(':meId = note.userId')
|
||||
.orWhere(new Brackets(qb2 => this.queryService
|
||||
.andFollowingUser(qb2, ':meId', 'note.userId')
|
||||
.andWhere('note.channelId IS NULL'))),
|
||||
))
|
||||
// 1. Not a reply, 2. a self-reply
|
||||
.andWhere(new Brackets(qb => qb
|
||||
.orWhere('note.replyId IS NULL') // 返信ではない
|
||||
.orWhere('note.replyUserId = note.userId')))
|
||||
.setParameters({ meId: me.id })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
if (followees.length > 0 && followingChannels.length > 0) {
|
||||
// ユーザー・チャンネルともにフォローあり
|
||||
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
||||
const followingChannelIds = followingChannels.map(x => x.followeeId);
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where(new Brackets(qb2 => {
|
||||
qb2
|
||||
.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds })
|
||||
.andWhere('note.channelId IS NULL');
|
||||
}))
|
||||
.orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
|
||||
}));
|
||||
} else if (followees.length > 0) {
|
||||
// ユーザーフォローのみ(チャンネルフォローなし)
|
||||
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
||||
query
|
||||
.andWhere('note.channelId IS NULL')
|
||||
.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
|
||||
} else if (followingChannels.length > 0) {
|
||||
// チャンネルフォローのみ(ユーザーフォローなし)
|
||||
const followingChannelIds = followingChannels.map(x => x.followeeId);
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds })
|
||||
.orWhere('note.userId = :meId', { meId: me.id });
|
||||
}));
|
||||
} else {
|
||||
// フォローなし
|
||||
query
|
||||
.andWhere('note.channelId IS NULL')
|
||||
.andWhere('note.userId = :meId', { meId: me.id });
|
||||
}
|
||||
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyId IS NULL') // 返信ではない
|
||||
.orWhere(new Brackets(qb => {
|
||||
qb // 返信だけど投稿者自身への返信
|
||||
.where('note.replyId IS NOT NULL')
|
||||
.andWhere('note.replyUserId = note.userId');
|
||||
}));
|
||||
}));
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.limit(ps.limit);
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
||||
if (ps.includeMyRenotes === false) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.userId != :meId', { meId: me.id });
|
||||
qb.orWhere('note.renoteId IS NULL');
|
||||
qb.orWhere('note.text IS NOT NULL');
|
||||
qb.orWhere('note.fileIds != \'{}\'');
|
||||
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
||||
}));
|
||||
}
|
||||
|
||||
if (ps.includeRenotedMyNotes === false) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
|
||||
qb.orWhere('note.renoteId IS NULL');
|
||||
qb.orWhere('note.text IS NOT NULL');
|
||||
qb.orWhere('note.fileIds != \'{}\'');
|
||||
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
||||
}));
|
||||
}
|
||||
|
||||
if (ps.includeLocalRenotes === false) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteUserHost IS NOT NULL');
|
||||
qb.orWhere('note.renoteId IS NULL');
|
||||
qb.orWhere('note.text IS NOT NULL');
|
||||
qb.orWhere('note.fileIds != \'{}\'');
|
||||
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
||||
}));
|
||||
}
|
||||
|
||||
if (ps.withFiles) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
}
|
||||
|
||||
if (ps.withRenotes === false) {
|
||||
query.andWhere('note.renoteId IS NULL');
|
||||
}
|
||||
|
||||
if (!ps.withBots) query.andWhere('user.isBot = FALSE');
|
||||
|
||||
if (!ps.withRenotes) {
|
||||
this.queryService.generateExcludedRenotesQueryForNotes(query);
|
||||
} else {
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
return await query.limit(ps.limit).getMany();
|
||||
return await query.getMany();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,11 +20,9 @@ import { ApiError } from '../../error.js';
|
|||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
||||
// TODO allow unauthenticated if default template allows?
|
||||
// Maybe a value 'optional' that allows unauthenticated OR a token w/ appropriate role.
|
||||
// This will allow unauthenticated requests without leaking post data to restricted clients.
|
||||
requireCredential: true,
|
||||
requireCredential: 'optional',
|
||||
kind: 'read:account',
|
||||
requiredRolePolicy: 'canUseTranslator',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
|
@ -88,17 +86,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private readonly loggerService: ApiLoggerService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const policies = await this.roleService.getUserPolicies(me.id);
|
||||
if (!policies.canUseTranslator) {
|
||||
throw new ApiError(meta.errors.unavailable);
|
||||
}
|
||||
|
||||
const note = await this.getterService.getNote(ps.noteId).catch(err => {
|
||||
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (!(await this.noteEntityService.isVisibleForMe(note, me.id))) {
|
||||
if (!(await this.noteEntityService.isVisibleForMe(note, me?.id ?? null))) {
|
||||
throw new ApiError(meta.errors.cannotTranslateInvisibleNote);
|
||||
}
|
||||
|
||||
|
@ -140,7 +133,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (this.serverSettings.deeplAuthKey) params.append('auth_key', this.serverSettings.deeplAuthKey);
|
||||
params.append('text', note.text);
|
||||
params.append('target_lang', targetLang);
|
||||
const endpoint = deeplFreeInstance ?? this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate';
|
||||
const endpoint = deeplFreeInstance ?? ( this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate' );
|
||||
|
||||
const res = await this.httpRequestService.send(endpoint, {
|
||||
method: 'POST',
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue