Merge tag '13.11.0' into io
# Conflicts: # packages/backend/src/server/ServerService.ts # packages/backend/src/server/api/endpoints/notes/timeline.ts
This commit is contained in:
		| @@ -19,6 +19,6 @@ | ||||
| 	</head> | ||||
| 	<body> | ||||
| 		<redoc spec-url="/api.json" expand-responses="200" expand-single-schema-field="true"></redoc> | ||||
| 		<script src="https://cdn.jsdelivr.net/npm/redoc@2.0.0-rc.50/bundles/redoc.standalone.js" integrity="sha256-WJbngBWN9vp6vkEuzeoSj5tE5saW9Hfj6/SinkzhL2s=" crossorigin="anonymous"></script> | ||||
| 		<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script> | ||||
| 	</body> | ||||
| </html> | ||||
|   | ||||
| @@ -1,8 +1,15 @@ | ||||
| import Redis from 'ioredis'; | ||||
| import { loadConfig } from './built/config.js'; | ||||
| import { createRedisConnection } from './built/redis.js'; | ||||
|  | ||||
| const config = loadConfig(); | ||||
| const redis = createRedisConnection(config); | ||||
| const redis = new Redis({ | ||||
| 	port: config.redis.port, | ||||
| 	host: config.redis.host, | ||||
| 	family: config.redis.family == null ? 0 : config.redis.family, | ||||
| 	password: config.redis.pass, | ||||
| 	keyPrefix: `${config.redis.prefix}:`, | ||||
| 	db: config.redis.db ?? 0, | ||||
| }); | ||||
|  | ||||
| redis.on('connect', () => redis.disconnect()); | ||||
| redis.on('error', (e) => { | ||||
|   | ||||
| @@ -0,0 +1,16 @@ | ||||
|  | ||||
| export class addRenoteMuting1665091090561 { | ||||
| 	constructor() { | ||||
| 		this.name = 'addRenoteMuting1665091090561'; | ||||
| 	} | ||||
|  | ||||
| 	async up(queryRunner) { | ||||
| 		await queryRunner.query(`CREATE TABLE "renote_muting" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "muteeId" character varying(32) NOT NULL, "muterId" character varying(32) NOT NULL, CONSTRAINT "PK_renoteMuting_id" PRIMARY KEY ("id"))`); | ||||
| 		await queryRunner.query(`CREATE INDEX "IDX_renote_muting_createdAt" ON "muting" ("createdAt") `); | ||||
| 		await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muteeId" ON "muting" ("muteeId") `); | ||||
| 		await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muterId" ON "muting" ("muterId") `); | ||||
| 	} | ||||
|  | ||||
| 	async down(queryRunner) { | ||||
| 	} | ||||
| } | ||||
| @@ -0,0 +1,15 @@ | ||||
| export class fixforeignkeyreports1675053125067 { | ||||
|     name = 'fixforeignkeyreports1675053125067' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_a9021cc2e1feb5f72d3db6e9f5" ON "abuse_user_report" ("targetUserId")`); | ||||
|         await queryRunner.query(`DELETE FROM "abuse_user_report" WHERE "targetUserId" NOT IN (SELECT "id" FROM "user")`); | ||||
|         await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT IF EXISTS "FK_a9021cc2e1feb5f72d3db6e9f5f"`); | ||||
|         await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`DROP INDEX "public"."IDX_a9021cc2e1feb5f72d3db6e9f5"`); | ||||
|         await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f"`); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| export class perNoteReactionAcceptance1678164627293 { | ||||
|     name = 'perNoteReactionAcceptance1678164627293' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "note" ADD "reactionAcceptance" character varying(64)`); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "reactionAcceptance"`); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,68 @@ | ||||
| export class tweakVarcharLength1678426061773 { | ||||
| 		name = 'tweakVarcharLength1678426061773' | ||||
|  | ||||
| 		async up(queryRunner) { | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "name" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "maintainerName" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "maintainerEmail" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "langs" TYPE character varying(1024) array`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "pinnedUsers" TYPE character varying(1024) array`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "hiddenTags" TYPE character varying(1024) array`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "blockedHosts" TYPE character varying(1024) array`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "themeColor" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "mascotImageUrl" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "bannerUrl" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "backgroundImageUrl" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "logoImageUrl" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "errorImageUrl" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "iconUrl" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "hcaptchaSiteKey" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "hcaptchaSecretKey" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "recaptchaSiteKey" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "recaptchaSecretKey" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "turnstileSiteKey" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "turnstileSecretKey" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "summalyProxy" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "email" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "smtpHost" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "smtpUser" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "smtpPass" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "swPublicKey" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "swPrivateKey" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "deeplAuthKey" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" RENAME COLUMN "ToSUrl" TO "termsOfServiceUrl"`); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "termsOfServiceUrl" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "repositoryUrl" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "feedbackUrl" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageBucket" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStoragePrefix" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageBaseUrl" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageEndpoint" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageRegion" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageAccessKey" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageSecretKey" TYPE character varying(1024)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "script" TYPE character varying(65536)`, undefined); | ||||
| 			await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___readWrite" TYPE integer`); | ||||
| 			await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___read" TYPE integer`); | ||||
| 			await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___write" TYPE integer`); | ||||
| 			await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredWithinWeek" TYPE integer`); | ||||
| 			await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredWithinMonth" TYPE integer`); | ||||
| 			await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredWithinYear" TYPE integer`); | ||||
| 			await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredOutsideWeek" TYPE integer`); | ||||
| 			await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredOutsideMonth" TYPE integer`); | ||||
| 			await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredOutsideYear" TYPE integer`); | ||||
| 			await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___readWrite" TYPE integer`); | ||||
| 			await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___read" TYPE integer`); | ||||
| 			await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___write" TYPE integer`); | ||||
| 			await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredWithinWeek" TYPE integer`); | ||||
| 			await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredWithinMonth" TYPE integer`); | ||||
| 			await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredWithinYear" TYPE integer`); | ||||
| 			await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredOutsideWeek" TYPE integer`); | ||||
| 			await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredOutsideMonth" TYPE integer`); | ||||
| 			await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredOutsideYear" TYPE integer`); | ||||
| 		} | ||||
|  | ||||
| 		async down(queryRunner) { | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" RENAME COLUMN "termsOfServiceUrl" TO "ToSUrl"`); | ||||
| 		} | ||||
| } | ||||
							
								
								
									
										13
									
								
								packages/backend/migration/1678427401214-remove-unused.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								packages/backend/migration/1678427401214-remove-unused.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| export class removeUnused1678427401214 { | ||||
|     name = 'removeUnused1678427401214' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "pinnedPages"`); | ||||
|         await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "pinnedClipId"`); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "meta" ADD "pinnedClipId" character varying(32)`); | ||||
|         await queryRunner.query(`ALTER TABLE "meta" ADD "pinnedPages" character varying(512) array NOT NULL DEFAULT '{/featured,/channels,/explore,/pages,/about-misskey}'`); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| export class roleDisplayOrder1678602320354 { | ||||
|     name = 'roleDisplayOrder1678602320354' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "role" ADD "displayOrder" integer NOT NULL DEFAULT '0'`); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "displayOrder"`); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										11
									
								
								packages/backend/migration/1678694614599-sensitive-words.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								packages/backend/migration/1678694614599-sensitive-words.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| export class sensitiveWords1678694614599 { | ||||
|     name = 'sensitiveWords1678694614599' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveWords" character varying(1024) array NOT NULL DEFAULT '{}'`); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveWords"`); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,14 @@ | ||||
| export class retentionDateKey1678869617549 { | ||||
|     name = 'retentionDateKey1678869617549' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
| 			await queryRunner.query(`TRUNCATE TABLE "retention_aggregation"`, undefined); | ||||
|         await queryRunner.query(`ALTER TABLE "retention_aggregation" ADD "dateKey" character varying(512) NOT NULL`); | ||||
|         await queryRunner.query(`CREATE UNIQUE INDEX "IDX_f7c3576b37bd2eec966ae24477" ON "retention_aggregation" ("dateKey") `); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`DROP INDEX "public"."IDX_f7c3576b37bd2eec966ae24477"`); | ||||
|         await queryRunner.query(`ALTER TABLE "retention_aggregation" DROP COLUMN "dateKey"`); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| export class addPropsForCustomEmoji1678945242650 { | ||||
|     name = 'addPropsForCustomEmoji1678945242650' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "emoji" ADD "license" character varying(1024)`); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "license"`); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										23
									
								
								packages/backend/migration/1678953978856-clip-favorite.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								packages/backend/migration/1678953978856-clip-favorite.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| export class clipFavorite1678953978856 { | ||||
|     name = 'clipFavorite1678953978856' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`CREATE TABLE "clip_favorite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "clipId" character varying(32) NOT NULL, CONSTRAINT "PK_1b539f43906f05ebcabe752a977" PRIMARY KEY ("id"))`); | ||||
|         await queryRunner.query(`CREATE INDEX "IDX_25a31662b0b0cc9af6549a9d71" ON "clip_favorite" ("userId") `); | ||||
|         await queryRunner.query(`CREATE UNIQUE INDEX "IDX_b1754a39d0b281e07ed7c078ec" ON "clip_favorite" ("userId", "clipId") `); | ||||
|         await queryRunner.query(`ALTER TABLE "clip" ADD "lastClippedAt" TIMESTAMP WITH TIME ZONE`); | ||||
|         await queryRunner.query(`CREATE INDEX "IDX_a3eac04ae2aa9e221e7596114a" ON "clip" ("lastClippedAt") `); | ||||
|         await queryRunner.query(`ALTER TABLE "clip_favorite" ADD CONSTRAINT "FK_25a31662b0b0cc9af6549a9d711" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); | ||||
|         await queryRunner.query(`ALTER TABLE "clip_favorite" ADD CONSTRAINT "FK_fce61c7986cee54393e79f1d849" FOREIGN KEY ("clipId") REFERENCES "clip"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "clip_favorite" DROP CONSTRAINT "FK_fce61c7986cee54393e79f1d849"`); | ||||
|         await queryRunner.query(`ALTER TABLE "clip_favorite" DROP CONSTRAINT "FK_25a31662b0b0cc9af6549a9d711"`); | ||||
|         await queryRunner.query(`DROP INDEX "public"."IDX_a3eac04ae2aa9e221e7596114a"`); | ||||
|         await queryRunner.query(`ALTER TABLE "clip" DROP COLUMN "lastClippedAt"`); | ||||
|         await queryRunner.query(`DROP INDEX "public"."IDX_b1754a39d0b281e07ed7c078ec"`); | ||||
|         await queryRunner.query(`DROP INDEX "public"."IDX_25a31662b0b0cc9af6549a9d71"`); | ||||
|         await queryRunner.query(`DROP TABLE "clip_favorite"`); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										17
									
								
								packages/backend/migration/1679309757174-antenna-active.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								packages/backend/migration/1679309757174-antenna-active.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| export class antennaActive1679309757174 { | ||||
|     name = 'antennaActive1679309757174' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "antenna" ADD "lastUsedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT 'now'`); | ||||
|         await queryRunner.query(`ALTER TABLE "antenna" ADD "isActive" boolean NOT NULL DEFAULT true`); | ||||
|         await queryRunner.query(`CREATE INDEX "IDX_084c2abb8948ef59a37dce6ac1" ON "antenna" ("lastUsedAt") `); | ||||
|         await queryRunner.query(`CREATE INDEX "IDX_36ef5192a1ce55ed0e40aa4db5" ON "antenna" ("isActive") `); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`DROP INDEX "public"."IDX_36ef5192a1ce55ed0e40aa4db5"`); | ||||
|         await queryRunner.query(`DROP INDEX "public"."IDX_084c2abb8948ef59a37dce6ac1"`); | ||||
|         await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "isActive"`); | ||||
|         await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "lastUsedAt"`); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| export class enableChartsForRemoteUser1679639483253 { | ||||
|     name = 'enableChartsForRemoteUser1679639483253' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "meta" ADD "enableChartsForRemoteUser" boolean NOT NULL DEFAULT true`); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableChartsForRemoteUser"`); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										11
									
								
								packages/backend/migration/1679651580149-cleanup.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								packages/backend/migration/1679651580149-cleanup.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| export class cleanup1679651580149 { | ||||
|     name = 'cleanup1679651580149' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "useStarForReactionFallback"`); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "meta" ADD "useStarForReactionFallback" boolean NOT NULL DEFAULT false`); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| export class enableChartsForFederatedInstances1679652081809 { | ||||
|     name = 'enableChartsForFederatedInstances1679652081809' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" ADD "enableChartsForFederatedInstances" boolean NOT NULL DEFAULT true`); | ||||
| 	} | ||||
|  | ||||
| 	async down(queryRunner) { | ||||
| 			await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableChartsForFederatedInstances"`); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										21
									
								
								packages/backend/migration/1680228513388-channelFavorite.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								packages/backend/migration/1680228513388-channelFavorite.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| export class channelFavorite1680228513388 { | ||||
|     name = 'channelFavorite1680228513388' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`CREATE TABLE "channel_favorite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "channelId" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, CONSTRAINT "PK_59bddfd54d48689a298d41af00c" PRIMARY KEY ("id")); COMMENT ON COLUMN "channel_favorite"."createdAt" IS 'The created date of the ChannelFavorite.'`); | ||||
|         await queryRunner.query(`CREATE INDEX "IDX_735a5544f9249d412255f47f95" ON "channel_favorite" ("createdAt") `); | ||||
|         await queryRunner.query(`CREATE INDEX "IDX_d3ca0db011b75ac2a940a2337d" ON "channel_favorite" ("channelId") `); | ||||
|         await queryRunner.query(`CREATE INDEX "IDX_8302bd27226605ece14842fb25" ON "channel_favorite" ("userId") `); | ||||
|         await queryRunner.query(`ALTER TABLE "channel_favorite" ADD CONSTRAINT "FK_d3ca0db011b75ac2a940a2337d2" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); | ||||
|         await queryRunner.query(`ALTER TABLE "channel_favorite" ADD CONSTRAINT "FK_8302bd27226605ece14842fb25a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "channel_favorite" DROP CONSTRAINT "FK_8302bd27226605ece14842fb25a"`); | ||||
|         await queryRunner.query(`ALTER TABLE "channel_favorite" DROP CONSTRAINT "FK_d3ca0db011b75ac2a940a2337d2"`); | ||||
|         await queryRunner.query(`DROP INDEX "public"."IDX_8302bd27226605ece14842fb25"`); | ||||
|         await queryRunner.query(`DROP INDEX "public"."IDX_d3ca0db011b75ac2a940a2337d"`); | ||||
|         await queryRunner.query(`DROP INDEX "public"."IDX_735a5544f9249d412255f47f95"`); | ||||
|         await queryRunner.query(`DROP TABLE "channel_favorite"`); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| export class channelNotePining1680238118084 { | ||||
|     name = 'channelNotePining1680238118084' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "channel" ADD "pinnedNoteIds" character varying(128) array NOT NULL DEFAULT '{}'`); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "pinnedNoteIds"`); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										10
									
								
								packages/backend/migration/1680491187535-cleanup.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								packages/backend/migration/1680491187535-cleanup.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| export class cleanup1680491187535 { | ||||
|     name = 'cleanup1680491187535' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`DROP TABLE "antenna_note" `); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|     } | ||||
| } | ||||
							
								
								
									
										11
									
								
								packages/backend/migration/1680582195041-cleanup.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								packages/backend/migration/1680582195041-cleanup.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| export class cleanup1680582195041 { | ||||
|     name = 'cleanup1680582195041' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
| 			await queryRunner.query(`DROP TABLE "notification" `); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|          | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,17 @@ | ||||
| export class AvatarUrlAndBannerUrl1680775031481 { | ||||
|     name = 'AvatarUrlAndBannerUrl1680775031481' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "user" ADD "avatarUrl" character varying(512)`); | ||||
|         await queryRunner.query(`ALTER TABLE "user" ADD "bannerUrl" character varying(512)`); | ||||
|         await queryRunner.query(`ALTER TABLE "user" ADD "avatarBlurhash" character varying(128)`); | ||||
|         await queryRunner.query(`ALTER TABLE "user" ADD "bannerBlurhash" character varying(128)`); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerBlurhash"`); | ||||
|         await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarBlurhash"`); | ||||
|         await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerUrl"`); | ||||
|         await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarUrl"`); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										17
									
								
								packages/backend/migration/1680931179228-account-move.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								packages/backend/migration/1680931179228-account-move.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| export class AccountMove1680931179228 { | ||||
|     name = 'AccountMove1680931179228' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "user" ADD "movedToUri" character varying(512)`); | ||||
|         await queryRunner.query(`COMMENT ON COLUMN "user"."movedToUri" IS 'The URI of the new account of the User'`); | ||||
|         await queryRunner.query(`ALTER TABLE "user" ADD "alsoKnownAs" text`); | ||||
|         await queryRunner.query(`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`); | ||||
|         await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "alsoKnownAs"`); | ||||
|         await queryRunner.query(`COMMENT ON COLUMN "user"."movedToUri" IS 'The URI of the new account of the User'`); | ||||
|         await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "movedToUri"`); | ||||
|     } | ||||
| } | ||||
| @@ -22,44 +22,46 @@ | ||||
| 		"test-and-coverage": "pnpm jest-and-coverage" | ||||
| 	}, | ||||
| 	"optionalDependencies": { | ||||
| 		"@swc/core-android-arm64": "^1.3.11", | ||||
| 		"@swc/core-darwin-arm64": "^1.3.36", | ||||
| 		"@swc/core-darwin-x64": "^1.3.36", | ||||
| 		"@swc/core-linux-arm-gnueabihf": "^1.3.36", | ||||
| 		"@swc/core-linux-arm64-gnu": "^1.3.36", | ||||
| 		"@swc/core-linux-arm64-musl": "^1.3.36", | ||||
| 		"@swc/core-linux-x64-gnu": "^1.3.36", | ||||
| 		"@swc/core-linux-x64-musl": "^1.3.36", | ||||
| 		"@swc/core-win32-arm64-msvc": "^1.3.36", | ||||
| 		"@swc/core-win32-ia32-msvc": "^1.3.36", | ||||
| 		"@swc/core-win32-x64-msvc": "^1.3.36", | ||||
| 		"@swc/core-android-arm64": "1.3.11", | ||||
| 		"@swc/core-darwin-arm64": "1.3.46", | ||||
| 		"@swc/core-darwin-x64": "1.3.46", | ||||
| 		"@swc/core-linux-arm-gnueabihf": "1.3.46", | ||||
| 		"@swc/core-linux-arm64-gnu": "1.3.46", | ||||
| 		"@swc/core-linux-arm64-musl": "1.3.46", | ||||
| 		"@swc/core-linux-x64-gnu": "1.3.46", | ||||
| 		"@swc/core-linux-x64-musl": "1.3.46", | ||||
| 		"@swc/core-win32-arm64-msvc": "1.3.46", | ||||
| 		"@swc/core-win32-ia32-msvc": "1.3.46", | ||||
| 		"@swc/core-win32-x64-msvc": "1.3.46", | ||||
| 		"@tensorflow/tfjs": "4.2.0", | ||||
| 		"@tensorflow/tfjs-node": "4.2.0" | ||||
| 	}, | ||||
| 	"dependencies": { | ||||
| 		"@bull-board/api": "4.12.1", | ||||
| 		"@bull-board/fastify": "4.12.1", | ||||
| 		"@bull-board/ui": "4.12.1", | ||||
| 		"@discordapp/twemoji": "14.0.2", | ||||
| 		"@aws-sdk/client-s3": "3.306.0", | ||||
| 		"@aws-sdk/lib-storage": "3.306.0", | ||||
| 		"@aws-sdk/node-http-handler": "3.306.0", | ||||
| 		"@bull-board/api": "5.0.0", | ||||
| 		"@bull-board/fastify": "5.0.0", | ||||
| 		"@bull-board/ui": "5.0.0", | ||||
| 		"@discordapp/twemoji": "14.1.2", | ||||
| 		"@fastify/accepts": "4.1.0", | ||||
| 		"@fastify/cookie": "8.3.0", | ||||
| 		"@fastify/cors": "8.2.0", | ||||
| 		"@fastify/http-proxy": "8.4.0", | ||||
| 		"@fastify/multipart": "7.4.1", | ||||
| 		"@fastify/static": "6.9.0", | ||||
| 		"@fastify/cors": "8.2.1", | ||||
| 		"@fastify/http-proxy": "9.0.0", | ||||
| 		"@fastify/multipart": "7.5.0", | ||||
| 		"@fastify/static": "6.10.0", | ||||
| 		"@fastify/view": "7.4.1", | ||||
| 		"@nestjs/common": "9.3.9", | ||||
| 		"@nestjs/core": "9.3.9", | ||||
| 		"@nestjs/testing": "9.3.9", | ||||
| 		"@nestjs/common": "9.4.0", | ||||
| 		"@nestjs/core": "9.4.0", | ||||
| 		"@nestjs/testing": "9.4.0", | ||||
| 		"@peertube/http-signature": "1.7.0", | ||||
| 		"@sinonjs/fake-timers": "10.0.2", | ||||
| 		"@swc/cli": "0.1.62", | ||||
| 		"@swc/core": "1.3.36", | ||||
| 		"@swc/core": "1.3.46", | ||||
| 		"accepts": "1.3.8", | ||||
| 		"ajv": "8.12.0", | ||||
| 		"archiver": "5.3.1", | ||||
| 		"autwh": "0.1.0", | ||||
| 		"aws-sdk": "2.1318.0", | ||||
| 		"bcryptjs": "2.4.3", | ||||
| 		"blurhash": "2.0.5", | ||||
| 		"bull": "4.10.4", | ||||
| @@ -74,35 +76,35 @@ | ||||
| 		"date-fns": "2.29.3", | ||||
| 		"deep-email-validator": "0.1.21", | ||||
| 		"escape-regexp": "0.0.1", | ||||
| 		"fastify": "4.13.0", | ||||
| 		"fastify": "4.15.0", | ||||
| 		"feed": "4.2.2", | ||||
| 		"file-type": "18.2.1", | ||||
| 		"fluent-ffmpeg": "2.1.2", | ||||
| 		"form-data": "4.0.0", | ||||
| 		"got": "12.5.3", | ||||
| 		"got": "12.6.0", | ||||
| 		"happy-dom": "8.9.0", | ||||
| 		"hpagent": "1.2.0", | ||||
| 		"ioredis": "4.28.5", | ||||
| 		"ip-cidr": "3.1.0", | ||||
| 		"is-svg": "4.3.2", | ||||
| 		"js-yaml": "4.1.0", | ||||
| 		"jsdom": "21.1.0", | ||||
| 		"jsdom": "21.1.1", | ||||
| 		"json5": "2.2.3", | ||||
| 		"jsonld": "8.1.1", | ||||
| 		"jsrsasign": "10.6.1", | ||||
| 		"jsrsasign": "10.7.0", | ||||
| 		"mfm-js": "0.23.3", | ||||
| 		"mime-types": "2.1.35", | ||||
| 		"misskey-js": "0.0.15", | ||||
| 		"misskey-js": "workspace:*", | ||||
| 		"ms": "3.0.0-canary.1", | ||||
| 		"nested-property": "4.0.0", | ||||
| 		"node-fetch": "3.3.0", | ||||
| 		"node-fetch": "3.3.1", | ||||
| 		"nodemailer": "6.9.1", | ||||
| 		"nsfwjs": "2.4.2", | ||||
| 		"oauth": "0.10.0", | ||||
| 		"os-utils": "0.0.14", | ||||
| 		"otpauth": "^9.0.2", | ||||
| 		"otpauth": "9.1.1", | ||||
| 		"parse5": "7.1.2", | ||||
| 		"pg": "8.9.0", | ||||
| 		"pg": "8.10.0", | ||||
| 		"private-ip": "3.0.0", | ||||
| 		"probe-image-size": "7.2.3", | ||||
| 		"promise-limit": "2.7.0", | ||||
| @@ -123,32 +125,33 @@ | ||||
| 		"sanitize-html": "2.10.0", | ||||
| 		"seedrandom": "3.0.5", | ||||
| 		"semver": "7.3.8", | ||||
| 		"sharp": "0.31.3", | ||||
| 		"sharp": "0.32.0", | ||||
| 		"sharp-read-bmp": "github:misskey-dev/sharp-read-bmp", | ||||
| 		"strict-event-emitter-types": "2.0.0", | ||||
| 		"stringz": "2.1.0", | ||||
| 		"summaly": "github:misskey-dev/summaly", | ||||
| 		"systeminformation": "5.17.10", | ||||
| 		"systeminformation": "5.17.12", | ||||
| 		"tinycolor2": "1.6.0", | ||||
| 		"tmp": "0.2.1", | ||||
| 		"tsc-alias": "1.8.2", | ||||
| 		"tsconfig-paths": "4.1.2", | ||||
| 		"tsc-alias": "1.8.5", | ||||
| 		"tsconfig-paths": "4.2.0", | ||||
| 		"twemoji-parser": "14.0.0", | ||||
| 		"typeorm": "0.3.11", | ||||
| 		"typescript": "4.9.5", | ||||
| 		"typeorm": "0.3.13", | ||||
| 		"typescript": "5.0.3", | ||||
| 		"ulid": "2.3.0", | ||||
| 		"unzipper": "0.10.11", | ||||
| 		"uuid": "9.0.0", | ||||
| 		"vary": "1.1.2", | ||||
| 		"web-push": "3.5.0", | ||||
| 		"websocket": "1.0.34", | ||||
| 		"ws": "8.12.1", | ||||
| 		"ws": "8.13.0", | ||||
| 		"xev": "3.0.2" | ||||
| 	}, | ||||
| 	"devDependencies": { | ||||
| 		"@jest/globals": "29.4.3", | ||||
| 		"@jest/globals": "29.5.0", | ||||
| 		"@swc/jest": "0.2.24", | ||||
| 		"@types/accepts": "1.3.5", | ||||
| 		"@types/archiver": "5.3.1", | ||||
| 		"@types/archiver": "5.3.2", | ||||
| 		"@types/bcryptjs": "2.4.2", | ||||
| 		"@types/bull": "4.10.0", | ||||
| 		"@types/cbor": "6.0.0", | ||||
| @@ -157,13 +160,13 @@ | ||||
| 		"@types/escape-regexp": "0.0.1", | ||||
| 		"@types/fluent-ffmpeg": "2.1.21", | ||||
| 		"@types/ioredis": "4.28.10", | ||||
| 		"@types/jest": "29.4.0", | ||||
| 		"@types/jest": "29.5.0", | ||||
| 		"@types/js-yaml": "4.0.5", | ||||
| 		"@types/jsdom": "21.1.0", | ||||
| 		"@types/jsdom": "21.1.1", | ||||
| 		"@types/jsonld": "1.5.8", | ||||
| 		"@types/jsrsasign": "10.5.5", | ||||
| 		"@types/jsrsasign": "10.5.8", | ||||
| 		"@types/mime-types": "2.1.1", | ||||
| 		"@types/node": "18.14.1", | ||||
| 		"@types/node": "18.15.11", | ||||
| 		"@types/node-fetch": "3.0.3", | ||||
| 		"@types/nodemailer": "6.4.7", | ||||
| 		"@types/oauth": "0.9.1", | ||||
| @@ -175,7 +178,7 @@ | ||||
| 		"@types/ratelimiter": "3.4.4", | ||||
| 		"@types/redis": "4.0.11", | ||||
| 		"@types/rename": "1.0.4", | ||||
| 		"@types/sanitize-html": "2.8.0", | ||||
| 		"@types/sanitize-html": "2.9.0", | ||||
| 		"@types/semver": "7.3.13", | ||||
| 		"@types/sharp": "0.31.1", | ||||
| 		"@types/sinonjs__fake-timers": "8.1.2", | ||||
| @@ -187,13 +190,14 @@ | ||||
| 		"@types/web-push": "3.3.2", | ||||
| 		"@types/websocket": "1.0.5", | ||||
| 		"@types/ws": "8.5.4", | ||||
| 		"@typescript-eslint/eslint-plugin": "5.52.0", | ||||
| 		"@typescript-eslint/parser": "5.53.0", | ||||
| 		"@typescript-eslint/eslint-plugin": "5.57.1", | ||||
| 		"@typescript-eslint/parser": "5.57.1", | ||||
| 		"aws-sdk-client-mock": "^2.1.1", | ||||
| 		"cross-env": "7.0.3", | ||||
| 		"eslint": "8.35.0", | ||||
| 		"eslint": "8.37.0", | ||||
| 		"eslint-plugin-import": "2.27.5", | ||||
| 		"execa": "6.1.0", | ||||
| 		"jest": "29.4.3", | ||||
| 		"jest-mock": "29.4.3" | ||||
| 		"jest": "29.5.0", | ||||
| 		"jest-mock": "29.5.0" | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -2,18 +2,15 @@ import { setTimeout } from 'node:timers/promises'; | ||||
| import { Global, Inject, Module } from '@nestjs/common'; | ||||
| import Redis from 'ioredis'; | ||||
| import { DataSource } from 'typeorm'; | ||||
| import { createRedisConnection } from '@/redis.js'; | ||||
| import { DI } from './di-symbols.js'; | ||||
| import { loadConfig } from './config.js'; | ||||
| import { createPostgresDataSource } from './postgres.js'; | ||||
| import { RepositoryModule } from './models/RepositoryModule.js'; | ||||
| import type { Provider, OnApplicationShutdown } from '@nestjs/common'; | ||||
|  | ||||
| const config = loadConfig(); | ||||
|  | ||||
| const $config: Provider = { | ||||
| 	provide: DI.config, | ||||
| 	useValue: config, | ||||
| 	useValue: loadConfig(), | ||||
| }; | ||||
|  | ||||
| const $db: Provider = { | ||||
| @@ -28,18 +25,31 @@ const $db: Provider = { | ||||
| const $redis: Provider = { | ||||
| 	provide: DI.redis, | ||||
| 	useFactory: (config) => { | ||||
| 		const redisClient = createRedisConnection(config); | ||||
| 		return redisClient; | ||||
| 		return new Redis({ | ||||
| 			port: config.redis.port, | ||||
| 			host: config.redis.host, | ||||
| 			family: config.redis.family == null ? 0 : config.redis.family, | ||||
| 			password: config.redis.pass, | ||||
| 			keyPrefix: `${config.redis.prefix}:`, | ||||
| 			db: config.redis.db ?? 0, | ||||
| 		}); | ||||
| 	}, | ||||
| 	inject: [DI.config], | ||||
| }; | ||||
|  | ||||
| const $redisSubscriber: Provider = { | ||||
| 	provide: DI.redisSubscriber, | ||||
| const $redisForPubsub: Provider = { | ||||
| 	provide: DI.redisForPubsub, | ||||
| 	useFactory: (config) => { | ||||
| 		const redisSubscriber = createRedisConnection(config); | ||||
| 		redisSubscriber.subscribe(config.host); | ||||
| 		return redisSubscriber; | ||||
| 		const redis = new Redis({ | ||||
| 			port: config.redisForPubsub.port, | ||||
| 			host: config.redisForPubsub.host, | ||||
| 			family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family, | ||||
| 			password: config.redisForPubsub.pass, | ||||
| 			keyPrefix: `${config.redisForPubsub.prefix}:`, | ||||
| 			db: config.redisForPubsub.db ?? 0, | ||||
| 		}); | ||||
| 		redis.subscribe(config.host); | ||||
| 		return redis; | ||||
| 	}, | ||||
| 	inject: [DI.config], | ||||
| }; | ||||
| @@ -47,14 +57,14 @@ const $redisSubscriber: Provider = { | ||||
| @Global() | ||||
| @Module({ | ||||
| 	imports: [RepositoryModule], | ||||
| 	providers: [$config, $db, $redis, $redisSubscriber], | ||||
| 	exports: [$config, $db, $redis, $redisSubscriber, RepositoryModule], | ||||
| 	providers: [$config, $db, $redis, $redisForPubsub], | ||||
| 	exports: [$config, $db, $redis, $redisForPubsub, RepositoryModule], | ||||
| }) | ||||
| export class GlobalModule implements OnApplicationShutdown { | ||||
| 	constructor( | ||||
| 		@Inject(DI.db) private db: DataSource, | ||||
| 		@Inject(DI.redis) private redisClient: Redis.Redis, | ||||
| 		@Inject(DI.redisSubscriber) private redisSubscriber: Redis.Redis, | ||||
| 		@Inject(DI.redisForPubsub) private redisForPubsub: Redis.Redis, | ||||
| 	) {} | ||||
|  | ||||
| 	async onApplicationShutdown(signal: string): Promise<void> { | ||||
| @@ -69,7 +79,7 @@ export class GlobalModule implements OnApplicationShutdown { | ||||
| 		await Promise.all([ | ||||
| 			this.db.destroy(), | ||||
| 			this.redisClient.disconnect(), | ||||
| 			this.redisSubscriber.disconnect(), | ||||
| 			this.redisForPubsub.disconnect(), | ||||
| 		]); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -25,6 +25,14 @@ export type Source = { | ||||
| 		disableCache?: boolean; | ||||
| 		extra?: { [x: string]: string }; | ||||
| 	}; | ||||
| 	dbReplications?: boolean; | ||||
| 	dbSlaves?: { | ||||
| 		host: string; | ||||
| 		port: number; | ||||
| 		db: string; | ||||
| 		user: string; | ||||
| 		pass: string; | ||||
| 	}[]; | ||||
| 	redis: { | ||||
| 		host: string; | ||||
| 		port: number; | ||||
| @@ -33,6 +41,22 @@ export type Source = { | ||||
| 		db?: number; | ||||
| 		prefix?: string; | ||||
| 	}; | ||||
| 	redisForPubsub?: { | ||||
| 		host: string; | ||||
| 		port: number; | ||||
| 		family?: number; | ||||
| 		pass: string; | ||||
| 		db?: number; | ||||
| 		prefix?: string; | ||||
| 	}; | ||||
| 	redisForJobQueue?: { | ||||
| 		host: string; | ||||
| 		port: number; | ||||
| 		family?: number; | ||||
| 		pass: string; | ||||
| 		db?: number; | ||||
| 		prefix?: string; | ||||
| 	}; | ||||
| 	elasticsearch: { | ||||
| 		host: string; | ||||
| 		port: number; | ||||
| @@ -93,6 +117,8 @@ export type Mixin = { | ||||
| 	mediaProxy: string; | ||||
| 	externalMediaProxyEnabled: boolean; | ||||
| 	videoThumbnailGenerator: string | null; | ||||
| 	redisForPubsub: NonNullable<Source['redisForPubsub']>; | ||||
| 	redisForJobQueue: NonNullable<Source['redisForJobQueue']>; | ||||
| }; | ||||
|  | ||||
| export type Config = Source & Mixin; | ||||
| @@ -153,6 +179,8 @@ export function loadConfig() { | ||||
| 		: null; | ||||
|  | ||||
| 	if (!config.redis.prefix) config.redis.prefix = mixin.host; | ||||
| 	if (config.redisForPubsub == null) config.redisForPubsub = config.redis; | ||||
| 	if (config.redisForJobQueue == null) config.redisForJobQueue = config.redis; | ||||
|  | ||||
| 	return Object.assign(config, mixin); | ||||
| } | ||||
|   | ||||
							
								
								
									
										114
									
								
								packages/backend/src/core/AccountMoveService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								packages/backend/src/core/AccountMoveService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,114 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { IsNull } from 'typeorm'; | ||||
|  | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { LocalUser } from '@/models/entities/User.js'; | ||||
| import { User } from '@/models/entities/User.js'; | ||||
| import type { FollowingsRepository, UsersRepository } from '@/models/index.js'; | ||||
|  | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { UserFollowingService } from '@/core/UserFollowingService.js'; | ||||
| import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; | ||||
| import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { AccountUpdateService } from '@/core/AccountUpdateService.js'; | ||||
| import { RelayService } from '@/core/RelayService.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class AccountMoveService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.followingsRepository) | ||||
| 		private followingsRepository: FollowingsRepository, | ||||
|  | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private apRendererService: ApRendererService, | ||||
| 		private apDeliverManagerService: ApDeliverManagerService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private userFollowingService: UserFollowingService, | ||||
| 		private accountUpdateService: AccountUpdateService, | ||||
| 		private relayService: RelayService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Move a local account to a remote account. | ||||
| 	 * | ||||
| 	 * After delivering Move activity, its local followers unfollow the old account and then follow the new one. | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async moveToRemote(src: LocalUser, dst: User): Promise<unknown> { | ||||
| 		// Make sure that the destination is a remote account. | ||||
| 		if (this.userEntityService.isLocalUser(dst)) throw new Error('move destiantion is not remote'); | ||||
| 		if (!dst.uri) throw new Error('destination uri is empty'); | ||||
|  | ||||
| 		// add movedToUri to indicate that the user has moved | ||||
| 		const update = {} as Partial<User>; | ||||
| 		update.alsoKnownAs = src.alsoKnownAs?.concat([dst.uri]) ?? [dst.uri]; | ||||
| 		update.movedToUri = dst.uri; | ||||
| 		await this.usersRepository.update(src.id, update); | ||||
|  | ||||
| 		const srcPerson = await this.apRendererService.renderPerson(src); | ||||
| 		const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src)); | ||||
| 		await this.apDeliverManagerService.deliverToFollowers(src, updateAct); | ||||
| 		this.relayService.deliverToRelays(src, updateAct); | ||||
|  | ||||
| 		// Deliver Move activity to the followers of the old account | ||||
| 		const moveAct = this.apRendererService.addContext(this.apRendererService.renderMove(src, dst)); | ||||
| 		await this.apDeliverManagerService.deliverToFollowers(src, moveAct); | ||||
|  | ||||
| 		// Publish meUpdated event | ||||
| 		const iObj = await this.userEntityService.pack<true, true>(src.id, src, { detail: true, includeSecrets: true }); | ||||
| 		this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj); | ||||
|  | ||||
| 		// follow the new account and unfollow the old one | ||||
| 		const followings = await this.followingsRepository.find({ | ||||
| 			relations: { | ||||
| 				follower: true, | ||||
| 			}, | ||||
| 			where: { | ||||
| 				followeeId: src.id, | ||||
| 				followerHost: IsNull(), // follower is local | ||||
| 			}, | ||||
| 		}); | ||||
| 		for (const following of followings) { | ||||
| 			if (!following.follower) continue; | ||||
| 			try { | ||||
| 				await this.userFollowingService.follow(following.follower, dst); | ||||
| 				await this.userFollowingService.unfollow(following.follower, src); | ||||
| 			} catch { | ||||
| 				/* empty */ | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return iObj; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Create an alias of an old remote account. | ||||
| 	 * | ||||
| 	 * The user's new profile will be published to the followers. | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async createAlias(me: LocalUser, updates: Partial<User>): Promise<unknown> { | ||||
| 		await this.usersRepository.update(me.id, updates); | ||||
|  | ||||
| 		// Publish meUpdated event | ||||
| 		const iObj = await this.userEntityService.pack<true, true>(me.id, me, { | ||||
| 			detail: true, | ||||
| 			includeSecrets: true, | ||||
| 		}); | ||||
| 		this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj); | ||||
|  | ||||
| 		if (me.isLocked === false) { | ||||
| 			await this.userFollowingService.acceptAllFollowRequests(me); | ||||
| 		} | ||||
|  | ||||
| 		this.accountUpdateService.publishToFollowers(me.id); | ||||
|  | ||||
| 		return iObj; | ||||
| 	} | ||||
| } | ||||
| @@ -29,7 +29,7 @@ export class AccountUpdateService { | ||||
| 	public async publishToFollowers(userId: User['id']) { | ||||
| 		const user = await this.usersRepository.findOneBy({ id: userId }); | ||||
| 		if (user == null) throw new Error('user not found'); | ||||
| 	 | ||||
|  | ||||
| 		// フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 | ||||
| 		if (this.userEntityService.isLocalUser(user)) { | ||||
| 			const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user)); | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import type { UserProfilesRepository, UsersRepository } from '@/models/index.js' | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { CreateNotificationService } from '@/core/CreateNotificationService.js'; | ||||
| import { NotificationService } from '@/core/NotificationService.js'; | ||||
|  | ||||
| export const ACHIEVEMENT_TYPES = [ | ||||
| 	'notes1', | ||||
| @@ -90,7 +90,7 @@ export class AchievementService { | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
|  | ||||
| 		private createNotificationService: CreateNotificationService, | ||||
| 		private notificationService: NotificationService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| @@ -114,7 +114,7 @@ export class AchievementService { | ||||
| 			}], | ||||
| 		}); | ||||
|  | ||||
| 		this.createNotificationService.createNotification(userId, 'achievementEarned', { | ||||
| 		this.notificationService.createNotification(userId, 'achievementEarned', { | ||||
| 			achievement: type, | ||||
| 		}); | ||||
| 	} | ||||
|   | ||||
| @@ -10,9 +10,9 @@ import { isUserRelated } from '@/misc/is-user-related.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { PushNotificationService } from '@/core/PushNotificationService.js'; | ||||
| import * as Acct from '@/misc/acct.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { MutingsRepository, NotesRepository, AntennaNotesRepository, AntennasRepository, UserListJoiningsRepository } from '@/models/index.js'; | ||||
| import type { MutingsRepository, NotesRepository, AntennasRepository, UserListJoiningsRepository } from '@/models/index.js'; | ||||
| import { UtilityService } from '@/core/UtilityService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { StreamMessages } from '@/server/api/stream/types.js'; | ||||
| @@ -24,8 +24,11 @@ export class AntennaService implements OnApplicationShutdown { | ||||
| 	private antennas: Antenna[]; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.redisSubscriber) | ||||
| 		private redisSubscriber: Redis.Redis, | ||||
| 		@Inject(DI.redis) | ||||
| 		private redisClient: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.redisForPubsub) | ||||
| 		private redisForPubsub: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.mutingsRepository) | ||||
| 		private mutingsRepository: MutingsRepository, | ||||
| @@ -33,9 +36,6 @@ export class AntennaService implements OnApplicationShutdown { | ||||
| 		@Inject(DI.notesRepository) | ||||
| 		private notesRepository: NotesRepository, | ||||
|  | ||||
| 		@Inject(DI.antennaNotesRepository) | ||||
| 		private antennaNotesRepository: AntennaNotesRepository, | ||||
|  | ||||
| 		@Inject(DI.antennasRepository) | ||||
| 		private antennasRepository: AntennasRepository, | ||||
|  | ||||
| @@ -52,12 +52,12 @@ export class AntennaService implements OnApplicationShutdown { | ||||
| 		this.antennasFetched = false; | ||||
| 		this.antennas = []; | ||||
|  | ||||
| 		this.redisSubscriber.on('message', this.onRedisMessage); | ||||
| 		this.redisForPubsub.on('message', this.onRedisMessage); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public onApplicationShutdown(signal?: string | undefined) { | ||||
| 		this.redisSubscriber.off('message', this.onRedisMessage); | ||||
| 		this.redisForPubsub.off('message', this.onRedisMessage); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| @@ -71,12 +71,14 @@ export class AntennaService implements OnApplicationShutdown { | ||||
| 					this.antennas.push({ | ||||
| 						...body, | ||||
| 						createdAt: new Date(body.createdAt), | ||||
| 						lastUsedAt: new Date(body.lastUsedAt), | ||||
| 					}); | ||||
| 					break; | ||||
| 				case 'antennaUpdated': | ||||
| 					this.antennas[this.antennas.findIndex(a => a.id === body.id)] = { | ||||
| 						...body, | ||||
| 						createdAt: new Date(body.createdAt), | ||||
| 						lastUsedAt: new Date(body.lastUsedAt), | ||||
| 					}; | ||||
| 					break; | ||||
| 				case 'antennaDeleted': | ||||
| @@ -90,54 +92,13 @@ export class AntennaService implements OnApplicationShutdown { | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise<void> { | ||||
| 		// 通知しない設定になっているか、自分自身の投稿なら既読にする | ||||
| 		const read = !antenna.notify || (antenna.userId === noteUser.id); | ||||
| 	 | ||||
| 		this.antennaNotesRepository.insert({ | ||||
| 			id: this.idService.genId(), | ||||
| 			antennaId: antenna.id, | ||||
| 			noteId: note.id, | ||||
| 			read: read, | ||||
| 		}); | ||||
| 	 | ||||
| 		this.redisClient.xadd( | ||||
| 			`antennaTimeline:${antenna.id}`, | ||||
| 			'MAXLEN', '~', '200', | ||||
| 			`${this.idService.parse(note.id).date.getTime()}-*`, | ||||
| 			'note', note.id); | ||||
| 		 | ||||
| 		this.globalEventService.publishAntennaStream(antenna.id, 'note', note); | ||||
| 	 | ||||
| 		if (!read) { | ||||
| 			const mutings = await this.mutingsRepository.find({ | ||||
| 				where: { | ||||
| 					muterId: antenna.userId, | ||||
| 				}, | ||||
| 				select: ['muteeId'], | ||||
| 			}); | ||||
| 	 | ||||
| 			// Copy | ||||
| 			const _note: Note = { | ||||
| 				...note, | ||||
| 			}; | ||||
| 	 | ||||
| 			if (note.replyId != null) { | ||||
| 				_note.reply = await this.notesRepository.findOneByOrFail({ id: note.replyId }); | ||||
| 			} | ||||
| 			if (note.renoteId != null) { | ||||
| 				_note.renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); | ||||
| 			} | ||||
| 	 | ||||
| 			if (isUserRelated(_note, new Set<string>(mutings.map(x => x.muteeId)))) { | ||||
| 				return; | ||||
| 			} | ||||
| 	 | ||||
| 			// 2秒経っても既読にならなかったら通知 | ||||
| 			setTimeout(async () => { | ||||
| 				const unread = await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false }); | ||||
| 				if (unread) { | ||||
| 					this.globalEventService.publishMainStream(antenna.userId, 'unreadAntenna', antenna); | ||||
| 					this.pushNotificationService.pushNotification(antenna.userId, 'unreadAntennaNote', { | ||||
| 						antenna: { id: antenna.id, name: antenna.name }, | ||||
| 						note: await this.noteEntityService.pack(note), | ||||
| 					}); | ||||
| 				} | ||||
| 			}, 2000); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている | ||||
| @@ -217,7 +178,9 @@ export class AntennaService implements OnApplicationShutdown { | ||||
| 	@bindThis | ||||
| 	public async getAntennas() { | ||||
| 		if (!this.antennasFetched) { | ||||
| 			this.antennas = await this.antennasRepository.find(); | ||||
| 			this.antennas = await this.antennasRepository.findBy({ | ||||
| 				isActive: true, | ||||
| 			}); | ||||
| 			this.antennasFetched = true; | ||||
| 		} | ||||
| 	 | ||||
|   | ||||
							
								
								
									
										172
									
								
								packages/backend/src/core/CacheService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								packages/backend/src/core/CacheService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,172 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import Redis from 'ioredis'; | ||||
| import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, UserProfile, UserProfilesRepository, UsersRepository } from '@/models/index.js'; | ||||
| import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; | ||||
| import type { LocalUser, User } from '@/models/entities/User.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { StreamMessages } from '@/server/api/stream/types.js'; | ||||
| import type { OnApplicationShutdown } from '@nestjs/common'; | ||||
|  | ||||
| @Injectable() | ||||
| export class CacheService implements OnApplicationShutdown { | ||||
| 	public userByIdCache: MemoryKVCache<User>; | ||||
| 	public localUserByNativeTokenCache: MemoryKVCache<LocalUser | null>; | ||||
| 	public localUserByIdCache: MemoryKVCache<LocalUser>; | ||||
| 	public uriPersonCache: MemoryKVCache<User | null>; | ||||
| 	public userProfileCache: RedisKVCache<UserProfile>; | ||||
| 	public userMutingsCache: RedisKVCache<Set<string>>; | ||||
| 	public userBlockingCache: RedisKVCache<Set<string>>; | ||||
| 	public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ | ||||
| 	public renoteMutingsCache: RedisKVCache<Set<string>>; | ||||
| 	public userFollowingsCache: RedisKVCache<Set<string>>; | ||||
| 	public userFollowingChannelsCache: RedisKVCache<Set<string>>; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.redis) | ||||
| 		private redisClient: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.redisForPubsub) | ||||
| 		private redisForPubsub: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
|  | ||||
| 		@Inject(DI.mutingsRepository) | ||||
| 		private mutingsRepository: MutingsRepository, | ||||
|  | ||||
| 		@Inject(DI.blockingsRepository) | ||||
| 		private blockingsRepository: BlockingsRepository, | ||||
|  | ||||
| 		@Inject(DI.renoteMutingsRepository) | ||||
| 		private renoteMutingsRepository: RenoteMutingsRepository, | ||||
|  | ||||
| 		@Inject(DI.followingsRepository) | ||||
| 		private followingsRepository: FollowingsRepository, | ||||
|  | ||||
| 		@Inject(DI.channelFollowingsRepository) | ||||
| 		private channelFollowingsRepository: ChannelFollowingsRepository, | ||||
|  | ||||
| 		private userEntityService: UserEntityService, | ||||
| 	) { | ||||
| 		//this.onMessage = this.onMessage.bind(this); | ||||
|  | ||||
| 		this.userByIdCache = new MemoryKVCache<User>(Infinity); | ||||
| 		this.localUserByNativeTokenCache = new MemoryKVCache<LocalUser | null>(Infinity); | ||||
| 		this.localUserByIdCache = new MemoryKVCache<LocalUser>(Infinity); | ||||
| 		this.uriPersonCache = new MemoryKVCache<User | null>(Infinity); | ||||
|  | ||||
| 		this.userProfileCache = new RedisKVCache<UserProfile>(this.redisClient, 'userProfile', { | ||||
| 			lifetime: 1000 * 60 * 30, // 30m | ||||
| 			memoryCacheLifetime: 1000 * 60, // 1m | ||||
| 			fetcher: (key) => this.userProfilesRepository.findOneByOrFail({ userId: key }), | ||||
| 			toRedisConverter: (value) => JSON.stringify(value), | ||||
| 			fromRedisConverter: (value) => JSON.parse(value), // TODO: date型の考慮 | ||||
| 		}); | ||||
|  | ||||
| 		this.userMutingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userMutings', { | ||||
| 			lifetime: 1000 * 60 * 30, // 30m | ||||
| 			memoryCacheLifetime: 1000 * 60, // 1m | ||||
| 			fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))), | ||||
| 			toRedisConverter: (value) => JSON.stringify(Array.from(value)), | ||||
| 			fromRedisConverter: (value) => new Set(JSON.parse(value)), | ||||
| 		}); | ||||
|  | ||||
| 		this.userBlockingCache = new RedisKVCache<Set<string>>(this.redisClient, 'userBlocking', { | ||||
| 			lifetime: 1000 * 60 * 30, // 30m | ||||
| 			memoryCacheLifetime: 1000 * 60, // 1m | ||||
| 			fetcher: (key) => this.blockingsRepository.find({ where: { blockerId: key }, select: ['blockeeId'] }).then(xs => new Set(xs.map(x => x.blockeeId))), | ||||
| 			toRedisConverter: (value) => JSON.stringify(Array.from(value)), | ||||
| 			fromRedisConverter: (value) => new Set(JSON.parse(value)), | ||||
| 		}); | ||||
|  | ||||
| 		this.userBlockedCache = new RedisKVCache<Set<string>>(this.redisClient, 'userBlocked', { | ||||
| 			lifetime: 1000 * 60 * 30, // 30m | ||||
| 			memoryCacheLifetime: 1000 * 60, // 1m | ||||
| 			fetcher: (key) => this.blockingsRepository.find({ where: { blockeeId: key }, select: ['blockerId'] }).then(xs => new Set(xs.map(x => x.blockerId))), | ||||
| 			toRedisConverter: (value) => JSON.stringify(Array.from(value)), | ||||
| 			fromRedisConverter: (value) => new Set(JSON.parse(value)), | ||||
| 		}); | ||||
|  | ||||
| 		this.renoteMutingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'renoteMutings', { | ||||
| 			lifetime: 1000 * 60 * 30, // 30m | ||||
| 			memoryCacheLifetime: 1000 * 60, // 1m | ||||
| 			fetcher: (key) => this.renoteMutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))), | ||||
| 			toRedisConverter: (value) => JSON.stringify(Array.from(value)), | ||||
| 			fromRedisConverter: (value) => new Set(JSON.parse(value)), | ||||
| 		}); | ||||
|  | ||||
| 		this.userFollowingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowings', { | ||||
| 			lifetime: 1000 * 60 * 30, // 30m | ||||
| 			memoryCacheLifetime: 1000 * 60, // 1m | ||||
| 			fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))), | ||||
| 			toRedisConverter: (value) => JSON.stringify(Array.from(value)), | ||||
| 			fromRedisConverter: (value) => new Set(JSON.parse(value)), | ||||
| 		}); | ||||
|  | ||||
| 		this.userFollowingChannelsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowingChannels', { | ||||
| 			lifetime: 1000 * 60 * 30, // 30m | ||||
| 			memoryCacheLifetime: 1000 * 60, // 1m | ||||
| 			fetcher: (key) => this.channelFollowingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))), | ||||
| 			toRedisConverter: (value) => JSON.stringify(Array.from(value)), | ||||
| 			fromRedisConverter: (value) => new Set(JSON.parse(value)), | ||||
| 		}); | ||||
|  | ||||
| 		this.redisForPubsub.on('message', this.onMessage); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	private async onMessage(_: string, data: string): Promise<void> { | ||||
| 		const obj = JSON.parse(data); | ||||
|  | ||||
| 		if (obj.channel === 'internal') { | ||||
| 			const { type, body } = obj.message as StreamMessages['internal']['payload']; | ||||
| 			switch (type) { | ||||
| 				case 'userChangeSuspendedState': | ||||
| 				case 'remoteUserUpdated': { | ||||
| 					const user = await this.usersRepository.findOneByOrFail({ id: body.id }); | ||||
| 					this.userByIdCache.set(user.id, user); | ||||
| 					for (const [k, v] of this.uriPersonCache.cache.entries()) { | ||||
| 						if (v.value?.id === user.id) { | ||||
| 							this.uriPersonCache.set(k, user); | ||||
| 						} | ||||
| 					} | ||||
| 					if (this.userEntityService.isLocalUser(user)) { | ||||
| 						this.localUserByNativeTokenCache.set(user.token!, user); | ||||
| 						this.localUserByIdCache.set(user.id, user); | ||||
| 					} | ||||
| 					break; | ||||
| 				} | ||||
| 				case 'userTokenRegenerated': { | ||||
| 					const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as LocalUser; | ||||
| 					this.localUserByNativeTokenCache.delete(body.oldToken); | ||||
| 					this.localUserByNativeTokenCache.set(body.newToken, user); | ||||
| 					break; | ||||
| 				} | ||||
| 				case 'follow': { | ||||
| 					const follower = this.userByIdCache.get(body.followerId); | ||||
| 					if (follower) follower.followingCount++; | ||||
| 					const followee = this.userByIdCache.get(body.followeeId); | ||||
| 					if (followee) followee.followersCount++; | ||||
| 					break; | ||||
| 				} | ||||
| 				default: | ||||
| 					break; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public findUserById(userId: User['id']) { | ||||
| 		return this.userByIdCache.fetch(userId, () => this.usersRepository.findOneByOrFail({ id: userId })); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public onApplicationShutdown(signal?: string | undefined) { | ||||
| 		this.redisForPubsub.off('message', this.onMessage); | ||||
| 	} | ||||
| } | ||||
| @@ -1,11 +1,11 @@ | ||||
| import { Module } from '@nestjs/common'; | ||||
| import { AccountMoveService } from './AccountMoveService.js'; | ||||
| import { AccountUpdateService } from './AccountUpdateService.js'; | ||||
| import { AiService } from './AiService.js'; | ||||
| import { AntennaService } from './AntennaService.js'; | ||||
| import { AppLockService } from './AppLockService.js'; | ||||
| import { AchievementService } from './AchievementService.js'; | ||||
| import { CaptchaService } from './CaptchaService.js'; | ||||
| import { CreateNotificationService } from './CreateNotificationService.js'; | ||||
| import { CreateSystemUserService } from './CreateSystemUserService.js'; | ||||
| import { CustomEmojiService } from './CustomEmojiService.js'; | ||||
| import { DeleteAccountService } from './DeleteAccountService.js'; | ||||
| @@ -39,9 +39,9 @@ import { S3Service } from './S3Service.js'; | ||||
| import { SignupService } from './SignupService.js'; | ||||
| import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js'; | ||||
| import { UserBlockingService } from './UserBlockingService.js'; | ||||
| import { UserCacheService } from './UserCacheService.js'; | ||||
| import { CacheService } from './CacheService.js'; | ||||
| import { UserFollowingService } from './UserFollowingService.js'; | ||||
| import { UserKeypairStoreService } from './UserKeypairStoreService.js'; | ||||
| import { UserKeypairService } from './UserKeypairService.js'; | ||||
| import { UserListService } from './UserListService.js'; | ||||
| import { UserMutingService } from './UserMutingService.js'; | ||||
| import { UserSuspendService } from './UserSuspendService.js'; | ||||
| @@ -82,6 +82,7 @@ import { HashtagEntityService } from './entities/HashtagEntityService.js'; | ||||
| import { InstanceEntityService } from './entities/InstanceEntityService.js'; | ||||
| import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js'; | ||||
| import { MutingEntityService } from './entities/MutingEntityService.js'; | ||||
| import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService.js'; | ||||
| import { NoteEntityService } from './entities/NoteEntityService.js'; | ||||
| import { NoteFavoriteEntityService } from './entities/NoteFavoriteEntityService.js'; | ||||
| import { NoteReactionEntityService } from './entities/NoteReactionEntityService.js'; | ||||
| @@ -119,13 +120,13 @@ import type { Provider } from '@nestjs/common'; | ||||
|  | ||||
| //#region 文字列ベースでのinjection用(循環参照対応のため) | ||||
| const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService }; | ||||
| const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService }; | ||||
| const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService }; | ||||
| const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; | ||||
| const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; | ||||
| const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; | ||||
| const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; | ||||
| const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; | ||||
| const $CreateNotificationService: Provider = { provide: 'CreateNotificationService', useExisting: CreateNotificationService }; | ||||
| const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService }; | ||||
| const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService }; | ||||
| const $DeleteAccountService: Provider = { provide: 'DeleteAccountService', useExisting: DeleteAccountService }; | ||||
| @@ -160,9 +161,9 @@ const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service }; | ||||
| const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService }; | ||||
| const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useExisting: TwoFactorAuthenticationService }; | ||||
| const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService }; | ||||
| const $UserCacheService: Provider = { provide: 'UserCacheService', useExisting: UserCacheService }; | ||||
| const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService }; | ||||
| const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService }; | ||||
| const $UserKeypairStoreService: Provider = { provide: 'UserKeypairStoreService', useExisting: UserKeypairStoreService }; | ||||
| const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService }; | ||||
| const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService }; | ||||
| const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService }; | ||||
| const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; | ||||
| @@ -203,6 +204,7 @@ const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useEx | ||||
| const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useExisting: InstanceEntityService }; | ||||
| const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useExisting: ModerationLogEntityService }; | ||||
| const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService }; | ||||
| const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityService', useExisting: RenoteMutingEntityService }; | ||||
| const $NoteEntityService: Provider = { provide: 'NoteEntityService', useExisting: NoteEntityService }; | ||||
| const $NoteFavoriteEntityService: Provider = { provide: 'NoteFavoriteEntityService', useExisting: NoteFavoriteEntityService }; | ||||
| const $NoteReactionEntityService: Provider = { provide: 'NoteReactionEntityService', useExisting: NoteReactionEntityService }; | ||||
| @@ -242,13 +244,13 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 	], | ||||
| 	providers: [ | ||||
| 		LoggerService, | ||||
| 		AccountMoveService, | ||||
| 		AccountUpdateService, | ||||
| 		AiService, | ||||
| 		AntennaService, | ||||
| 		AppLockService, | ||||
| 		AchievementService, | ||||
| 		CaptchaService, | ||||
| 		CreateNotificationService, | ||||
| 		CreateSystemUserService, | ||||
| 		CustomEmojiService, | ||||
| 		DeleteAccountService, | ||||
| @@ -283,9 +285,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		SignupService, | ||||
| 		TwoFactorAuthenticationService, | ||||
| 		UserBlockingService, | ||||
| 		UserCacheService, | ||||
| 		CacheService, | ||||
| 		UserFollowingService, | ||||
| 		UserKeypairStoreService, | ||||
| 		UserKeypairService, | ||||
| 		UserListService, | ||||
| 		UserMutingService, | ||||
| 		UserSuspendService, | ||||
| @@ -325,6 +327,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		InstanceEntityService, | ||||
| 		ModerationLogEntityService, | ||||
| 		MutingEntityService, | ||||
| 		RenoteMutingEntityService, | ||||
| 		NoteEntityService, | ||||
| 		NoteFavoriteEntityService, | ||||
| 		NoteReactionEntityService, | ||||
| @@ -359,13 +362,13 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
|  | ||||
| 		//#region 文字列ベースでのinjection用(循環参照対応のため) | ||||
| 		$LoggerService, | ||||
| 		$AccountMoveService, | ||||
| 		$AccountUpdateService, | ||||
| 		$AiService, | ||||
| 		$AntennaService, | ||||
| 		$AppLockService, | ||||
| 		$AchievementService, | ||||
| 		$CaptchaService, | ||||
| 		$CreateNotificationService, | ||||
| 		$CreateSystemUserService, | ||||
| 		$CustomEmojiService, | ||||
| 		$DeleteAccountService, | ||||
| @@ -400,9 +403,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		$SignupService, | ||||
| 		$TwoFactorAuthenticationService, | ||||
| 		$UserBlockingService, | ||||
| 		$UserCacheService, | ||||
| 		$CacheService, | ||||
| 		$UserFollowingService, | ||||
| 		$UserKeypairStoreService, | ||||
| 		$UserKeypairService, | ||||
| 		$UserListService, | ||||
| 		$UserMutingService, | ||||
| 		$UserSuspendService, | ||||
| @@ -442,6 +445,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		$InstanceEntityService, | ||||
| 		$ModerationLogEntityService, | ||||
| 		$MutingEntityService, | ||||
| 		$RenoteMutingEntityService, | ||||
| 		$NoteEntityService, | ||||
| 		$NoteFavoriteEntityService, | ||||
| 		$NoteReactionEntityService, | ||||
| @@ -477,13 +481,13 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 	exports: [ | ||||
| 		QueueModule, | ||||
| 		LoggerService, | ||||
| 		AccountMoveService, | ||||
| 		AccountUpdateService, | ||||
| 		AiService, | ||||
| 		AntennaService, | ||||
| 		AppLockService, | ||||
| 		AchievementService, | ||||
| 		CaptchaService, | ||||
| 		CreateNotificationService, | ||||
| 		CreateSystemUserService, | ||||
| 		CustomEmojiService, | ||||
| 		DeleteAccountService, | ||||
| @@ -518,9 +522,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		SignupService, | ||||
| 		TwoFactorAuthenticationService, | ||||
| 		UserBlockingService, | ||||
| 		UserCacheService, | ||||
| 		CacheService, | ||||
| 		UserFollowingService, | ||||
| 		UserKeypairStoreService, | ||||
| 		UserKeypairService, | ||||
| 		UserListService, | ||||
| 		UserMutingService, | ||||
| 		UserSuspendService, | ||||
| @@ -559,6 +563,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		InstanceEntityService, | ||||
| 		ModerationLogEntityService, | ||||
| 		MutingEntityService, | ||||
| 		RenoteMutingEntityService, | ||||
| 		NoteEntityService, | ||||
| 		NoteFavoriteEntityService, | ||||
| 		NoteReactionEntityService, | ||||
| @@ -593,13 +598,13 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
|  | ||||
| 		//#region 文字列ベースでのinjection用(循環参照対応のため) | ||||
| 		$LoggerService, | ||||
| 		$AccountMoveService, | ||||
| 		$AccountUpdateService, | ||||
| 		$AiService, | ||||
| 		$AntennaService, | ||||
| 		$AppLockService, | ||||
| 		$AchievementService, | ||||
| 		$CaptchaService, | ||||
| 		$CreateNotificationService, | ||||
| 		$CreateSystemUserService, | ||||
| 		$CustomEmojiService, | ||||
| 		$DeleteAccountService, | ||||
| @@ -634,9 +639,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		$SignupService, | ||||
| 		$TwoFactorAuthenticationService, | ||||
| 		$UserBlockingService, | ||||
| 		$UserCacheService, | ||||
| 		$CacheService, | ||||
| 		$UserFollowingService, | ||||
| 		$UserKeypairStoreService, | ||||
| 		$UserKeypairService, | ||||
| 		$UserListService, | ||||
| 		$UserMutingService, | ||||
| 		$UserSuspendService, | ||||
| @@ -675,6 +680,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		$InstanceEntityService, | ||||
| 		$ModerationLogEntityService, | ||||
| 		$MutingEntityService, | ||||
| 		$RenoteMutingEntityService, | ||||
| 		$NoteEntityService, | ||||
| 		$NoteFavoriteEntityService, | ||||
| 		$NoteReactionEntityService, | ||||
| @@ -708,4 +714,4 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		//#endregion | ||||
| 	], | ||||
| }) | ||||
| export class CoreModule {} | ||||
| export class CoreModule { } | ||||
|   | ||||
| @@ -1,125 +0,0 @@ | ||||
| import { setTimeout } from 'node:timers/promises'; | ||||
| import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; | ||||
| import type { MutingsRepository, NotificationsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { Notification } from '@/models/entities/Notification.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; | ||||
| import { PushNotificationService } from '@/core/PushNotificationService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class CreateNotificationService implements OnApplicationShutdown { | ||||
| 	#shutdownController = new AbortController(); | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
|  | ||||
| 		@Inject(DI.notificationsRepository) | ||||
| 		private notificationsRepository: NotificationsRepository, | ||||
|  | ||||
| 		@Inject(DI.mutingsRepository) | ||||
| 		private mutingsRepository: MutingsRepository, | ||||
|  | ||||
| 		private notificationEntityService: NotificationEntityService, | ||||
| 		private idService: IdService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private pushNotificationService: PushNotificationService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async createNotification( | ||||
| 		notifieeId: User['id'], | ||||
| 		type: Notification['type'], | ||||
| 		data: Partial<Notification>, | ||||
| 	): Promise<Notification | null> { | ||||
| 		if (data.notifierId && (notifieeId === data.notifierId)) { | ||||
| 			return null; | ||||
| 		} | ||||
|  | ||||
| 		const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId }); | ||||
|  | ||||
| 		const isMuted = profile?.mutingNotificationTypes.includes(type); | ||||
|  | ||||
| 		// Create notification | ||||
| 		const notification = await this.notificationsRepository.insert({ | ||||
| 			id: this.idService.genId(), | ||||
| 			createdAt: new Date(), | ||||
| 			notifieeId: notifieeId, | ||||
| 			type: type, | ||||
| 			// 相手がこの通知をミュートしているようなら、既読を予めつけておく | ||||
| 			isRead: isMuted, | ||||
| 			...data, | ||||
| 		} as Partial<Notification>) | ||||
| 			.then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0])); | ||||
|  | ||||
| 		const packed = await this.notificationEntityService.pack(notification, {}); | ||||
|  | ||||
| 		// Publish notification event | ||||
| 		this.globalEventService.publishMainStream(notifieeId, 'notification', packed); | ||||
|  | ||||
| 		// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する | ||||
| 		setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { | ||||
| 			const fresh = await this.notificationsRepository.findOneBy({ id: notification.id }); | ||||
| 			if (fresh == null) return; // 既に削除されているかもしれない | ||||
| 			if (fresh.isRead) return; | ||||
|  | ||||
| 			//#region ただしミュートしているユーザーからの通知なら無視 | ||||
| 			const mutings = await this.mutingsRepository.findBy({ | ||||
| 				muterId: notifieeId, | ||||
| 			}); | ||||
| 			if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) { | ||||
| 				return; | ||||
| 			} | ||||
| 			//#endregion | ||||
|  | ||||
| 			this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); | ||||
| 			this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); | ||||
|  | ||||
| 			if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); | ||||
| 			if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); | ||||
| 		}, () => { /* aborted, ignore it */ }); | ||||
|  | ||||
| 		return notification; | ||||
| 	} | ||||
|  | ||||
| 	// TODO | ||||
| 	//const locales = await import('../../../../locales/index.js'); | ||||
|  | ||||
| 	// TODO: locale ファイルをクライアント用とサーバー用で分けたい | ||||
|  | ||||
| 	@bindThis | ||||
| 	private async emailNotificationFollow(userId: User['id'], follower: User) { | ||||
| 		/* | ||||
| 		const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); | ||||
| 		if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return; | ||||
| 		const locale = locales[userProfile.lang ?? 'ja-JP']; | ||||
| 		const i18n = new I18n(locale); | ||||
| 		// TODO: render user information html | ||||
| 		sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); | ||||
| 		*/ | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	private async emailNotificationReceiveFollowRequest(userId: User['id'], follower: User) { | ||||
| 		/* | ||||
| 		const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); | ||||
| 		if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return; | ||||
| 		const locale = locales[userProfile.lang ?? 'ja-JP']; | ||||
| 		const i18n = new I18n(locale); | ||||
| 		// TODO: render user information html | ||||
| 		sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); | ||||
| 		*/ | ||||
| 	} | ||||
|  | ||||
| 	onApplicationShutdown(signal?: string | undefined): void { | ||||
| 		this.#shutdownController.abort(); | ||||
| 	} | ||||
| } | ||||
| @@ -1,24 +1,28 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DataSource, In, IsNull } from 'typeorm'; | ||||
| import Redis from 'ioredis'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import type { DriveFile } from '@/models/entities/DriveFile.js'; | ||||
| import type { Emoji } from '@/models/entities/Emoji.js'; | ||||
| import type { EmojisRepository, Note } from '@/models/index.js'; | ||||
| import type { EmojisRepository } from '@/models/index.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { Cache } from '@/misc/cache.js'; | ||||
| import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; | ||||
| import { UtilityService } from '@/core/UtilityService.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import { ReactionService } from '@/core/ReactionService.js'; | ||||
| import { query } from '@/misc/prelude/url.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class CustomEmojiService { | ||||
| 	private cache: Cache<Emoji | null>; | ||||
| 	private cache: MemoryKVCache<Emoji | null>; | ||||
| 	public localEmojisCache: RedisSingleCache<Map<string, Emoji>>; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.redis) | ||||
| 		private redisClient: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| @@ -32,9 +36,16 @@ export class CustomEmojiService { | ||||
| 		private idService: IdService, | ||||
| 		private emojiEntityService: EmojiEntityService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private reactionService: ReactionService, | ||||
| 	) { | ||||
| 		this.cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12); | ||||
| 		this.cache = new MemoryKVCache<Emoji | null>(1000 * 60 * 60 * 12); | ||||
|  | ||||
| 		this.localEmojisCache = new RedisSingleCache<Map<string, Emoji>>(this.redisClient, 'localEmojis', { | ||||
| 			lifetime: 1000 * 60 * 30, // 30m | ||||
| 			memoryCacheLifetime: 1000 * 60 * 3, // 3m | ||||
| 			fetcher: () => this.emojisRepository.find({ where: { host: IsNull() } }).then(emojis => new Map(emojis.map(emoji => [emoji.name, emoji]))), | ||||
| 			toRedisConverter: (value) => JSON.stringify(value.values()), | ||||
| 			fromRedisConverter: (value) => new Map(JSON.parse(value).map((x: Emoji) => [x.name, x])), // TODO: Date型の変換 | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| @@ -44,6 +55,7 @@ export class CustomEmojiService { | ||||
| 		category: string | null; | ||||
| 		aliases: string[]; | ||||
| 		host: string | null; | ||||
| 		license: string | null; | ||||
| 	}): Promise<Emoji> { | ||||
| 		const emoji = await this.emojisRepository.insert({ | ||||
| 			id: this.idService.genId(), | ||||
| @@ -55,10 +67,11 @@ export class CustomEmojiService { | ||||
| 			originalUrl: data.driveFile.url, | ||||
| 			publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url, | ||||
| 			type: data.driveFile.webpublicType ?? data.driveFile.type, | ||||
| 			license: data.license, | ||||
| 		}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); | ||||
|  | ||||
| 		if (data.host == null) { | ||||
| 			await this.db.queryResultCache!.remove(['meta_emojis']); | ||||
| 			this.localEmojisCache.refresh(); | ||||
|  | ||||
| 			this.globalEventService.publishBroadcastStream('emojiAdded', { | ||||
| 				emoji: await this.emojiEntityService.packDetailed(emoji.id), | ||||
| @@ -68,6 +81,146 @@ export class CustomEmojiService { | ||||
| 		return emoji; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async update(id: Emoji['id'], data: { | ||||
| 		name?: string; | ||||
| 		category?: string | null; | ||||
| 		aliases?: string[]; | ||||
| 		license?: string | null; | ||||
| 	}): Promise<void> { | ||||
| 		const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); | ||||
| 		const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() }); | ||||
| 		if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists'); | ||||
|  | ||||
| 		await this.emojisRepository.update(emoji.id, { | ||||
| 			updatedAt: new Date(), | ||||
| 			name: data.name, | ||||
| 			category: data.category, | ||||
| 			aliases: data.aliases, | ||||
| 			license: data.license, | ||||
| 		}); | ||||
|  | ||||
| 		this.localEmojisCache.refresh(); | ||||
|  | ||||
| 		const updated = await this.emojiEntityService.packDetailed(emoji.id); | ||||
|  | ||||
| 		if (emoji.name === data.name) { | ||||
| 			this.globalEventService.publishBroadcastStream('emojiUpdated', { | ||||
| 				emojis: [updated], | ||||
| 			}); | ||||
| 		} else { | ||||
| 			this.globalEventService.publishBroadcastStream('emojiDeleted', { | ||||
| 				emojis: [await this.emojiEntityService.packDetailed(emoji)], | ||||
| 			}); | ||||
|  | ||||
| 			this.globalEventService.publishBroadcastStream('emojiAdded', { | ||||
| 				emoji: updated, | ||||
| 			});	 | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async addAliasesBulk(ids: Emoji['id'][], aliases: string[]) { | ||||
| 		const emojis = await this.emojisRepository.findBy({ | ||||
| 			id: In(ids), | ||||
| 		}); | ||||
|  | ||||
| 		for (const emoji of emojis) { | ||||
| 			await this.emojisRepository.update(emoji.id, { | ||||
| 				updatedAt: new Date(), | ||||
| 				aliases: [...new Set(emoji.aliases.concat(aliases))], | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		this.localEmojisCache.refresh(); | ||||
|  | ||||
| 		this.globalEventService.publishBroadcastStream('emojiUpdated', { | ||||
| 			emojis: await this.emojiEntityService.packDetailedMany(ids), | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async setAliasesBulk(ids: Emoji['id'][], aliases: string[]) { | ||||
| 		await this.emojisRepository.update({ | ||||
| 			id: In(ids), | ||||
| 		}, { | ||||
| 			updatedAt: new Date(), | ||||
| 			aliases: aliases, | ||||
| 		}); | ||||
|  | ||||
| 		this.localEmojisCache.refresh(); | ||||
|  | ||||
| 		this.globalEventService.publishBroadcastStream('emojiUpdated', { | ||||
| 			emojis: await this.emojiEntityService.packDetailedMany(ids), | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async removeAliasesBulk(ids: Emoji['id'][], aliases: string[]) { | ||||
| 		const emojis = await this.emojisRepository.findBy({ | ||||
| 			id: In(ids), | ||||
| 		}); | ||||
|  | ||||
| 		for (const emoji of emojis) { | ||||
| 			await this.emojisRepository.update(emoji.id, { | ||||
| 				updatedAt: new Date(), | ||||
| 				aliases: emoji.aliases.filter(x => !aliases.includes(x)), | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		this.localEmojisCache.refresh(); | ||||
| 	 | ||||
| 		this.globalEventService.publishBroadcastStream('emojiUpdated', { | ||||
| 			emojis: await this.emojiEntityService.packDetailedMany(ids), | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async setCategoryBulk(ids: Emoji['id'][], category: string | null) { | ||||
| 		await this.emojisRepository.update({ | ||||
| 			id: In(ids), | ||||
| 		}, { | ||||
| 			updatedAt: new Date(), | ||||
| 			category: category, | ||||
| 		}); | ||||
|  | ||||
| 		this.localEmojisCache.refresh(); | ||||
|  | ||||
| 		this.globalEventService.publishBroadcastStream('emojiUpdated', { | ||||
| 			emojis: await this.emojiEntityService.packDetailedMany(ids), | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async delete(id: Emoji['id']) { | ||||
| 		const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); | ||||
|  | ||||
| 		await this.emojisRepository.delete(emoji.id); | ||||
|  | ||||
| 		this.localEmojisCache.refresh(); | ||||
|  | ||||
| 		this.globalEventService.publishBroadcastStream('emojiDeleted', { | ||||
| 			emojis: [await this.emojiEntityService.packDetailed(emoji)], | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async deleteBulk(ids: Emoji['id'][]) { | ||||
| 		const emojis = await this.emojisRepository.findBy({ | ||||
| 			id: In(ids), | ||||
| 		}); | ||||
|  | ||||
| 		for (const emoji of emojis) { | ||||
| 			await this.emojisRepository.delete(emoji.id); | ||||
| 		} | ||||
|  | ||||
| 		this.localEmojisCache.refresh(); | ||||
|  | ||||
| 		this.globalEventService.publishBroadcastStream('emojiDeleted', { | ||||
| 			emojis: await this.emojiEntityService.packDetailedMany(emojis), | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null { | ||||
| 	// クエリに使うホスト | ||||
| @@ -82,7 +235,7 @@ export class CustomEmojiService { | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	private parseEmojiStr(emojiName: string, noteUserHost: string | null) { | ||||
| 	public parseEmojiStr(emojiName: string, noteUserHost: string | null) { | ||||
| 		const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/); | ||||
| 		if (!match) return { name: null, host: null }; | ||||
|  | ||||
| @@ -141,30 +294,6 @@ export class CustomEmojiService { | ||||
| 		return res; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public aggregateNoteEmojis(notes: Note[]) { | ||||
| 		let emojis: { name: string | null; host: string | null; }[] = []; | ||||
| 		for (const note of notes) { | ||||
| 			emojis = emojis.concat(note.emojis | ||||
| 				.map(e => this.parseEmojiStr(e, note.userHost))); | ||||
| 			if (note.renote) { | ||||
| 				emojis = emojis.concat(note.renote.emojis | ||||
| 					.map(e => this.parseEmojiStr(e, note.renote!.userHost))); | ||||
| 				if (note.renote.user) { | ||||
| 					emojis = emojis.concat(note.renote.user.emojis | ||||
| 						.map(e => this.parseEmojiStr(e, note.renote!.userHost))); | ||||
| 				} | ||||
| 			} | ||||
| 			const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis; | ||||
| 			emojis = emojis.concat(customReactions); | ||||
| 			if (note.user) { | ||||
| 				emojis = emojis.concat(note.user.emojis | ||||
| 					.map(e => this.parseEmojiStr(e, note.userHost))); | ||||
| 			} | ||||
| 		} | ||||
| 		return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[]; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します | ||||
| 	 */ | ||||
|   | ||||
| @@ -36,8 +36,5 @@ export class DeleteAccountService { | ||||
| 		await this.usersRepository.update(user.id, { | ||||
| 			isDeleted: true, | ||||
| 		}); | ||||
| 	 | ||||
| 		// Terminate streaming | ||||
| 		this.globalEventService.publishUserEvent(user.id, 'terminate', {}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import IPCIDR from 'ip-cidr'; | ||||
| import PrivateIp from 'private-ip'; | ||||
| import chalk from 'chalk'; | ||||
| import got, * as Got from 'got'; | ||||
| import { parse } from 'content-disposition'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import { HttpRequestService } from '@/core/HttpRequestService.js'; | ||||
| @@ -32,13 +33,18 @@ export class DownloadService { | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async downloadUrl(url: string, path: string): Promise<void> { | ||||
| 	public async downloadUrl(url: string, path: string): Promise<{ | ||||
| 		filename: string; | ||||
| 	}> { | ||||
| 		this.logger.info(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`); | ||||
|  | ||||
| 		const timeout = 30 * 1000; | ||||
| 		const operationTimeout = 60 * 1000; | ||||
| 		const maxSize = this.config.maxFileSize ?? 262144000; | ||||
|  | ||||
| 		const urlObj = new URL(url); | ||||
| 		let filename = urlObj.pathname.split('/').pop() ?? 'untitled'; | ||||
|  | ||||
| 		const req = got.stream(url, { | ||||
| 			headers: { | ||||
| 				'User-Agent': this.config.userAgent, | ||||
| @@ -77,6 +83,14 @@ export class DownloadService { | ||||
| 					req.destroy(); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			const contentDisposition = res.headers['content-disposition']; | ||||
| 			if (contentDisposition != null) { | ||||
| 				const parsed = parse(contentDisposition); | ||||
| 				if (parsed.parameters.filename) { | ||||
| 					filename = parsed.parameters.filename; | ||||
| 				} | ||||
| 			} | ||||
| 		}).on('downloadProgress', (progress: Got.Progress) => { | ||||
| 			if (progress.transferred > maxSize) { | ||||
| 				this.logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`); | ||||
| @@ -95,6 +109,10 @@ export class DownloadService { | ||||
| 		} | ||||
|  | ||||
| 		this.logger.succ(`Download finished: ${chalk.cyan(url)}`); | ||||
|  | ||||
| 		return { | ||||
| 			filename, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
|   | ||||
| @@ -2,7 +2,9 @@ import * as fs from 'node:fs'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { v4 as uuid } from 'uuid'; | ||||
| import sharp from 'sharp'; | ||||
| import { sharpBmp } from 'sharp-read-bmp'; | ||||
| import { IsNull } from 'typeorm'; | ||||
| import { DeleteObjectCommandInput, PutObjectCommandInput, NoSuchKey } from '@aws-sdk/client-s3'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository } from '@/models/index.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| @@ -33,7 +35,8 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { FileInfoService } from '@/core/FileInfoService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { RoleService } from '@/core/RoleService.js'; | ||||
| import type S3 from 'aws-sdk/clients/s3.js'; | ||||
| import { correctFilename } from '@/misc/correct-filename.js'; | ||||
| import { isMimeImage } from '@/misc/is-mime-image.js'; | ||||
|  | ||||
| type AddFileArgs = { | ||||
| 	/** User who wish to add file */ | ||||
| @@ -78,6 +81,7 @@ type UploadFromUrlArgs = { | ||||
| export class DriveService { | ||||
| 	private registerLogger: Logger; | ||||
| 	private downloaderLogger: Logger; | ||||
| 	private deleteLogger: Logger; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| @@ -115,6 +119,7 @@ export class DriveService { | ||||
| 		const logger = new Logger('drive', 'blue'); | ||||
| 		this.registerLogger = logger.createSubLogger('register', 'yellow'); | ||||
| 		this.downloaderLogger = logger.createSubLogger('downloader'); | ||||
| 		this.deleteLogger = logger.createSubLogger('delete'); | ||||
| 	} | ||||
|  | ||||
| 	/*** | ||||
| @@ -168,7 +173,7 @@ export class DriveService { | ||||
| 			//#region Uploads | ||||
| 			this.registerLogger.info(`uploading original: ${key}`); | ||||
| 			const uploads = [ | ||||
| 				this.upload(key, fs.createReadStream(path), type, name), | ||||
| 				this.upload(key, fs.createReadStream(path), type, ext, name), | ||||
| 			]; | ||||
|  | ||||
| 			if (alts.webpublic) { | ||||
| @@ -176,7 +181,7 @@ export class DriveService { | ||||
| 				webpublicUrl = `${ baseUrl }/${ webpublicKey }`; | ||||
|  | ||||
| 				this.registerLogger.info(`uploading webpublic: ${webpublicKey}`); | ||||
| 				uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name)); | ||||
| 				uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, alts.webpublic.ext, name)); | ||||
| 			} | ||||
|  | ||||
| 			if (alts.thumbnail) { | ||||
| @@ -184,7 +189,7 @@ export class DriveService { | ||||
| 				thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; | ||||
|  | ||||
| 				this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); | ||||
| 				uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type)); | ||||
| 				uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext)); | ||||
| 			} | ||||
|  | ||||
| 			await Promise.all(uploads); | ||||
| @@ -273,8 +278,8 @@ export class DriveService { | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if (!['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/svg+xml'].includes(type)) { | ||||
| 			this.registerLogger.debug('web image and thumbnail not created (not an required file)'); | ||||
| 		if (!isMimeImage(type, 'sharp-convertible-image-with-bmp')) { | ||||
| 			this.registerLogger.debug('web image and thumbnail not created (cannot convert by sharp)'); | ||||
| 			return { | ||||
| 				webpublic: null, | ||||
| 				thumbnail: null, | ||||
| @@ -283,22 +288,16 @@ export class DriveService { | ||||
|  | ||||
| 		let img: sharp.Sharp | null = null; | ||||
| 		let satisfyWebpublic: boolean; | ||||
| 		let isAnimated: boolean; | ||||
|  | ||||
| 		try { | ||||
| 			img = sharp(path); | ||||
| 			img = await sharpBmp(path, type); | ||||
| 			const metadata = await img.metadata(); | ||||
| 			const isAnimated = metadata.pages && metadata.pages > 1; | ||||
|  | ||||
| 			// skip animated | ||||
| 			if (isAnimated) { | ||||
| 				return { | ||||
| 					webpublic: null, | ||||
| 					thumbnail: null, | ||||
| 				}; | ||||
| 			} | ||||
| 			isAnimated = !!(metadata.pages && metadata.pages > 1); | ||||
|  | ||||
| 			satisfyWebpublic = !!( | ||||
| 				type !== 'image/svg+xml' && type !== 'image/webp' && type !== 'image/avif' && | ||||
| 				type !== 'image/svg+xml' && // security reason | ||||
| 				type !== 'image/avif' && // not supported by Mastodon and MS Edge | ||||
| 			!(metadata.exif ?? metadata.iptc ?? metadata.xmp ?? metadata.tifftagPhotoshop) && | ||||
| 			metadata.width && metadata.width <= 2048 && | ||||
| 			metadata.height && metadata.height <= 2048 | ||||
| @@ -314,15 +313,13 @@ export class DriveService { | ||||
| 		// #region webpublic | ||||
| 		let webpublic: IImage | null = null; | ||||
|  | ||||
| 		if (generateWeb && !satisfyWebpublic) { | ||||
| 		if (generateWeb && !satisfyWebpublic && !isAnimated) { | ||||
| 			this.registerLogger.info('creating web image'); | ||||
|  | ||||
| 			try { | ||||
| 				if (['image/jpeg', 'image/webp', 'image/avif'].includes(type)) { | ||||
| 					webpublic = await this.imageProcessingService.convertSharpToJpeg(img, 2048, 2048); | ||||
| 				} else if (['image/png'].includes(type)) { | ||||
| 					webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048); | ||||
| 				} else if (['image/svg+xml'].includes(type)) { | ||||
| 					webpublic = await this.imageProcessingService.convertSharpToWebp(img, 2048, 2048); | ||||
| 				} else if (['image/png', 'image/bmp', 'image/svg+xml'].includes(type)) { | ||||
| 					webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048); | ||||
| 				} else { | ||||
| 					this.registerLogger.debug('web image not created (not an required image)'); | ||||
| @@ -332,6 +329,7 @@ export class DriveService { | ||||
| 			} | ||||
| 		} else { | ||||
| 			if (satisfyWebpublic) this.registerLogger.info('web image not created (original satisfies webpublic)'); | ||||
| 			else if (isAnimated) this.registerLogger.info('web image not created (animated image)'); | ||||
| 			else this.registerLogger.info('web image not created (from remote)'); | ||||
| 		} | ||||
| 		// #endregion webpublic | ||||
| @@ -340,10 +338,10 @@ export class DriveService { | ||||
| 		let thumbnail: IImage | null = null; | ||||
|  | ||||
| 		try { | ||||
| 			if (['image/jpeg', 'image/webp', 'image/avif', 'image/png', 'image/svg+xml'].includes(type)) { | ||||
| 				thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 280); | ||||
| 			if (isAnimated) { | ||||
| 				thumbnail = await this.imageProcessingService.convertSharpToWebp(sharp(path, { animated: true }), 374, 317, { alphaQuality: 70 }); | ||||
| 			} else { | ||||
| 				this.registerLogger.debug('thumbnail not created (not an required file)'); | ||||
| 				thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 422); | ||||
| 			} | ||||
| 		} catch (err) { | ||||
| 			this.registerLogger.warn('thumbnail not created (an error occured)', err as Error); | ||||
| @@ -360,7 +358,7 @@ export class DriveService { | ||||
| 	 * Upload to ObjectStorage | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) { | ||||
| 	private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, ext?: string | null, filename?: string) { | ||||
| 		if (type === 'image/apng') type = 'image/png'; | ||||
| 		if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream'; | ||||
|  | ||||
| @@ -372,26 +370,26 @@ export class DriveService { | ||||
| 			Body: stream, | ||||
| 			ContentType: type, | ||||
| 			CacheControl: 'max-age=31536000, immutable', | ||||
| 		} as S3.PutObjectRequest; | ||||
| 		} as PutObjectCommandInput; | ||||
|  | ||||
| 		if (filename) params.ContentDisposition = contentDisposition('inline', filename); | ||||
| 		if (filename) params.ContentDisposition = contentDisposition( | ||||
| 			'inline', | ||||
| 			// 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、 | ||||
| 			// 許可されているファイル形式でしか拡張子をつけない | ||||
| 			ext ? correctFilename(filename, ext) : filename, | ||||
| 		); | ||||
| 		if (meta.objectStorageSetPublicRead) params.ACL = 'public-read'; | ||||
|  | ||||
| 		const s3 = this.s3Service.getS3(meta); | ||||
|  | ||||
| 		const upload = s3.upload(params, { | ||||
| 			partSize: s3.endpoint.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024, | ||||
| 		}); | ||||
|  | ||||
| 		await upload.promise() | ||||
| 		await this.s3Service.upload(meta, params) | ||||
| 			.then( | ||||
| 				result => { | ||||
| 					if (result) { | ||||
| 					if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput | ||||
| 						this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`); | ||||
| 					} else { | ||||
| 						this.registerLogger.error(`Upload Result Empty: key = ${key}, filename = ${filename}`); | ||||
| 					} else { // AbortMultipartUploadCommandOutput | ||||
| 						this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`); | ||||
| 					} | ||||
| 				}, | ||||
| 				}) | ||||
| 			.catch( | ||||
| 				err => { | ||||
| 					this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err); | ||||
| 				}, | ||||
| @@ -466,7 +464,12 @@ export class DriveService { | ||||
| 		//} | ||||
|  | ||||
| 		// detect name | ||||
| 		const detectedName = name ?? (info.type.ext ? `untitled.${info.type.ext}` : 'untitled'); | ||||
| 		const detectedName = correctFilename( | ||||
| 			// DriveFile.nameは256文字, validateFileNameは200文字制限であるため、 | ||||
| 			// extを付加してデータベースの文字数制限に当たることはまずない | ||||
| 			(name && this.driveFileEntityService.validateFileName(name)) ? name : 'untitled', | ||||
| 			info.type.ext, | ||||
| 		); | ||||
|  | ||||
| 		if (user && !force) { | ||||
| 		// Check if there is a file with the same hash | ||||
| @@ -522,10 +525,10 @@ export class DriveService { | ||||
| 		}; | ||||
|  | ||||
| 		const properties: { | ||||
| 		width?: number; | ||||
| 		height?: number; | ||||
| 		orientation?: number; | ||||
| 	} = {}; | ||||
| 			width?: number; | ||||
| 			height?: number; | ||||
| 			orientation?: number; | ||||
| 		} = {}; | ||||
|  | ||||
| 		if (info.width) { | ||||
| 			properties['width'] = info.width; | ||||
| @@ -610,17 +613,20 @@ export class DriveService { | ||||
|  | ||||
| 		if (user) { | ||||
| 			this.driveFileEntityService.pack(file, { self: true }).then(packedFile => { | ||||
| 			// Publish driveFileCreated event | ||||
| 				// Publish driveFileCreated event | ||||
| 				this.globalEventService.publishMainStream(user.id, 'driveFileCreated', packedFile); | ||||
| 				this.globalEventService.publishDriveStream(user.id, 'fileCreated', packedFile); | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		// 統計を更新 | ||||
| 		this.driveChart.update(file, true); | ||||
| 		this.perUserDriveChart.update(file, true); | ||||
| 		if (file.userHost !== null) { | ||||
| 			this.instanceChart.updateDrive(file, true); | ||||
| 		if (file.userHost == null) { | ||||
| 			// ローカルユーザーのみ | ||||
| 			this.perUserDriveChart.update(file, true); | ||||
| 		} else { | ||||
| 			if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { | ||||
| 				this.instanceChart.updateDrive(file, true); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return file; | ||||
| @@ -686,7 +692,7 @@ export class DriveService { | ||||
|  | ||||
| 	@bindThis | ||||
| 	private async deletePostProcess(file: DriveFile, isExpired = false) { | ||||
| 	// リモートファイル期限切れ削除後は直リンクにする | ||||
| 		// リモートファイル期限切れ削除後は直リンクにする | ||||
| 		if (isExpired && file.userHost !== null && file.uri != null) { | ||||
| 			this.driveFilesRepository.update(file.id, { | ||||
| 				isLink: true, | ||||
| @@ -703,24 +709,37 @@ export class DriveService { | ||||
| 			this.driveFilesRepository.delete(file.id); | ||||
| 		} | ||||
|  | ||||
| 		// 統計を更新 | ||||
| 		this.driveChart.update(file, false); | ||||
| 		this.perUserDriveChart.update(file, false); | ||||
| 		if (file.userHost !== null) { | ||||
| 			this.instanceChart.updateDrive(file, false); | ||||
| 		if (file.userHost == null) { | ||||
| 			// ローカルユーザーのみ | ||||
| 			this.perUserDriveChart.update(file, false); | ||||
| 		} else { | ||||
| 			if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { | ||||
| 				this.instanceChart.updateDrive(file, false); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async deleteObjectStorageFile(key: string) { | ||||
| 		const meta = await this.metaService.fetch(); | ||||
| 		try { | ||||
| 			const param = { | ||||
| 				Bucket: meta.objectStorageBucket, | ||||
| 				Key: key, | ||||
| 			} as DeleteObjectCommandInput; | ||||
|  | ||||
| 		const s3 = this.s3Service.getS3(meta); | ||||
|  | ||||
| 		await s3.deleteObject({ | ||||
| 			Bucket: meta.objectStorageBucket!, | ||||
| 			Key: key, | ||||
| 		}).promise(); | ||||
| 			await this.s3Service.delete(meta, param); | ||||
| 		} catch (err: any) { | ||||
| 			if (err.name === 'NoSuchKey') { | ||||
| 				this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err as Error); | ||||
| 				return; | ||||
| 			} else { | ||||
| 				throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, { | ||||
| 					cause: err, | ||||
| 				}); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| @@ -736,24 +755,19 @@ export class DriveService { | ||||
| 		requestIp = null, | ||||
| 		requestHeaders = null, | ||||
| 	}: UploadFromUrlArgs): Promise<DriveFile> { | ||||
| 		let name = new URL(url).pathname.split('/').pop() ?? null; | ||||
| 		if (name == null || !this.driveFileEntityService.validateFileName(name)) { | ||||
| 			name = null; | ||||
| 		} | ||||
| 	 | ||||
| 		// If the comment is same as the name, skip comment | ||||
| 		// (image.name is passed in when receiving attachment) | ||||
| 		if (comment !== null && name === comment) { | ||||
| 			comment = null; | ||||
| 		} | ||||
| 	 | ||||
| 		// Create temp file | ||||
| 		const [path, cleanup] = await createTemp(); | ||||
| 	 | ||||
|  | ||||
| 		try { | ||||
| 			// write content at URL to temp file | ||||
| 			await this.downloadService.downloadUrl(url, path); | ||||
| 	 | ||||
| 			const { filename: name } = await this.downloadService.downloadUrl(url, path); | ||||
|  | ||||
| 			// If the comment is same as the name, skip comment | ||||
| 			// (image.name is passed in when receiving attachment) | ||||
| 			if (comment !== null && name === comment) { | ||||
| 				comment = null; | ||||
| 			} | ||||
|  | ||||
| 			const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders }); | ||||
| 			this.downloaderLogger.succ(`Got: ${driveFile.id}`); | ||||
| 			return driveFile!; | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import Redis from 'ioredis'; | ||||
| import type { InstancesRepository } from '@/models/index.js'; | ||||
| import type { Instance } from '@/models/entities/Instance.js'; | ||||
| import { Cache } from '@/misc/cache.js'; | ||||
| import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { UtilityService } from '@/core/UtilityService.js'; | ||||
| @@ -9,23 +10,40 @@ import { bindThis } from '@/decorators.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class FederatedInstanceService { | ||||
| 	private cache: Cache<Instance>; | ||||
| 	public federatedInstanceCache: RedisKVCache<Instance | null>; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.redis) | ||||
| 		private redisClient: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.instancesRepository) | ||||
| 		private instancesRepository: InstancesRepository, | ||||
|  | ||||
| 		private utilityService: UtilityService, | ||||
| 		private idService: IdService, | ||||
| 	) { | ||||
| 		this.cache = new Cache<Instance>(1000 * 60 * 60); | ||||
| 		this.federatedInstanceCache = new RedisKVCache<Instance | null>(this.redisClient, 'federatedInstance', { | ||||
| 			lifetime: 1000 * 60 * 60 * 24, // 24h | ||||
| 			memoryCacheLifetime: 1000 * 60 * 30, // 30m | ||||
| 			fetcher: (key) => this.instancesRepository.findOneBy({ host: key }), | ||||
| 			toRedisConverter: (value) => JSON.stringify(value), | ||||
| 			fromRedisConverter: (value) => { | ||||
| 				const parsed = JSON.parse(value); | ||||
| 				return { | ||||
| 					...parsed, | ||||
| 					firstRetrievedAt: new Date(parsed.firstRetrievedAt), | ||||
| 					latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null, | ||||
| 					infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null, | ||||
| 				}; | ||||
| 			}, | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async fetch(host: string): Promise<Instance> { | ||||
| 		host = this.utilityService.toPuny(host); | ||||
| 	 | ||||
| 		const cached = this.cache.get(host); | ||||
| 		const cached = await this.federatedInstanceCache.get(host); | ||||
| 		if (cached) return cached; | ||||
| 	 | ||||
| 		const index = await this.instancesRepository.findOneBy({ host }); | ||||
| @@ -37,10 +55,10 @@ export class FederatedInstanceService { | ||||
| 				firstRetrievedAt: new Date(), | ||||
| 			}).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0])); | ||||
| 	 | ||||
| 			this.cache.set(host, i); | ||||
| 			this.federatedInstanceCache.set(host, i); | ||||
| 			return i; | ||||
| 		} else { | ||||
| 			this.cache.set(host, index); | ||||
| 			this.federatedInstanceCache.set(host, index); | ||||
| 			return index; | ||||
| 		} | ||||
| 	} | ||||
| @@ -49,10 +67,10 @@ export class FederatedInstanceService { | ||||
| 	public async updateCachePartial(host: string, data: Partial<Instance>): Promise<void> { | ||||
| 		host = this.utilityService.toPuny(host); | ||||
| 	 | ||||
| 		const cached = this.cache.get(host); | ||||
| 		const cached = await this.federatedInstanceCache.get(host); | ||||
| 		if (cached == null) return; | ||||
| 	 | ||||
| 		this.cache.set(host, { | ||||
| 		this.federatedInstanceCache.set(host, { | ||||
| 			...cached, | ||||
| 			...data, | ||||
| 		}); | ||||
|   | ||||
| @@ -14,9 +14,8 @@ import type { | ||||
| 	MainStreamTypes, | ||||
| 	NoteStreamTypes, | ||||
| 	UserListStreamTypes, | ||||
| 	UserStreamTypes, | ||||
| } from '@/server/api/stream/types.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| @@ -49,11 +48,6 @@ export class GlobalEventService { | ||||
| 		this.publish('internal', type, typeof value === 'undefined' ? null : value); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public publishUserEvent<K extends keyof UserStreamTypes>(userId: User['id'], type: K, value?: UserStreamTypes[K]): void { | ||||
| 		this.publish(`user:${userId}`, type, typeof value === 'undefined' ? null : value); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public publishBroadcastStream<K extends keyof BroadcastTypes>(type: K, value?: BroadcastTypes[K]): void { | ||||
| 		this.publish('broadcast', type, typeof value === 'undefined' ? null : value); | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { ulid } from 'ulid'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import { genAid } from '@/misc/id/aid.js'; | ||||
| import { genAid, parseAid } from '@/misc/id/aid.js'; | ||||
| import { genMeid } from '@/misc/id/meid.js'; | ||||
| import { genMeidg } from '@/misc/id/meidg.js'; | ||||
| import { genObjectId } from '@/misc/id/object-id.js'; | ||||
| @@ -32,4 +32,17 @@ export class IdService { | ||||
| 			default: throw new Error('unrecognized id generation method'); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public parse(id: string): { date: Date; } { | ||||
| 		switch (this.method) { | ||||
| 			case 'aid': return parseAid(id); | ||||
| 			// TODO | ||||
| 			//case 'meid': | ||||
| 			//case 'meidg': | ||||
| 			//case 'ulid': | ||||
| 			//case 'objectid': | ||||
| 			default: throw new Error('unrecognized id generation method'); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -15,15 +15,28 @@ export type IImageStream = { | ||||
| 	type: string; | ||||
| }; | ||||
|  | ||||
| export type IImageStreamable = IImage | IImageStream; | ||||
| export type IImageSharp = { | ||||
| 	data: sharp.Sharp; | ||||
| 	ext: string | null; | ||||
| 	type: string; | ||||
| }; | ||||
|  | ||||
| export type IImageStreamable = IImage | IImageStream | IImageSharp; | ||||
|  | ||||
| export const webpDefault: sharp.WebpOptions = { | ||||
| 	quality: 85, | ||||
| 	quality: 77, | ||||
| 	alphaQuality: 95, | ||||
| 	lossless: false, | ||||
| 	nearLossless: false, | ||||
| 	smartSubsample: true, | ||||
| 	mixed: true, | ||||
| 	effort: 2, | ||||
| }; | ||||
|  | ||||
| export const avifDefault: sharp.AvifOptions = { | ||||
| 	quality: 60, | ||||
| 	lossless: false, | ||||
| 	effort: 2, | ||||
| }; | ||||
|  | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| @@ -37,36 +50,6 @@ export class ImageProcessingService { | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Convert to JPEG | ||||
| 	 *   with resize, remove metadata, resolve orientation, stop animation | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async convertToJpeg(path: string, width: number, height: number): Promise<IImage> { | ||||
| 		return this.convertSharpToJpeg(await sharp(path), width, height); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async convertSharpToJpeg(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> { | ||||
| 		const data = await sharp | ||||
| 			.resize(width, height, { | ||||
| 				fit: 'inside', | ||||
| 				withoutEnlargement: true, | ||||
| 			}) | ||||
| 			.rotate() | ||||
| 			.jpeg({ | ||||
| 				quality: 85, | ||||
| 				progressive: true, | ||||
| 			}) | ||||
| 			.toBuffer(); | ||||
|  | ||||
| 		return { | ||||
| 			data, | ||||
| 			ext: 'jpg', | ||||
| 			type: 'image/jpeg', | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Convert to WebP | ||||
| 	 *   with resize, remove metadata, resolve orientation, stop animation | ||||
| @@ -78,29 +61,22 @@ export class ImageProcessingService { | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise<IImage> { | ||||
| 		const data = await sharp | ||||
| 			.resize(width, height, { | ||||
| 				fit: 'inside', | ||||
| 				withoutEnlargement: true, | ||||
| 			}) | ||||
| 			.rotate() | ||||
| 			.webp(options) | ||||
| 			.toBuffer(); | ||||
| 		const result = this.convertSharpToWebpStream(sharp, width, height, options); | ||||
|  | ||||
| 		return { | ||||
| 			data, | ||||
| 			ext: 'webp', | ||||
| 			type: 'image/webp', | ||||
| 			data: await result.data.toBuffer(), | ||||
| 			ext: result.ext, | ||||
| 			type: result.type, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public convertToWebpStream(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream { | ||||
| 	public convertToWebpStream(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageSharp { | ||||
| 		return this.convertSharpToWebpStream(sharp(path), width, height, options); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public convertSharpToWebpStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream { | ||||
| 	public convertSharpToWebpStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageSharp { | ||||
| 		const data = sharp | ||||
| 			.resize(width, height, { | ||||
| 				fit: 'inside', | ||||
| @@ -115,13 +91,56 @@ export class ImageProcessingService { | ||||
| 			type: 'image/webp', | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Convert to Avif | ||||
| 	 *   with resize, remove metadata, resolve orientation, stop animation | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async convertToAvif(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise<IImage> { | ||||
| 		return this.convertSharpToAvif(sharp(path), width, height, options); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async convertSharpToAvif(sharp: sharp.Sharp, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise<IImage> { | ||||
| 		const result = this.convertSharpToAvifStream(sharp, width, height, options); | ||||
|  | ||||
| 		return { | ||||
| 			data: await result.data.toBuffer(), | ||||
| 			ext: result.ext, | ||||
| 			type: result.type, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public convertToAvifStream(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): IImageSharp { | ||||
| 		return this.convertSharpToAvifStream(sharp(path), width, height, options); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public convertSharpToAvifStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.AvifOptions = avifDefault): IImageSharp { | ||||
| 		const data = sharp | ||||
| 			.resize(width, height, { | ||||
| 				fit: 'inside', | ||||
| 				withoutEnlargement: true, | ||||
| 			}) | ||||
| 			.rotate() | ||||
| 			.avif(options); | ||||
|  | ||||
| 		return { | ||||
| 			data, | ||||
| 			ext: 'avif', | ||||
| 			type: 'image/avif', | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Convert to PNG | ||||
| 	 *   with resize, remove metadata, resolve orientation, stop animation | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async convertToPng(path: string, width: number, height: number): Promise<IImage> { | ||||
| 		return this.convertSharpToPng(await sharp(path), width, height); | ||||
| 		return this.convertSharpToPng(sharp(path), width, height); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { IsNull } from 'typeorm'; | ||||
| import type { LocalUser } from '@/models/entities/User.js'; | ||||
| import type { UsersRepository } from '@/models/index.js'; | ||||
| import { Cache } from '@/misc/cache.js'; | ||||
| import { MemorySingleCache } from '@/misc/cache.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| @@ -11,7 +11,7 @@ const ACTOR_USERNAME = 'instance.actor' as const; | ||||
|  | ||||
| @Injectable() | ||||
| export class InstanceActorService { | ||||
| 	private cache: Cache<LocalUser>; | ||||
| 	private cache: MemorySingleCache<LocalUser>; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.usersRepository) | ||||
| @@ -19,12 +19,12 @@ export class InstanceActorService { | ||||
|  | ||||
| 		private createSystemUserService: CreateSystemUserService, | ||||
| 	) { | ||||
| 		this.cache = new Cache<LocalUser>(Infinity); | ||||
| 		this.cache = new MemorySingleCache<LocalUser>(Infinity); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async getInstanceActor(): Promise<LocalUser> { | ||||
| 		const cached = this.cache.get(null); | ||||
| 		const cached = this.cache.get(); | ||||
| 		if (cached) return cached; | ||||
| 	 | ||||
| 		const user = await this.usersRepository.findOneBy({ | ||||
| @@ -33,11 +33,11 @@ export class InstanceActorService { | ||||
| 		}) as LocalUser | undefined; | ||||
| 	 | ||||
| 		if (user) { | ||||
| 			this.cache.set(null, user); | ||||
| 			this.cache.set(user); | ||||
| 			return user; | ||||
| 		} else { | ||||
| 			const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as LocalUser; | ||||
| 			this.cache.set(null, created); | ||||
| 			this.cache.set(created); | ||||
| 			return created; | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -14,8 +14,8 @@ export class MetaService implements OnApplicationShutdown { | ||||
| 	private intervalId: NodeJS.Timer; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.redisSubscriber) | ||||
| 		private redisSubscriber: Redis.Redis, | ||||
| 		@Inject(DI.redisForPubsub) | ||||
| 		private redisForPubsub: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.db) | ||||
| 		private db: DataSource, | ||||
| @@ -33,7 +33,7 @@ export class MetaService implements OnApplicationShutdown { | ||||
| 			}, 1000 * 60 * 5); | ||||
| 		} | ||||
|  | ||||
| 		this.redisSubscriber.on('message', this.onMessage); | ||||
| 		this.redisForPubsub.on('message', this.onMessage); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| @@ -122,6 +122,6 @@ export class MetaService implements OnApplicationShutdown { | ||||
| 	@bindThis | ||||
| 	public onApplicationShutdown(signal?: string | undefined) { | ||||
| 		clearInterval(this.intervalId); | ||||
| 		this.redisSubscriber.off('message', this.onMessage); | ||||
| 		this.redisForPubsub.off('message', this.onMessage); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { setImmediate } from 'node:timers/promises'; | ||||
| import * as mfm from 'mfm-js'; | ||||
| import { In, DataSource } from 'typeorm'; | ||||
| import Redis from 'ioredis'; | ||||
| import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; | ||||
| import { extractMentions } from '@/misc/extract-mentions.js'; | ||||
| import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; | ||||
| @@ -19,7 +20,7 @@ import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js | ||||
| import { checkWordMute } from '@/misc/check-word-mute.js'; | ||||
| import type { Channel } from '@/models/entities/Channel.js'; | ||||
| import { normalizeForSearch } from '@/misc/normalize-for-search.js'; | ||||
| import { Cache } from '@/misc/cache.js'; | ||||
| import { MemorySingleCache } from '@/misc/cache.js'; | ||||
| import type { UserProfile } from '@/models/entities/UserProfile.js'; | ||||
| import { RelayService } from '@/core/RelayService.js'; | ||||
| import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; | ||||
| @@ -30,7 +31,7 @@ import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; | ||||
| import InstanceChart from '@/core/chart/charts/instance.js'; | ||||
| import ActiveUsersChart from '@/core/chart/charts/active-users.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { CreateNotificationService } from '@/core/CreateNotificationService.js'; | ||||
| import { NotificationService } from '@/core/NotificationService.js'; | ||||
| import { WebhookService } from '@/core/WebhookService.js'; | ||||
| import { HashtagService } from '@/core/HashtagService.js'; | ||||
| import { AntennaService } from '@/core/AntennaService.js'; | ||||
| @@ -44,8 +45,9 @@ import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; | ||||
| import { RoleService } from '@/core/RoleService.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
|  | ||||
| const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); | ||||
| const mutedWordsCache = new MemorySingleCache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); | ||||
|  | ||||
| type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; | ||||
|  | ||||
| @@ -59,7 +61,7 @@ class NotificationManager { | ||||
|  | ||||
| 	constructor( | ||||
| 		private mutingsRepository: MutingsRepository, | ||||
| 		private createNotificationService: CreateNotificationService, | ||||
| 		private notificationService: NotificationService, | ||||
| 		notifier: { id: User['id']; }, | ||||
| 		note: Note, | ||||
| 	) { | ||||
| @@ -100,7 +102,7 @@ class NotificationManager { | ||||
|  | ||||
| 			// 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する | ||||
| 			if (!mentioneesMutedUserIds.includes(this.notifier.id)) { | ||||
| 				this.createNotificationService.createNotification(x.target, x.reason, { | ||||
| 				this.notificationService.createNotification(x.target, x.reason, { | ||||
| 					notifierId: this.notifier.id, | ||||
| 					noteId: this.note.id, | ||||
| 				}); | ||||
| @@ -125,6 +127,7 @@ type Option = { | ||||
| 	files?: DriveFile[] | null; | ||||
| 	poll?: IPoll | null; | ||||
| 	localOnly?: boolean | null; | ||||
| 	reactionAcceptance?: Note['reactionAcceptance']; | ||||
| 	cw?: string | null; | ||||
| 	visibility?: string; | ||||
| 	visibleUsers?: MinimumUser[] | null; | ||||
| @@ -148,6 +151,9 @@ export class NoteCreateService implements OnApplicationShutdown { | ||||
| 		@Inject(DI.db) | ||||
| 		private db: DataSource, | ||||
|  | ||||
| 		@Inject(DI.redis) | ||||
| 		private redisClient: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| @@ -181,7 +187,7 @@ export class NoteCreateService implements OnApplicationShutdown { | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private queueService: QueueService, | ||||
| 		private noteReadService: NoteReadService, | ||||
| 		private createNotificationService: CreateNotificationService, | ||||
| 		private notificationService: NotificationService, | ||||
| 		private relayService: RelayService, | ||||
| 		private federatedInstanceService: FederatedInstanceService, | ||||
| 		private hashtagService: HashtagService, | ||||
| @@ -191,11 +197,12 @@ export class NoteCreateService implements OnApplicationShutdown { | ||||
| 		private apDeliverManagerService: ApDeliverManagerService, | ||||
| 		private apRendererService: ApRendererService, | ||||
| 		private roleService: RoleService, | ||||
| 		private metaService: MetaService, | ||||
| 		private notesChart: NotesChart, | ||||
| 		private perUserNotesChart: PerUserNotesChart, | ||||
| 		private activeUsersChart: ActiveUsersChart, | ||||
| 		private instanceChart: InstanceChart, | ||||
| 	) {} | ||||
| 	) { } | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async create(user: { | ||||
| @@ -229,7 +236,9 @@ export class NoteCreateService implements OnApplicationShutdown { | ||||
| 		if (data.channel != null) data.localOnly = true; | ||||
|  | ||||
| 		if (data.visibility === 'public' && data.channel == null) { | ||||
| 			if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { | ||||
| 			if ((data.text != null) && (await this.metaService.fetch()).sensitiveWords.some(w => data.text!.includes(w))) { | ||||
| 				data.visibility = 'home'; | ||||
| 			} else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { | ||||
| 				data.visibility = 'home'; | ||||
| 			} | ||||
| 		} | ||||
| @@ -316,6 +325,14 @@ export class NoteCreateService implements OnApplicationShutdown { | ||||
|  | ||||
| 		const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); | ||||
|  | ||||
| 		if (data.channel) { | ||||
| 			this.redisClient.xadd( | ||||
| 				`channelTimeline:${data.channel.id}`, | ||||
| 				'MAXLEN', '~', '1000', | ||||
| 				`${this.idService.parse(note.id).date.getTime()}-*`, | ||||
| 				'note', note.id); | ||||
| 		} | ||||
|  | ||||
| 		setImmediate('post created', { signal: this.#shutdownController.signal }).then( | ||||
| 			() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!), | ||||
| 			() => { /* aborted, ignore this */ }, | ||||
| @@ -346,6 +363,7 @@ export class NoteCreateService implements OnApplicationShutdown { | ||||
| 			emojis, | ||||
| 			userId: user.id, | ||||
| 			localOnly: data.localOnly!, | ||||
| 			reactionAcceptance: data.reactionAcceptance, | ||||
| 			visibility: data.visibility as any, | ||||
| 			visibleUserIds: data.visibility === 'specified' | ||||
| 				? data.visibleUsers | ||||
| @@ -385,7 +403,7 @@ export class NoteCreateService implements OnApplicationShutdown { | ||||
| 		// 投稿を作成 | ||||
| 		try { | ||||
| 			if (insert.hasPoll) { | ||||
| 			// Start transaction | ||||
| 				// Start transaction | ||||
| 				await this.db.transaction(async transactionalEntityManager => { | ||||
| 					await transactionalEntityManager.insert(Note, insert); | ||||
|  | ||||
| @@ -408,7 +426,7 @@ export class NoteCreateService implements OnApplicationShutdown { | ||||
|  | ||||
| 			return insert; | ||||
| 		} catch (e) { | ||||
| 		// duplicate key error | ||||
| 			// duplicate key error | ||||
| 			if (isDuplicateKeyValueError(e)) { | ||||
| 				const err = new Error('Duplicated note'); | ||||
| 				err.name = 'duplicated'; | ||||
| @@ -429,15 +447,20 @@ export class NoteCreateService implements OnApplicationShutdown { | ||||
| 		createdAt: User['createdAt']; | ||||
| 		isBot: User['isBot']; | ||||
| 	}, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { | ||||
| 		// 統計を更新 | ||||
| 		const meta = await this.metaService.fetch(); | ||||
|  | ||||
| 		this.notesChart.update(note, true); | ||||
| 		this.perUserNotesChart.update(user, note, true); | ||||
| 		if (meta.enableChartsForRemoteUser || (user.host == null)) { | ||||
| 			this.perUserNotesChart.update(user, note, true); | ||||
| 		} | ||||
|  | ||||
| 		// Register host | ||||
| 		if (this.userEntityService.isRemoteUser(user)) { | ||||
| 			this.federatedInstanceService.fetch(user.host).then(i => { | ||||
| 			this.federatedInstanceService.fetch(user.host).then(async i => { | ||||
| 				this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); | ||||
| 				this.instanceChart.updateNote(i.host, note, true); | ||||
| 				if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { | ||||
| 					this.instanceChart.updateNote(i.host, note, true); | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| @@ -450,7 +473,7 @@ export class NoteCreateService implements OnApplicationShutdown { | ||||
| 		this.incNotesCountOfUser(user); | ||||
|  | ||||
| 		// Word mute | ||||
| 		mutedWordsCache.fetch(null, () => this.userProfilesRepository.find({ | ||||
| 		mutedWordsCache.fetch(() => this.userProfilesRepository.find({ | ||||
| 			where: { | ||||
| 				enableWordMute: true, | ||||
| 			}, | ||||
| @@ -479,18 +502,6 @@ export class NoteCreateService implements OnApplicationShutdown { | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		// Channel | ||||
| 		if (note.channelId) { | ||||
| 			this.channelFollowingsRepository.findBy({ followeeId: note.channelId }).then(followings => { | ||||
| 				for (const following of followings) { | ||||
| 					this.noteReadService.insertNoteUnread(following.followerId, note, { | ||||
| 						isSpecified: false, | ||||
| 						isMentioned: false, | ||||
| 					}); | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		if (data.reply) { | ||||
| 			this.saveReply(data.reply, note); | ||||
| 		} | ||||
| @@ -552,7 +563,7 @@ export class NoteCreateService implements OnApplicationShutdown { | ||||
| 				} | ||||
| 			}); | ||||
|  | ||||
| 			const nm = new NotificationManager(this.mutingsRepository, this.createNotificationService, user, note); | ||||
| 			const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note); | ||||
|  | ||||
| 			await this.createMentionedEvents(mentionedUsers, note, nm); | ||||
|  | ||||
|   | ||||
| @@ -16,6 +16,7 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class NoteDeleteService { | ||||
| @@ -39,6 +40,7 @@ export class NoteDeleteService { | ||||
| 		private federatedInstanceService: FederatedInstanceService, | ||||
| 		private apRendererService: ApRendererService, | ||||
| 		private apDeliverManagerService: ApDeliverManagerService, | ||||
| 		private metaService: MetaService, | ||||
| 		private notesChart: NotesChart, | ||||
| 		private perUserNotesChart: PerUserNotesChart, | ||||
| 		private instanceChart: InstanceChart, | ||||
| @@ -95,14 +97,19 @@ export class NoteDeleteService { | ||||
| 			} | ||||
| 			//#endregion | ||||
|  | ||||
| 			// 統計を更新 | ||||
| 			const meta = await this.metaService.fetch(); | ||||
|  | ||||
| 			this.notesChart.update(note, false); | ||||
| 			this.perUserNotesChart.update(user, note, false); | ||||
| 			if (meta.enableChartsForRemoteUser || (user.host == null)) { | ||||
| 				this.perUserNotesChart.update(user, note, false); | ||||
| 			} | ||||
|  | ||||
| 			if (this.userEntityService.isRemoteUser(user)) { | ||||
| 				this.federatedInstanceService.fetch(user.host).then(i => { | ||||
| 				this.federatedInstanceService.fetch(user.host).then(async i => { | ||||
| 					this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); | ||||
| 					this.instanceChart.updateNote(i.host, note, false); | ||||
| 					if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { | ||||
| 						this.instanceChart.updateNote(i.host, note, false); | ||||
| 					} | ||||
| 				}); | ||||
| 			} | ||||
| 		} | ||||
|   | ||||
| @@ -1,28 +1,20 @@ | ||||
| import { setTimeout } from 'node:timers/promises'; | ||||
| import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; | ||||
| import { In, IsNull, Not } from 'typeorm'; | ||||
| import { In } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { Channel } from '@/models/entities/Channel.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { Note } from '@/models/entities/Note.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import type { UsersRepository, NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository, FollowingsRepository, ChannelFollowingsRepository, AntennaNotesRepository } from '@/models/index.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import type { NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository } from '@/models/index.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { NotificationService } from './NotificationService.js'; | ||||
| import { AntennaService } from './AntennaService.js'; | ||||
| import { PushNotificationService } from './PushNotificationService.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class NoteReadService implements OnApplicationShutdown { | ||||
| 	#shutdownController = new AbortController(); | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.noteUnreadsRepository) | ||||
| 		private noteUnreadsRepository: NoteUnreadsRepository, | ||||
|  | ||||
| @@ -32,21 +24,8 @@ export class NoteReadService implements OnApplicationShutdown { | ||||
| 		@Inject(DI.noteThreadMutingsRepository) | ||||
| 		private noteThreadMutingsRepository: NoteThreadMutingsRepository, | ||||
|  | ||||
| 		@Inject(DI.followingsRepository) | ||||
| 		private followingsRepository: FollowingsRepository, | ||||
|  | ||||
| 		@Inject(DI.channelFollowingsRepository) | ||||
| 		private channelFollowingsRepository: ChannelFollowingsRepository, | ||||
|  | ||||
| 		@Inject(DI.antennaNotesRepository) | ||||
| 		private antennaNotesRepository: AntennaNotesRepository, | ||||
|  | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private idService: IdService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private notificationService: NotificationService, | ||||
| 		private antennaService: AntennaService, | ||||
| 		private pushNotificationService: PushNotificationService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| @@ -57,7 +36,6 @@ export class NoteReadService implements OnApplicationShutdown { | ||||
| 		isMentioned: boolean; | ||||
| 	}): Promise<void> { | ||||
| 		//#region ミュートしているなら無視 | ||||
| 		// TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする | ||||
| 		const mute = await this.mutingsRepository.findBy({ | ||||
| 			muterId: userId, | ||||
| 		}); | ||||
| @@ -77,7 +55,6 @@ export class NoteReadService implements OnApplicationShutdown { | ||||
| 			userId: userId, | ||||
| 			isSpecified: params.isSpecified, | ||||
| 			isMentioned: params.isMentioned, | ||||
| 			noteChannelId: note.channelId, | ||||
| 			noteUserId: note.userId, | ||||
| 		}; | ||||
|  | ||||
| @@ -95,9 +72,6 @@ export class NoteReadService implements OnApplicationShutdown { | ||||
| 			if (params.isSpecified) { | ||||
| 				this.globalEventService.publishMainStream(userId, 'unreadSpecifiedNote', note.id); | ||||
| 			} | ||||
| 			if (note.channelId) { | ||||
| 				this.globalEventService.publishMainStream(userId, 'unreadChannel', note.id); | ||||
| 			} | ||||
| 		}, () => { /* aborted, ignore it */ }); | ||||
| 	} | ||||
|  | ||||
| @@ -105,23 +79,9 @@ export class NoteReadService implements OnApplicationShutdown { | ||||
| 	public async read( | ||||
| 		userId: User['id'], | ||||
| 		notes: (Note | Packed<'Note'>)[], | ||||
| 		info?: { | ||||
| 			following: Set<User['id']>; | ||||
| 			followingChannels: Set<Channel['id']>; | ||||
| 		}, | ||||
| 	): Promise<void> { | ||||
| 		const followingChannels = info?.followingChannels ? info.followingChannels : new Set<string>((await this.channelFollowingsRepository.find({ | ||||
| 			where: { | ||||
| 				followerId: userId, | ||||
| 			}, | ||||
| 			select: ['followeeId'], | ||||
| 		})).map(x => x.followeeId)); | ||||
|  | ||||
| 		const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); | ||||
| 		const readMentions: (Note | Packed<'Note'>)[] = []; | ||||
| 		const readSpecifiedNotes: (Note | Packed<'Note'>)[] = []; | ||||
| 		const readChannelNotes: (Note | Packed<'Note'>)[] = []; | ||||
| 		const readAntennaNotes: (Note | Packed<'Note'>)[] = []; | ||||
|  | ||||
| 		for (const note of notes) { | ||||
| 			if (note.mentions && note.mentions.includes(userId)) { | ||||
| @@ -129,25 +89,13 @@ export class NoteReadService implements OnApplicationShutdown { | ||||
| 			} else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) { | ||||
| 				readSpecifiedNotes.push(note); | ||||
| 			} | ||||
|  | ||||
| 			if (note.channelId && followingChannels.has(note.channelId)) { | ||||
| 				readChannelNotes.push(note); | ||||
| 			} | ||||
|  | ||||
| 			if (note.user != null) { // たぶんnullになることは無いはずだけど一応 | ||||
| 				for (const antenna of myAntennas) { | ||||
| 					if (await this.antennaService.checkHitAntenna(antenna, note, note.user)) { | ||||
| 						readAntennaNotes.push(note); | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) { | ||||
| 		if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0)) { | ||||
| 			// Remove the record | ||||
| 			await this.noteUnreadsRepository.delete({ | ||||
| 				userId: userId, | ||||
| 				noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]), | ||||
| 				noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]), | ||||
| 			}); | ||||
|  | ||||
| 			// TODO: ↓まとめてクエリしたい | ||||
| @@ -171,49 +119,6 @@ export class NoteReadService implements OnApplicationShutdown { | ||||
| 					this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes'); | ||||
| 				} | ||||
| 			}); | ||||
| 	 | ||||
| 			this.noteUnreadsRepository.countBy({ | ||||
| 				userId: userId, | ||||
| 				noteChannelId: Not(IsNull()), | ||||
| 			}).then(channelNoteCount => { | ||||
| 				if (channelNoteCount === 0) { | ||||
| 					// 全て既読になったイベントを発行 | ||||
| 					this.globalEventService.publishMainStream(userId, 'readAllChannels'); | ||||
| 				} | ||||
| 			}); | ||||
| 	 | ||||
| 			this.notificationService.readNotificationByQuery(userId, { | ||||
| 				noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]), | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		if (readAntennaNotes.length > 0) { | ||||
| 			await this.antennaNotesRepository.update({ | ||||
| 				antennaId: In(myAntennas.map(a => a.id)), | ||||
| 				noteId: In(readAntennaNotes.map(n => n.id)), | ||||
| 			}, { | ||||
| 				read: true, | ||||
| 			}); | ||||
|  | ||||
| 			// TODO: まとめてクエリしたい | ||||
| 			for (const antenna of myAntennas) { | ||||
| 				const count = await this.antennaNotesRepository.countBy({ | ||||
| 					antennaId: antenna.id, | ||||
| 					read: false, | ||||
| 				}); | ||||
|  | ||||
| 				if (count === 0) { | ||||
| 					this.globalEventService.publishMainStream(userId, 'readAntenna', antenna); | ||||
| 					this.pushNotificationService.pushNotification(userId, 'readAntenna', { antennaId: antenna.id }); | ||||
| 				} | ||||
| 			} | ||||
| 	 | ||||
| 			this.userEntityService.getHasUnreadAntenna(userId).then(unread => { | ||||
| 				if (!unread) { | ||||
| 					this.globalEventService.publishMainStream(userId, 'readAllAntennas'); | ||||
| 					this.pushNotificationService.pushNotification(userId, 'readAllAntennas', undefined); | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -1,70 +1,157 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { setTimeout } from 'node:timers/promises'; | ||||
| import Redis from 'ioredis'; | ||||
| import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; | ||||
| import { In } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { NotificationsRepository } from '@/models/index.js'; | ||||
| import type { MutingsRepository, UserProfile, UserProfilesRepository, UsersRepository } from '@/models/index.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { Notification } from '@/models/entities/Notification.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { GlobalEventService } from './GlobalEventService.js'; | ||||
| import { PushNotificationService } from './PushNotificationService.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { PushNotificationService } from '@/core/PushNotificationService.js'; | ||||
| import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { CacheService } from '@/core/CacheService.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class NotificationService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.notificationsRepository) | ||||
| 		private notificationsRepository: NotificationsRepository, | ||||
| export class NotificationService implements OnApplicationShutdown { | ||||
| 	#shutdownController = new AbortController(); | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.redis) | ||||
| 		private redisClient: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
|  | ||||
| 		@Inject(DI.mutingsRepository) | ||||
| 		private mutingsRepository: MutingsRepository, | ||||
|  | ||||
| 		private notificationEntityService: NotificationEntityService, | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private idService: IdService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private pushNotificationService: PushNotificationService, | ||||
| 		private cacheService: CacheService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async readNotification( | ||||
| 	public async readAllNotification( | ||||
| 		userId: User['id'], | ||||
| 		notificationIds: Notification['id'][], | ||||
| 		force = false, | ||||
| 	) { | ||||
| 		if (notificationIds.length === 0) return; | ||||
| 		const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); | ||||
| 		 | ||||
| 		const latestNotificationIdsRes = await this.redisClient.xrevrange( | ||||
| 			`notificationTimeline:${userId}`, | ||||
| 			'+', | ||||
| 			'-', | ||||
| 			'COUNT', 1); | ||||
| 		const latestNotificationId = latestNotificationIdsRes[0]?.[0]; | ||||
|  | ||||
| 		// Update documents | ||||
| 		const result = await this.notificationsRepository.update({ | ||||
| 			notifieeId: userId, | ||||
| 			id: In(notificationIds), | ||||
| 			isRead: false, | ||||
| 		}, { | ||||
| 			isRead: true, | ||||
| 		}); | ||||
| 		if (latestNotificationId == null) return; | ||||
|  | ||||
| 		if (result.affected === 0) return; | ||||
| 		this.redisClient.set(`latestReadNotification:${userId}`, latestNotificationId); | ||||
|  | ||||
| 		if (!await this.userEntityService.getHasUnreadNotification(userId)) return this.postReadAllNotifications(userId); | ||||
| 		else return this.postReadNotifications(userId, notificationIds); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async readNotificationByQuery( | ||||
| 		userId: User['id'], | ||||
| 		query: Record<string, any>, | ||||
| 	) { | ||||
| 		const notificationIds = await this.notificationsRepository.findBy({ | ||||
| 			...query, | ||||
| 			notifieeId: userId, | ||||
| 			isRead: false, | ||||
| 		}).then(notifications => notifications.map(notification => notification.id)); | ||||
|  | ||||
| 		return this.readNotification(userId, notificationIds); | ||||
| 		if (force || latestReadNotificationId == null || (latestReadNotificationId < latestNotificationId)) { | ||||
| 			return this.postReadAllNotifications(userId); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	private postReadAllNotifications(userId: User['id']) { | ||||
| 		this.globalEventService.publishMainStream(userId, 'readAllNotifications'); | ||||
| 		return this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	private postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) { | ||||
| 		return this.pushNotificationService.pushNotification(userId, 'readNotifications', { notificationIds }); | ||||
| 	public async createNotification( | ||||
| 		notifieeId: User['id'], | ||||
| 		type: Notification['type'], | ||||
| 		data: Partial<Notification>, | ||||
| 	): Promise<Notification | null> { | ||||
| 		const profile = await this.cacheService.userProfileCache.fetch(notifieeId); | ||||
| 		const isMuted = profile.mutingNotificationTypes.includes(type); | ||||
| 		if (isMuted) return null; | ||||
|  | ||||
| 		if (data.notifierId) { | ||||
| 			if (notifieeId === data.notifierId) { | ||||
| 				return null; | ||||
| 			} | ||||
|  | ||||
| 			const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId); | ||||
| 			if (mutings.has(data.notifierId)) { | ||||
| 				return null; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		const notification = { | ||||
| 			id: this.idService.genId(), | ||||
| 			createdAt: new Date(), | ||||
| 			type: type, | ||||
| 			...data, | ||||
| 		} as Notification; | ||||
|  | ||||
| 		const redisIdPromise = this.redisClient.xadd( | ||||
| 			`notificationTimeline:${notifieeId}`, | ||||
| 			'MAXLEN', '~', '300', | ||||
| 			`${this.idService.parse(notification.id).date.getTime()}-*`, | ||||
| 			'data', JSON.stringify(notification)); | ||||
|  | ||||
| 		const packed = await this.notificationEntityService.pack(notification, notifieeId, {}); | ||||
|  | ||||
| 		// Publish notification event | ||||
| 		this.globalEventService.publishMainStream(notifieeId, 'notification', packed); | ||||
|  | ||||
| 		// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する | ||||
| 		setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => { | ||||
| 			const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`); | ||||
| 			if (latestReadNotificationId && (latestReadNotificationId >= await redisIdPromise)) return; | ||||
|  | ||||
| 			this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); | ||||
| 			this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); | ||||
|  | ||||
| 			if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); | ||||
| 			if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); | ||||
| 		}, () => { /* aborted, ignore it */ }); | ||||
|  | ||||
| 		return notification; | ||||
| 	} | ||||
|  | ||||
| 	// TODO | ||||
| 	//const locales = await import('../../../../locales/index.js'); | ||||
|  | ||||
| 	// TODO: locale ファイルをクライアント用とサーバー用で分けたい | ||||
|  | ||||
| 	@bindThis | ||||
| 	private async emailNotificationFollow(userId: User['id'], follower: User) { | ||||
| 		/* | ||||
| 		const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); | ||||
| 		if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return; | ||||
| 		const locale = locales[userProfile.lang ?? 'ja-JP']; | ||||
| 		const i18n = new I18n(locale); | ||||
| 		// TODO: render user information html | ||||
| 		sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); | ||||
| 		*/ | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	private async emailNotificationReceiveFollowRequest(userId: User['id'], follower: User) { | ||||
| 		/* | ||||
| 		const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); | ||||
| 		if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return; | ||||
| 		const locale = locales[userProfile.lang ?? 'ja-JP']; | ||||
| 		const i18n = new I18n(locale); | ||||
| 		// TODO: render user information html | ||||
| 		sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); | ||||
| 		*/ | ||||
| 	} | ||||
|  | ||||
| 	onApplicationShutdown(signal?: string | undefined): void { | ||||
| 		this.#shutdownController.abort(); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; | ||||
| import push from 'web-push'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import type { Packed } from '@/misc/schema'; | ||||
| import type { Packed } from '@/misc/json-schema'; | ||||
| import { getNoteSummary } from '@/misc/get-note-summary.js'; | ||||
| import type { SwSubscriptionsRepository } from '@/models/index.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| @@ -15,10 +15,6 @@ type PushNotificationsTypes = { | ||||
| 		antenna: { id: string, name: string }; | ||||
| 		note: Packed<'Note'>; | ||||
| 	}; | ||||
| 	'readNotifications': { notificationIds: string[] }; | ||||
| 	'readAllNotifications': undefined; | ||||
| 	'readAntenna': { antennaId: string }; | ||||
| 	'readAllAntennas': undefined; | ||||
| }; | ||||
|  | ||||
| // Reduce length because push message servers have character limits | ||||
| @@ -72,14 +68,6 @@ export class PushNotificationService { | ||||
| 		}); | ||||
| 	 | ||||
| 		for (const subscription of subscriptions) { | ||||
| 			// Continue if sendReadMessage is false | ||||
| 			if ([ | ||||
| 				'readNotifications', | ||||
| 				'readAllNotifications', | ||||
| 				'readAntenna', | ||||
| 				'readAllAntennas', | ||||
| 			].includes(type) && !subscription.sendReadMessage) continue; | ||||
|  | ||||
| 			const pushSubscription = { | ||||
| 				endpoint: subscription.endpoint, | ||||
| 				keys: { | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Brackets, ObjectLiteral } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository } from '@/models/index.js'; | ||||
| import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/index.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import type { SelectQueryBuilder } from 'typeorm'; | ||||
|  | ||||
| @@ -29,6 +29,9 @@ export class QueryService { | ||||
|  | ||||
| 		@Inject(DI.mutingsRepository) | ||||
| 		private mutingsRepository: MutingsRepository, | ||||
|  | ||||
| 		@Inject(DI.renoteMutingsRepository) | ||||
| 		private renoteMutingsRepository: RenoteMutingsRepository, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| @@ -269,5 +272,24 @@ export class QueryService { | ||||
| 			q.setParameters({ meId: me.id }); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| 	@bindThis | ||||
| 	public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder<any>, me: { id: User['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()); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -8,13 +8,13 @@ import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, End | ||||
| function q<T>(config: Config, name: string, limitPerSec = -1) { | ||||
| 	return new Bull<T>(name, { | ||||
| 		redis: { | ||||
| 			port: config.redis.port, | ||||
| 			host: config.redis.host, | ||||
| 			family: config.redis.family == null ? 0 : config.redis.family, | ||||
| 			password: config.redis.pass, | ||||
| 			db: config.redis.db ?? 0, | ||||
| 			port: config.redisForJobQueue.port, | ||||
| 			host: config.redisForJobQueue.host, | ||||
| 			family: config.redisForJobQueue.family == null ? 0 : config.redisForJobQueue.family, | ||||
| 			password: config.redisForJobQueue.pass, | ||||
| 			db: config.redisForJobQueue.db ?? 0, | ||||
| 		}, | ||||
| 		prefix: config.redis.prefix ? `${config.redis.prefix}:queue` : 'queue', | ||||
| 		prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue` : 'queue', | ||||
| 		limiter: limitPerSec > 0 ? { | ||||
| 			max: limitPerSec, | ||||
| 			duration: 1000, | ||||
|   | ||||
| @@ -26,7 +26,7 @@ export class QueueService { | ||||
| 	) {} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public deliver(user: ThinUser, content: IActivity | null, to: string | null) { | ||||
| 	public deliver(user: ThinUser, content: IActivity | null, to: string | null, isSharedInbox: boolean) { | ||||
| 		if (content == null) return null; | ||||
| 		if (to == null) return null; | ||||
|  | ||||
| @@ -36,6 +36,7 @@ export class QueueService { | ||||
| 			}, | ||||
| 			content, | ||||
| 			to, | ||||
| 			isSharedInbox, | ||||
| 		}; | ||||
|  | ||||
| 		return this.deliverQueue.add(data, { | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { IsNull } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { EmojisRepository, BlockingsRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/index.js'; | ||||
| import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/index.js'; | ||||
| import { IdentifiableError } from '@/misc/identifiable-error.js'; | ||||
| import type { RemoteUser, User } from '@/models/entities/User.js'; | ||||
| import type { Note } from '@/models/entities/Note.js'; | ||||
| @@ -9,7 +8,7 @@ import { IdService } from '@/core/IdService.js'; | ||||
| import type { NoteReaction } from '@/models/entities/NoteReaction.js'; | ||||
| import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { CreateNotificationService } from '@/core/CreateNotificationService.js'; | ||||
| import { NotificationService } from '@/core/NotificationService.js'; | ||||
| import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js'; | ||||
| import { emojiRegex } from '@/misc/emoji-regex.js'; | ||||
| import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; | ||||
| @@ -20,6 +19,9 @@ import { MetaService } from '@/core/MetaService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { UtilityService } from '@/core/UtilityService.js'; | ||||
| import { UserBlockingService } from '@/core/UserBlockingService.js'; | ||||
| import { CustomEmojiService } from '@/core/CustomEmojiService.js'; | ||||
|  | ||||
| const FALLBACK = '❤'; | ||||
|  | ||||
| const legacies: Record<string, string> = { | ||||
| 	'like': '👍', | ||||
| @@ -58,9 +60,6 @@ export class ReactionService { | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.blockingsRepository) | ||||
| 		private blockingsRepository: BlockingsRepository, | ||||
|  | ||||
| 		@Inject(DI.notesRepository) | ||||
| 		private notesRepository: NotesRepository, | ||||
|  | ||||
| @@ -72,6 +71,7 @@ export class ReactionService { | ||||
|  | ||||
| 		private utilityService: UtilityService, | ||||
| 		private metaService: MetaService, | ||||
| 		private customEmojiService: CustomEmojiService, | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private noteEntityService: NoteEntityService, | ||||
| 		private userBlockingService: UserBlockingService, | ||||
| @@ -79,7 +79,7 @@ export class ReactionService { | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private apRendererService: ApRendererService, | ||||
| 		private apDeliverManagerService: ApDeliverManagerService, | ||||
| 		private createNotificationService: CreateNotificationService, | ||||
| 		private notificationService: NotificationService, | ||||
| 		private perUserReactionsChart: PerUserReactionsChart, | ||||
| 	) { | ||||
| 	} | ||||
| @@ -93,15 +93,18 @@ export class ReactionService { | ||||
| 				throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7'); | ||||
| 			} | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		// check visibility | ||||
| 		if (!await this.noteEntityService.isVisibleForMe(note, user.id)) { | ||||
| 			throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); | ||||
| 		} | ||||
| 	 | ||||
| 		// TODO: cache | ||||
| 		reaction = await this.toDbReaction(reaction, user.host); | ||||
| 	 | ||||
|  | ||||
| 		if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote') && (user.host != null))) { | ||||
| 			reaction = '❤️'; | ||||
| 		} else { | ||||
| 			reaction = await this.toDbReaction(reaction, user.host); | ||||
| 		} | ||||
|  | ||||
| 		const record: NoteReaction = { | ||||
| 			id: this.idService.genId(), | ||||
| 			createdAt: new Date(), | ||||
| @@ -109,7 +112,7 @@ export class ReactionService { | ||||
| 			userId: user.id, | ||||
| 			reaction, | ||||
| 		}; | ||||
| 	 | ||||
|  | ||||
| 		// Create reaction | ||||
| 		try { | ||||
| 			await this.noteReactionsRepository.insert(record); | ||||
| @@ -119,7 +122,7 @@ export class ReactionService { | ||||
| 					noteId: note.id, | ||||
| 					userId: user.id, | ||||
| 				}); | ||||
| 	 | ||||
|  | ||||
| 				if (exists.reaction !== reaction) { | ||||
| 					// 別のリアクションがすでにされていたら置き換える | ||||
| 					await this.delete(user, note); | ||||
| @@ -132,7 +135,7 @@ export class ReactionService { | ||||
| 				throw e; | ||||
| 			} | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		// Increment reactions count | ||||
| 		const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; | ||||
| 		await this.notesRepository.createQueryBuilder().update() | ||||
| @@ -142,39 +145,45 @@ export class ReactionService { | ||||
| 			}) | ||||
| 			.where('id = :id', { id: note.id }) | ||||
| 			.execute(); | ||||
| 	 | ||||
| 		this.perUserReactionsChart.update(user, note); | ||||
| 	 | ||||
|  | ||||
| 		const meta = await this.metaService.fetch(); | ||||
|  | ||||
| 		if (meta.enableChartsForRemoteUser || (user.host == null)) { | ||||
| 			this.perUserReactionsChart.update(user, note); | ||||
| 		} | ||||
|  | ||||
| 		// カスタム絵文字リアクションだったら絵文字情報も送る | ||||
| 		const decodedReaction = this.decodeReaction(reaction); | ||||
| 	 | ||||
| 		const emoji = await this.emojisRepository.findOne({ | ||||
| 			where: { | ||||
| 				name: decodedReaction.name, | ||||
| 				host: decodedReaction.host ?? IsNull(), | ||||
| 			}, | ||||
| 			select: ['name', 'host', 'originalUrl', 'publicUrl'], | ||||
| 		}); | ||||
| 	 | ||||
|  | ||||
| 		const customEmoji = decodedReaction.name == null ? null : decodedReaction.host == null | ||||
| 			? (await this.customEmojiService.localEmojisCache.fetch()).get(decodedReaction.name) | ||||
| 			: await this.emojisRepository.findOne( | ||||
| 				{ | ||||
| 					where: { | ||||
| 						name: decodedReaction.name, | ||||
| 						host: decodedReaction.host, | ||||
| 					}, | ||||
| 				}); | ||||
|  | ||||
| 		this.globalEventService.publishNoteStream(note.id, 'reacted', { | ||||
| 			reaction: decodedReaction.reaction, | ||||
| 			emoji: emoji != null ? { | ||||
| 				name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}@.`, | ||||
| 			emoji: customEmoji != null ? { | ||||
| 				name: customEmoji.host ? `${customEmoji.name}@${customEmoji.host}` : `${customEmoji.name}@.`, | ||||
| 				// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) | ||||
| 				url: emoji.publicUrl || emoji.originalUrl, | ||||
| 				url: customEmoji.publicUrl || customEmoji.originalUrl, | ||||
| 			} : null, | ||||
| 			userId: user.id, | ||||
| 		}); | ||||
| 	 | ||||
|  | ||||
| 		// リアクションされたユーザーがローカルユーザーなら通知を作成 | ||||
| 		if (note.userHost === null) { | ||||
| 			this.createNotificationService.createNotification(note.userId, 'reaction', { | ||||
| 			this.notificationService.createNotification(note.userId, 'reaction', { | ||||
| 				notifierId: user.id, | ||||
| 				noteId: note.id, | ||||
| 				reaction: reaction, | ||||
| 			}); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		//#region 配信 | ||||
| 		if (this.userEntityService.isLocalUser(user) && !note.localOnly) { | ||||
| 			const content = this.apRendererService.addContext(await this.apRendererService.renderLike(record, note)); | ||||
| @@ -183,7 +192,7 @@ export class ReactionService { | ||||
| 				const reactee = await this.usersRepository.findOneBy({ id: note.userId }); | ||||
| 				dm.addDirectRecipe(reactee as RemoteUser); | ||||
| 			} | ||||
| 	 | ||||
|  | ||||
| 			if (['public', 'home', 'followers'].includes(note.visibility)) { | ||||
| 				dm.addFollowersRecipe(); | ||||
| 			} else if (note.visibility === 'specified') { | ||||
| @@ -192,7 +201,7 @@ export class ReactionService { | ||||
| 					dm.addDirectRecipe(u as RemoteUser); | ||||
| 				} | ||||
| 			} | ||||
| 	 | ||||
|  | ||||
| 			dm.execute(); | ||||
| 		} | ||||
| 		//#endregion | ||||
| @@ -205,18 +214,18 @@ export class ReactionService { | ||||
| 			noteId: note.id, | ||||
| 			userId: user.id, | ||||
| 		}); | ||||
| 	 | ||||
|  | ||||
| 		if (exist == null) { | ||||
| 			throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		// Delete reaction | ||||
| 		const result = await this.noteReactionsRepository.delete(exist.id); | ||||
| 	 | ||||
|  | ||||
| 		if (result.affected !== 1) { | ||||
| 			throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		// Decrement reactions count | ||||
| 		const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`; | ||||
| 		await this.notesRepository.createQueryBuilder().update() | ||||
| @@ -225,14 +234,14 @@ export class ReactionService { | ||||
| 			}) | ||||
| 			.where('id = :id', { id: note.id }) | ||||
| 			.execute(); | ||||
| 	 | ||||
|  | ||||
| 		if (!user.isBot) this.notesRepository.decrement({ id: note.id }, 'score', 1); | ||||
| 	 | ||||
|  | ||||
| 		this.globalEventService.publishNoteStream(note.id, 'unreacted', { | ||||
| 			reaction: this.decodeReaction(exist.reaction).reaction, | ||||
| 			userId: user.id, | ||||
| 		}); | ||||
| 	 | ||||
|  | ||||
| 		//#region 配信 | ||||
| 		if (this.userEntityService.isLocalUser(user) && !note.localOnly) { | ||||
| 			const content = this.apRendererService.addContext(this.apRendererService.renderUndo(await this.apRendererService.renderLike(exist, note), user)); | ||||
| @@ -246,12 +255,6 @@ export class ReactionService { | ||||
| 		} | ||||
| 		//#endregion | ||||
| 	} | ||||
| 	 | ||||
| 	@bindThis | ||||
| 	public async getFallbackReaction(): Promise<string> { | ||||
| 		const meta = await this.metaService.fetch(); | ||||
| 		return meta.useStarForReactionFallback ? '⭐' : '👍'; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public convertLegacyReactions(reactions: Record<string, number>) { | ||||
| @@ -286,7 +289,7 @@ export class ReactionService { | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise<string> { | ||||
| 		if (reaction == null) return await this.getFallbackReaction(); | ||||
| 		if (reaction == null) return FALLBACK; | ||||
|  | ||||
| 		reacterHost = this.utilityService.toPunyNullable(reacterHost); | ||||
|  | ||||
| @@ -296,7 +299,7 @@ export class ReactionService { | ||||
| 		// Unicode絵文字 | ||||
| 		const match = emojiRegex.exec(reaction); | ||||
| 		if (match) { | ||||
| 		// 合字を含む1つの絵文字 | ||||
| 			// 合字を含む1つの絵文字 | ||||
| 			const unicode = match[0]; | ||||
|  | ||||
| 			// 異体字セレクタ除去 | ||||
| @@ -306,15 +309,17 @@ export class ReactionService { | ||||
| 		const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/); | ||||
| 		if (custom) { | ||||
| 			const name = custom[1]; | ||||
| 			const emoji = await this.emojisRepository.findOneBy({ | ||||
| 				host: reacterHost ?? IsNull(), | ||||
| 				name, | ||||
| 			}); | ||||
| 			const emoji = reacterHost == null | ||||
| 				? (await this.customEmojiService.localEmojisCache.fetch()).get(name) | ||||
| 				: await this.emojisRepository.findOneBy({ | ||||
| 					host: reacterHost, | ||||
| 					name, | ||||
| 				}); | ||||
|  | ||||
| 			if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`; | ||||
| 		} | ||||
|  | ||||
| 		return await this.getFallbackReaction(); | ||||
| 		return FALLBACK; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import { IsNull } from 'typeorm'; | ||||
| import type { LocalUser, User } from '@/models/entities/User.js'; | ||||
| import type { RelaysRepository, UsersRepository } from '@/models/index.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { Cache } from '@/misc/cache.js'; | ||||
| import { MemorySingleCache } from '@/misc/cache.js'; | ||||
| import type { Relay } from '@/models/entities/Relay.js'; | ||||
| import { QueueService } from '@/core/QueueService.js'; | ||||
| import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; | ||||
| @@ -16,7 +16,7 @@ const ACTOR_USERNAME = 'relay.actor' as const; | ||||
|  | ||||
| @Injectable() | ||||
| export class RelayService { | ||||
| 	private relaysCache: Cache<Relay[]>; | ||||
| 	private relaysCache: MemorySingleCache<Relay[]>; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.usersRepository) | ||||
| @@ -30,7 +30,7 @@ export class RelayService { | ||||
| 		private createSystemUserService: CreateSystemUserService, | ||||
| 		private apRendererService: ApRendererService, | ||||
| 	) { | ||||
| 		this.relaysCache = new Cache<Relay[]>(1000 * 60 * 10); | ||||
| 		this.relaysCache = new MemorySingleCache<Relay[]>(1000 * 60 * 10); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| @@ -57,7 +57,7 @@ export class RelayService { | ||||
| 		const relayActor = await this.getRelayActor(); | ||||
| 		const follow = await this.apRendererService.renderFollowRelay(relay, relayActor); | ||||
| 		const activity = this.apRendererService.addContext(follow); | ||||
| 		this.queueService.deliver(relayActor, activity, relay.inbox); | ||||
| 		this.queueService.deliver(relayActor, activity, relay.inbox, false); | ||||
| 	 | ||||
| 		return relay; | ||||
| 	} | ||||
| @@ -76,7 +76,7 @@ export class RelayService { | ||||
| 		const follow = this.apRendererService.renderFollowRelay(relay, relayActor); | ||||
| 		const undo = this.apRendererService.renderUndo(follow, relayActor); | ||||
| 		const activity = this.apRendererService.addContext(undo); | ||||
| 		this.queueService.deliver(relayActor, activity, relay.inbox); | ||||
| 		this.queueService.deliver(relayActor, activity, relay.inbox, false); | ||||
| 	 | ||||
| 		await this.relaysRepository.delete(relay.id); | ||||
| 	} | ||||
| @@ -109,7 +109,7 @@ export class RelayService { | ||||
| 	public async deliverToRelays(user: { id: User['id']; host: null; }, activity: any): Promise<void> { | ||||
| 		if (activity == null) return; | ||||
| 	 | ||||
| 		const relays = await this.relaysCache.fetch(null, () => this.relaysRepository.findBy({ | ||||
| 		const relays = await this.relaysCache.fetch(() => this.relaysRepository.findBy({ | ||||
| 			status: 'accepted', | ||||
| 		})); | ||||
| 		if (relays.length === 0) return; | ||||
| @@ -120,7 +120,7 @@ export class RelayService { | ||||
| 		const signed = await this.apRendererService.attachLdSignature(copy, user); | ||||
| 	 | ||||
| 		for (const relay of relays) { | ||||
| 			this.queueService.deliver(user, signed, relay.inbox); | ||||
| 			this.queueService.deliver(user, signed, relay.inbox, false); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -2,12 +2,12 @@ import { Inject, Injectable } from '@nestjs/common'; | ||||
| import Redis from 'ioredis'; | ||||
| import { In } from 'typeorm'; | ||||
| import type { Role, RoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; | ||||
| import { Cache } from '@/misc/cache.js'; | ||||
| import { MemoryKVCache, MemorySingleCache } from '@/misc/cache.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import { UserCacheService } from '@/core/UserCacheService.js'; | ||||
| import { CacheService } from '@/core/CacheService.js'; | ||||
| import type { RoleCondFormulaValue } from '@/models/entities/Role.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { StreamMessages } from '@/server/api/stream/types.js'; | ||||
| @@ -21,6 +21,7 @@ export type RolePolicies = { | ||||
| 	canPublicNote: boolean; | ||||
| 	canInvite: boolean; | ||||
| 	canManageCustomEmojis: boolean; | ||||
| 	canSearchNotes: boolean; | ||||
| 	canHideAds: boolean; | ||||
| 	driveCapacityMb: number; | ||||
| 	pinLimit: number; | ||||
| @@ -40,6 +41,7 @@ export const DEFAULT_POLICIES: RolePolicies = { | ||||
| 	canPublicNote: true, | ||||
| 	canInvite: false, | ||||
| 	canManageCustomEmojis: false, | ||||
| 	canSearchNotes: false, | ||||
| 	canHideAds: false, | ||||
| 	driveCapacityMb: 100, | ||||
| 	pinLimit: 5, | ||||
| @@ -55,15 +57,15 @@ export const DEFAULT_POLICIES: RolePolicies = { | ||||
|  | ||||
| @Injectable() | ||||
| export class RoleService implements OnApplicationShutdown { | ||||
| 	private rolesCache: Cache<Role[]>; | ||||
| 	private roleAssignmentByUserIdCache: Cache<RoleAssignment[]>; | ||||
| 	private rolesCache: MemorySingleCache<Role[]>; | ||||
| 	private roleAssignmentByUserIdCache: MemoryKVCache<RoleAssignment[]>; | ||||
|  | ||||
| 	public static AlreadyAssignedError = class extends Error {}; | ||||
| 	public static NotAssignedError = class extends Error {}; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.redisSubscriber) | ||||
| 		private redisSubscriber: Redis.Redis, | ||||
| 		@Inject(DI.redisForPubsub) | ||||
| 		private redisForPubsub: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
| @@ -75,17 +77,17 @@ export class RoleService implements OnApplicationShutdown { | ||||
| 		private roleAssignmentsRepository: RoleAssignmentsRepository, | ||||
|  | ||||
| 		private metaService: MetaService, | ||||
| 		private userCacheService: UserCacheService, | ||||
| 		private cacheService: CacheService, | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private idService: IdService, | ||||
| 	) { | ||||
| 		//this.onMessage = this.onMessage.bind(this); | ||||
|  | ||||
| 		this.rolesCache = new Cache<Role[]>(Infinity); | ||||
| 		this.roleAssignmentByUserIdCache = new Cache<RoleAssignment[]>(Infinity); | ||||
| 		this.rolesCache = new MemorySingleCache<Role[]>(1000 * 60 * 60 * 1); | ||||
| 		this.roleAssignmentByUserIdCache = new MemoryKVCache<RoleAssignment[]>(1000 * 60 * 60 * 1); | ||||
|  | ||||
| 		this.redisSubscriber.on('message', this.onMessage); | ||||
| 		this.redisForPubsub.on('message', this.onMessage); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| @@ -96,7 +98,7 @@ export class RoleService implements OnApplicationShutdown { | ||||
| 			const { type, body } = obj.message as StreamMessages['internal']['payload']; | ||||
| 			switch (type) { | ||||
| 				case 'roleCreated': { | ||||
| 					const cached = this.rolesCache.get(null); | ||||
| 					const cached = this.rolesCache.get(); | ||||
| 					if (cached) { | ||||
| 						cached.push({ | ||||
| 							...body, | ||||
| @@ -108,7 +110,7 @@ export class RoleService implements OnApplicationShutdown { | ||||
| 					break; | ||||
| 				} | ||||
| 				case 'roleUpdated': { | ||||
| 					const cached = this.rolesCache.get(null); | ||||
| 					const cached = this.rolesCache.get(); | ||||
| 					if (cached) { | ||||
| 						const i = cached.findIndex(x => x.id === body.id); | ||||
| 						if (i > -1) { | ||||
| @@ -123,9 +125,9 @@ export class RoleService implements OnApplicationShutdown { | ||||
| 					break; | ||||
| 				} | ||||
| 				case 'roleDeleted': { | ||||
| 					const cached = this.rolesCache.get(null); | ||||
| 					const cached = this.rolesCache.get(); | ||||
| 					if (cached) { | ||||
| 						this.rolesCache.set(null, cached.filter(x => x.id !== body.id)); | ||||
| 						this.rolesCache.set(cached.filter(x => x.id !== body.id)); | ||||
| 					} | ||||
| 					break; | ||||
| 				} | ||||
| @@ -190,6 +192,12 @@ export class RoleService implements OnApplicationShutdown { | ||||
| 				case 'followingMoreThanOrEq': { | ||||
| 					return user.followingCount >= value.value; | ||||
| 				} | ||||
| 				case 'notesLessThanOrEq': { | ||||
| 					return user.notesCount <= value.value; | ||||
| 				} | ||||
| 				case 'notesMoreThanOrEq': { | ||||
| 					return user.notesCount >= value.value; | ||||
| 				} | ||||
| 				default: | ||||
| 					return false; | ||||
| 			} | ||||
| @@ -206,9 +214,9 @@ export class RoleService implements OnApplicationShutdown { | ||||
| 		// 期限切れのロールを除外 | ||||
| 		assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); | ||||
| 		const assignedRoleIds = assigns.map(x => x.roleId); | ||||
| 		const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); | ||||
| 		const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); | ||||
| 		const assignedRoles = roles.filter(r => assignedRoleIds.includes(r.id)); | ||||
| 		const user = roles.some(r => r.target === 'conditional') ? await this.userCacheService.findById(userId) : null; | ||||
| 		const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; | ||||
| 		const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula)); | ||||
| 		return [...assignedRoles, ...matchedCondRoles]; | ||||
| 	} | ||||
| @@ -223,11 +231,11 @@ export class RoleService implements OnApplicationShutdown { | ||||
| 		// 期限切れのロールを除外 | ||||
| 		assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); | ||||
| 		const assignedRoleIds = assigns.map(x => x.roleId); | ||||
| 		const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); | ||||
| 		const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); | ||||
| 		const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id)); | ||||
| 		const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional')); | ||||
| 		if (badgeCondRoles.length > 0) { | ||||
| 			const user = roles.some(r => r.target === 'conditional') ? await this.userCacheService.findById(userId) : null; | ||||
| 			const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; | ||||
| 			const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, r.condFormula)); | ||||
| 			return [...assignedBadgeRoles, ...matchedBadgeCondRoles]; | ||||
| 		} else { | ||||
| @@ -264,6 +272,7 @@ export class RoleService implements OnApplicationShutdown { | ||||
| 			canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), | ||||
| 			canInvite: calc('canInvite', vs => vs.some(v => v === true)), | ||||
| 			canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)), | ||||
| 			canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), | ||||
| 			canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), | ||||
| 			driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), | ||||
| 			pinLimit: calc('pinLimit', vs => Math.max(...vs)), | ||||
| @@ -292,7 +301,7 @@ export class RoleService implements OnApplicationShutdown { | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async getModeratorIds(includeAdmins = true): Promise<User['id'][]> { | ||||
| 		const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); | ||||
| 		const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); | ||||
| 		const moderatorRoles = includeAdmins ? roles.filter(r => r.isModerator || r.isAdministrator) : roles.filter(r => r.isModerator); | ||||
| 		const assigns = moderatorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ | ||||
| 			roleId: In(moderatorRoles.map(r => r.id)), | ||||
| @@ -312,7 +321,7 @@ export class RoleService implements OnApplicationShutdown { | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async getAdministratorIds(): Promise<User['id'][]> { | ||||
| 		const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); | ||||
| 		const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); | ||||
| 		const administratorRoles = roles.filter(r => r.isAdministrator); | ||||
| 		const assigns = administratorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ | ||||
| 			roleId: In(administratorRoles.map(r => r.id)), | ||||
| @@ -391,6 +400,6 @@ export class RoleService implements OnApplicationShutdown { | ||||
|  | ||||
| 	@bindThis | ||||
| 	public onApplicationShutdown(signal?: string | undefined) { | ||||
| 		this.redisSubscriber.off('message', this.onMessage); | ||||
| 		this.redisForPubsub.off('message', this.onMessage); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,11 +1,16 @@ | ||||
| import { URL } from 'node:url'; | ||||
| import * as http from 'node:http'; | ||||
| import * as https from 'node:https'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import S3 from 'aws-sdk/clients/s3.js'; | ||||
| import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3'; | ||||
| import { Upload } from '@aws-sdk/lib-storage'; | ||||
| import { NodeHttpHandler, NodeHttpHandlerOptions } from '@aws-sdk/node-http-handler'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import type { Meta } from '@/models/entities/Meta.js'; | ||||
| import { HttpRequestService } from '@/core/HttpRequestService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import type { DeleteObjectCommandInput, PutObjectCommandInput } from '@aws-sdk/client-s3'; | ||||
|  | ||||
| @Injectable() | ||||
| export class S3Service { | ||||
| @@ -18,23 +23,47 @@ export class S3Service { | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public getS3(meta: Meta) { | ||||
| 		const u = meta.objectStorageEndpoint != null | ||||
| 			? `${meta.objectStorageUseSSL ? 'https://' : 'http://'}${meta.objectStorageEndpoint}` | ||||
| 			: `${meta.objectStorageUseSSL ? 'https://' : 'http://'}example.net`; | ||||
| 	 | ||||
| 		return new S3({ | ||||
| 			endpoint: meta.objectStorageEndpoint ?? undefined, | ||||
| 			accessKeyId: meta.objectStorageAccessKey!, | ||||
| 			secretAccessKey: meta.objectStorageSecretKey!, | ||||
| 			region: meta.objectStorageRegion ?? undefined, | ||||
| 			sslEnabled: meta.objectStorageUseSSL, | ||||
| 			s3ForcePathStyle: !meta.objectStorageEndpoint	// AWS with endPoint omitted | ||||
| 				? false | ||||
| 				: meta.objectStorageS3ForcePathStyle, | ||||
| 			httpOptions: { | ||||
| 				agent: this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy), | ||||
| 			}, | ||||
| 	public getS3Client(meta: Meta): S3Client { | ||||
| 		const u = meta.objectStorageEndpoint | ||||
| 			? `${meta.objectStorageUseSSL ? 'https' : 'http'}://${meta.objectStorageEndpoint}` | ||||
| 			: `${meta.objectStorageUseSSL ? 'https' : 'http'}://example.net`; // dummy url to select http(s) agent | ||||
|  | ||||
| 		const agent = this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy); | ||||
| 		const handlerOption: NodeHttpHandlerOptions = {}; | ||||
| 		if (meta.objectStorageUseSSL) { | ||||
| 			handlerOption.httpsAgent = agent as https.Agent; | ||||
| 		} else { | ||||
| 			handlerOption.httpAgent = agent as http.Agent; | ||||
| 		} | ||||
|  | ||||
| 		return new S3Client({ | ||||
| 			endpoint: meta.objectStorageEndpoint ? u : undefined, | ||||
| 			credentials: (meta.objectStorageAccessKey !== null && meta.objectStorageSecretKey !== null) ? { | ||||
| 				accessKeyId: meta.objectStorageAccessKey, | ||||
| 				secretAccessKey: meta.objectStorageSecretKey, | ||||
| 			} : undefined, | ||||
| 			region: meta.objectStorageRegion ? meta.objectStorageRegion : undefined, // 空文字列もundefinedにするため ?? は使わない | ||||
| 			tls: meta.objectStorageUseSSL, | ||||
| 			forcePathStyle: meta.objectStorageEndpoint ? meta.objectStorageS3ForcePathStyle : false, // AWS with endPoint omitted | ||||
| 			requestHandler: new NodeHttpHandler(handlerOption), | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async upload(meta: Meta, input: PutObjectCommandInput) { | ||||
| 		const client = this.getS3Client(meta); | ||||
| 		return new Upload({ | ||||
| 			client, | ||||
| 			params: input, | ||||
| 			partSize: (client.config.endpoint && (await client.config.endpoint()).hostname === 'storage.googleapis.com') | ||||
| 				? 500 * 1024 * 1024 | ||||
| 				: 8 * 1024 * 1024, | ||||
| 		}).done(); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public delete(meta: Meta, input: DeleteObjectCommandInput) { | ||||
| 		const client = this.getS3Client(meta); | ||||
| 		return client.send(new DeleteObjectCommand(input)); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -90,7 +90,7 @@ export class SignupService { | ||||
| 					cipher: undefined, | ||||
| 					passphrase: undefined, | ||||
| 				}, | ||||
| 			} as any, (err, publicKey, privateKey) => | ||||
| 			}, (err, publicKey, privateKey) => | ||||
| 				err ? rej(err) : res([publicKey, privateKey]), | ||||
| 			)); | ||||
| 	 | ||||
|   | ||||
| @@ -1,40 +1,30 @@ | ||||
|  | ||||
| import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; | ||||
| import Redis from 'ioredis'; | ||||
| import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; | ||||
| import { ModuleRef } from '@nestjs/core'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { Blocking } from '@/models/entities/Blocking.js'; | ||||
| import { QueueService } from '@/core/QueueService.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { UsersRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/index.js'; | ||||
| import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/index.js'; | ||||
| import Logger from '@/logger.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; | ||||
| import { LoggerService } from '@/core/LoggerService.js'; | ||||
| import { WebhookService } from '@/core/WebhookService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { Cache } from '@/misc/cache.js'; | ||||
| import { StreamMessages } from '@/server/api/stream/types.js'; | ||||
| import { CacheService } from '@/core/CacheService.js'; | ||||
| import { UserFollowingService } from '@/core/UserFollowingService.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class UserBlockingService implements OnApplicationShutdown { | ||||
| export class UserBlockingService implements OnModuleInit { | ||||
| 	private logger: Logger; | ||||
|  | ||||
| 	// キーがユーザーIDで、値がそのユーザーがブロックしているユーザーのIDのリストなキャッシュ | ||||
| 	private blockingsByUserIdCache: Cache<User['id'][]>; | ||||
| 	private userFollowingService: UserFollowingService; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.redisSubscriber) | ||||
| 		private redisSubscriber: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.followingsRepository) | ||||
| 		private followingsRepository: FollowingsRepository, | ||||
|  | ||||
| 		private moduleRef: ModuleRef, | ||||
| 	 | ||||
| 		@Inject(DI.followRequestsRepository) | ||||
| 		private followRequestsRepository: FollowRequestsRepository, | ||||
|  | ||||
| @@ -47,47 +37,20 @@ export class UserBlockingService implements OnApplicationShutdown { | ||||
| 		@Inject(DI.userListJoiningsRepository) | ||||
| 		private userListJoiningsRepository: UserListJoiningsRepository, | ||||
|  | ||||
| 		private cacheService: CacheService, | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private idService: IdService, | ||||
| 		private queueService: QueueService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private webhookService: WebhookService, | ||||
| 		private apRendererService: ApRendererService, | ||||
| 		private perUserFollowingChart: PerUserFollowingChart, | ||||
| 		private loggerService: LoggerService, | ||||
| 	) { | ||||
| 		this.logger = this.loggerService.getLogger('user-block'); | ||||
|  | ||||
| 		this.blockingsByUserIdCache = new Cache<User['id'][]>(Infinity); | ||||
|  | ||||
| 		this.redisSubscriber.on('message', this.onMessage); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	private async onMessage(_: string, data: string): Promise<void> { | ||||
| 		const obj = JSON.parse(data); | ||||
|  | ||||
| 		if (obj.channel === 'internal') { | ||||
| 			const { type, body } = obj.message as StreamMessages['internal']['payload']; | ||||
| 			switch (type) { | ||||
| 				case 'blockingCreated': { | ||||
| 					const cached = this.blockingsByUserIdCache.get(body.blockerId); | ||||
| 					if (cached) { | ||||
| 						this.blockingsByUserIdCache.set(body.blockerId, [...cached, ...[body.blockeeId]]); | ||||
| 					} | ||||
| 					break; | ||||
| 				} | ||||
| 				case 'blockingDeleted': { | ||||
| 					const cached = this.blockingsByUserIdCache.get(body.blockerId); | ||||
| 					if (cached) { | ||||
| 						this.blockingsByUserIdCache.set(body.blockerId, cached.filter(x => x !== body.blockeeId)); | ||||
| 					} | ||||
| 					break; | ||||
| 				} | ||||
| 				default: | ||||
| 					break; | ||||
| 			} | ||||
| 		} | ||||
| 	onModuleInit() { | ||||
| 		this.userFollowingService = this.moduleRef.get('UserFollowingService'); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| @@ -95,8 +58,8 @@ export class UserBlockingService implements OnApplicationShutdown { | ||||
| 		await Promise.all([ | ||||
| 			this.cancelRequest(blocker, blockee), | ||||
| 			this.cancelRequest(blockee, blocker), | ||||
| 			this.unFollow(blocker, blockee), | ||||
| 			this.unFollow(blockee, blocker), | ||||
| 			this.userFollowingService.unfollow(blocker, blockee), | ||||
| 			this.userFollowingService.unfollow(blockee, blocker), | ||||
| 			this.removeFromList(blockee, blocker), | ||||
| 		]); | ||||
|  | ||||
| @@ -111,6 +74,9 @@ export class UserBlockingService implements OnApplicationShutdown { | ||||
|  | ||||
| 		await this.blockingsRepository.insert(blocking); | ||||
|  | ||||
| 		this.cacheService.userBlockingCache.refresh(blocker.id); | ||||
| 		this.cacheService.userBlockedCache.refresh(blockee.id); | ||||
|  | ||||
| 		this.globalEventService.publishInternalEvent('blockingCreated', { | ||||
| 			blockerId: blocker.id, | ||||
| 			blockeeId: blockee.id, | ||||
| @@ -118,7 +84,7 @@ export class UserBlockingService implements OnApplicationShutdown { | ||||
|  | ||||
| 		if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) { | ||||
| 			const content = this.apRendererService.addContext(this.apRendererService.renderBlock(blocking)); | ||||
| 			this.queueService.deliver(blocker, content, blockee.inbox); | ||||
| 			this.queueService.deliver(blocker, content, blockee.inbox, false); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| @@ -148,7 +114,6 @@ export class UserBlockingService implements OnApplicationShutdown { | ||||
| 			this.userEntityService.pack(followee, follower, { | ||||
| 				detail: true, | ||||
| 			}).then(async packed => { | ||||
| 				this.globalEventService.publishUserEvent(follower.id, 'unfollow', packed); | ||||
| 				this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); | ||||
|  | ||||
| 				const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); | ||||
| @@ -163,61 +128,13 @@ export class UserBlockingService implements OnApplicationShutdown { | ||||
| 		// リモートにフォローリクエストをしていたらUndoFollow送信 | ||||
| 		if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { | ||||
| 			const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); | ||||
| 			this.queueService.deliver(follower, content, followee.inbox); | ||||
| 			this.queueService.deliver(follower, content, followee.inbox, false); | ||||
| 		} | ||||
|  | ||||
| 		// リモートからフォローリクエストを受けていたらReject送信 | ||||
| 		if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { | ||||
| 			const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee)); | ||||
| 			this.queueService.deliver(followee, content, follower.inbox); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	private async unFollow(follower: User, followee: User) { | ||||
| 		const following = await this.followingsRepository.findOneBy({ | ||||
| 			followerId: follower.id, | ||||
| 			followeeId: followee.id, | ||||
| 		}); | ||||
|  | ||||
| 		if (following == null) { | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		await Promise.all([ | ||||
| 			this.followingsRepository.delete(following.id), | ||||
| 			this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), | ||||
| 			this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), | ||||
| 			this.perUserFollowingChart.update(follower, followee, false), | ||||
| 		]); | ||||
|  | ||||
| 		// Publish unfollow event | ||||
| 		if (this.userEntityService.isLocalUser(follower)) { | ||||
| 			this.userEntityService.pack(followee, follower, { | ||||
| 				detail: true, | ||||
| 			}).then(async packed => { | ||||
| 				this.globalEventService.publishUserEvent(follower.id, 'unfollow', packed); | ||||
| 				this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); | ||||
|  | ||||
| 				const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); | ||||
| 				for (const webhook of webhooks) { | ||||
| 					this.queueService.webhookDeliver(webhook, 'unfollow', { | ||||
| 						user: packed, | ||||
| 					}); | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		// リモートにフォローをしていたらUndoFollow送信 | ||||
| 		if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { | ||||
| 			const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); | ||||
| 			this.queueService.deliver(follower, content, followee.inbox); | ||||
| 		} | ||||
|  | ||||
| 		// リモートからフォローをされていたらRejectFollow送信 | ||||
| 		if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) { | ||||
| 			const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee)); | ||||
| 			this.queueService.deliver(followee, content, follower.inbox); | ||||
| 			this.queueService.deliver(followee, content, follower.inbox, false); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| @@ -254,6 +171,9 @@ export class UserBlockingService implements OnApplicationShutdown { | ||||
|  | ||||
| 		await this.blockingsRepository.delete(blocking.id); | ||||
|  | ||||
| 		this.cacheService.userBlockingCache.refresh(blocker.id); | ||||
| 		this.cacheService.userBlockedCache.refresh(blockee.id); | ||||
|  | ||||
| 		this.globalEventService.publishInternalEvent('blockingDeleted', { | ||||
| 			blockerId: blocker.id, | ||||
| 			blockeeId: blockee.id, | ||||
| @@ -262,23 +182,12 @@ export class UserBlockingService implements OnApplicationShutdown { | ||||
| 		// deliver if remote bloking | ||||
| 		if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) { | ||||
| 			const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderBlock(blocking), blocker)); | ||||
| 			this.queueService.deliver(blocker, content, blockee.inbox); | ||||
| 			this.queueService.deliver(blocker, content, blockee.inbox, false); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async checkBlocked(blockerId: User['id'], blockeeId: User['id']): Promise<boolean> { | ||||
| 		const blockedUserIds = await this.blockingsByUserIdCache.fetch(blockerId, () => this.blockingsRepository.find({ | ||||
| 			where: { | ||||
| 				blockerId, | ||||
| 			}, | ||||
| 			select: ['blockeeId'], | ||||
| 		}).then(records => records.map(record => record.blockeeId))); | ||||
| 		return blockedUserIds.includes(blockeeId); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public onApplicationShutdown(signal?: string | undefined) { | ||||
| 		this.redisSubscriber.off('message', this.onMessage); | ||||
| 		return (await this.cacheService.userBlockingCache.fetch(blockerId)).has(blockeeId); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,88 +0,0 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import Redis from 'ioredis'; | ||||
| import type { UsersRepository } from '@/models/index.js'; | ||||
| import { Cache } from '@/misc/cache.js'; | ||||
| import type { LocalUser, User } from '@/models/entities/User.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { StreamMessages } from '@/server/api/stream/types.js'; | ||||
| import type { OnApplicationShutdown } from '@nestjs/common'; | ||||
|  | ||||
| @Injectable() | ||||
| export class UserCacheService implements OnApplicationShutdown { | ||||
| 	public userByIdCache: Cache<User>; | ||||
| 	public localUserByNativeTokenCache: Cache<LocalUser | null>; | ||||
| 	public localUserByIdCache: Cache<LocalUser>; | ||||
| 	public uriPersonCache: Cache<User | null>; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.redisSubscriber) | ||||
| 		private redisSubscriber: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		private userEntityService: UserEntityService, | ||||
| 	) { | ||||
| 		//this.onMessage = this.onMessage.bind(this); | ||||
|  | ||||
| 		this.userByIdCache = new Cache<User>(Infinity); | ||||
| 		this.localUserByNativeTokenCache = new Cache<LocalUser | null>(Infinity); | ||||
| 		this.localUserByIdCache = new Cache<LocalUser>(Infinity); | ||||
| 		this.uriPersonCache = new Cache<User | null>(Infinity); | ||||
|  | ||||
| 		this.redisSubscriber.on('message', this.onMessage); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	private async onMessage(_: string, data: string): Promise<void> { | ||||
| 		const obj = JSON.parse(data); | ||||
|  | ||||
| 		if (obj.channel === 'internal') { | ||||
| 			const { type, body } = obj.message as StreamMessages['internal']['payload']; | ||||
| 			switch (type) { | ||||
| 				case 'userChangeSuspendedState': | ||||
| 				case 'remoteUserUpdated': { | ||||
| 					const user = await this.usersRepository.findOneByOrFail({ id: body.id }); | ||||
| 					this.userByIdCache.set(user.id, user); | ||||
| 					for (const [k, v] of this.uriPersonCache.cache.entries()) { | ||||
| 						if (v.value?.id === user.id) { | ||||
| 							this.uriPersonCache.set(k, user); | ||||
| 						} | ||||
| 					} | ||||
| 					if (this.userEntityService.isLocalUser(user)) { | ||||
| 						this.localUserByNativeTokenCache.set(user.token, user); | ||||
| 						this.localUserByIdCache.set(user.id, user); | ||||
| 					} | ||||
| 					break; | ||||
| 				} | ||||
| 				case 'userTokenRegenerated': { | ||||
| 					const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as LocalUser; | ||||
| 					this.localUserByNativeTokenCache.delete(body.oldToken); | ||||
| 					this.localUserByNativeTokenCache.set(body.newToken, user); | ||||
| 					break; | ||||
| 				} | ||||
| 				case 'follow': { | ||||
| 					const follower = this.userByIdCache.get(body.followerId); | ||||
| 					if (follower) follower.followingCount++; | ||||
| 					const followee = this.userByIdCache.get(body.followeeId); | ||||
| 					if (followee) followee.followersCount++; | ||||
| 					break; | ||||
| 				} | ||||
| 				default: | ||||
| 					break; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public findById(userId: User['id']) { | ||||
| 		return this.userByIdCache.fetch(userId, () => this.usersRepository.findOneByOrFail({ id: userId })); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public onApplicationShutdown(signal?: string | undefined) { | ||||
| 		this.redisSubscriber.off('message', this.onMessage); | ||||
| 	} | ||||
| } | ||||
| @@ -1,4 +1,5 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Inject, Injectable, OnModuleInit, forwardRef } from '@nestjs/common'; | ||||
| import { ModuleRef } from '@nestjs/core'; | ||||
| import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; | ||||
| import { IdentifiableError } from '@/misc/identifiable-error.js'; | ||||
| import { QueueService } from '@/core/QueueService.js'; | ||||
| @@ -6,17 +7,19 @@ import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import InstanceChart from '@/core/chart/charts/instance.js'; | ||||
| import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; | ||||
| import { WebhookService } from '@/core/WebhookService.js'; | ||||
| import { CreateNotificationService } from '@/core/CreateNotificationService.js'; | ||||
| import { NotificationService } from '@/core/NotificationService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { UserBlockingService } from '@/core/UserBlockingService.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import { CacheService } from '@/core/CacheService.js'; | ||||
| import Logger from '../logger.js'; | ||||
|  | ||||
| const logger = new Logger('following/create'); | ||||
| @@ -35,8 +38,12 @@ type Remote = RemoteUser | { | ||||
| type Both = Local | Remote; | ||||
|  | ||||
| @Injectable() | ||||
| export class UserFollowingService { | ||||
| export class UserFollowingService implements OnModuleInit { | ||||
| 	private userBlockingService: UserBlockingService; | ||||
|  | ||||
| 	constructor( | ||||
| 		private moduleRef: ModuleRef, | ||||
| 	 | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| @@ -52,12 +59,13 @@ export class UserFollowingService { | ||||
| 		@Inject(DI.instancesRepository) | ||||
| 		private instancesRepository: InstancesRepository, | ||||
|  | ||||
| 		private cacheService: CacheService, | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private userBlockingService: UserBlockingService, | ||||
| 		private idService: IdService, | ||||
| 		private queueService: QueueService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private createNotificationService: CreateNotificationService, | ||||
| 		private metaService: MetaService, | ||||
| 		private notificationService: NotificationService, | ||||
| 		private federatedInstanceService: FederatedInstanceService, | ||||
| 		private webhookService: WebhookService, | ||||
| 		private apRendererService: ApRendererService, | ||||
| @@ -66,6 +74,10 @@ export class UserFollowingService { | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	onModuleInit() { | ||||
| 		this.userBlockingService = this.moduleRef.get('UserBlockingService'); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string): Promise<void> { | ||||
| 		const [follower, followee] = await Promise.all([ | ||||
| @@ -82,7 +94,7 @@ export class UserFollowingService { | ||||
| 		if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocked) { | ||||
| 			// リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。 | ||||
| 			const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, requestId), followee)); | ||||
| 			this.queueService.deliver(followee, content, follower.inbox); | ||||
| 			this.queueService.deliver(followee, content, follower.inbox, false); | ||||
| 			return; | ||||
| 		} else if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocking) { | ||||
| 			// リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。 | ||||
| @@ -131,7 +143,7 @@ export class UserFollowingService { | ||||
|  | ||||
| 		if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { | ||||
| 			const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee)); | ||||
| 			this.queueService.deliver(followee, content, follower.inbox); | ||||
| 			this.queueService.deliver(followee, content, follower.inbox, false); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| @@ -145,15 +157,15 @@ export class UserFollowingService { | ||||
| 		}, | ||||
| 	): Promise<void> { | ||||
| 		if (follower.id === followee.id) return; | ||||
| 	 | ||||
|  | ||||
| 		let alreadyFollowed = false as boolean; | ||||
| 	 | ||||
|  | ||||
| 		await this.followingsRepository.insert({ | ||||
| 			id: this.idService.genId(), | ||||
| 			createdAt: new Date(), | ||||
| 			followerId: follower.id, | ||||
| 			followeeId: followee.id, | ||||
| 	 | ||||
|  | ||||
| 			// 非正規化 | ||||
| 			followerHost: follower.host, | ||||
| 			followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : null, | ||||
| @@ -169,59 +181,64 @@ export class UserFollowingService { | ||||
| 				throw err; | ||||
| 			} | ||||
| 		}); | ||||
| 	 | ||||
|  | ||||
| 		this.cacheService.userFollowingsCache.refresh(follower.id); | ||||
|  | ||||
| 		const req = await this.followRequestsRepository.findOneBy({ | ||||
| 			followeeId: followee.id, | ||||
| 			followerId: follower.id, | ||||
| 		}); | ||||
| 	 | ||||
|  | ||||
| 		if (req) { | ||||
| 			await this.followRequestsRepository.delete({ | ||||
| 				followeeId: followee.id, | ||||
| 				followerId: follower.id, | ||||
| 			}); | ||||
| 	 | ||||
|  | ||||
| 			// 通知を作成 | ||||
| 			this.createNotificationService.createNotification(follower.id, 'followRequestAccepted', { | ||||
| 			this.notificationService.createNotification(follower.id, 'followRequestAccepted', { | ||||
| 				notifierId: followee.id, | ||||
| 			}); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		if (alreadyFollowed) return; | ||||
|  | ||||
| 		this.globalEventService.publishInternalEvent('follow', { followerId: follower.id, followeeId: followee.id }); | ||||
| 	 | ||||
|  | ||||
| 		//#region Increment counts | ||||
| 		await Promise.all([ | ||||
| 			this.usersRepository.increment({ id: follower.id }, 'followingCount', 1), | ||||
| 			this.usersRepository.increment({ id: followee.id }, 'followersCount', 1), | ||||
| 		]); | ||||
| 		//#endregion | ||||
| 	 | ||||
|  | ||||
| 		//#region Update instance stats | ||||
| 		if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { | ||||
| 			this.federatedInstanceService.fetch(follower.host).then(i => { | ||||
| 			this.federatedInstanceService.fetch(follower.host).then(async i => { | ||||
| 				this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); | ||||
| 				this.instanceChart.updateFollowing(i.host, true); | ||||
| 				if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { | ||||
| 					this.instanceChart.updateFollowing(i.host, true); | ||||
| 				} | ||||
| 			}); | ||||
| 		} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { | ||||
| 			this.federatedInstanceService.fetch(followee.host).then(i => { | ||||
| 			this.federatedInstanceService.fetch(followee.host).then(async i => { | ||||
| 				this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); | ||||
| 				this.instanceChart.updateFollowers(i.host, true); | ||||
| 				if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { | ||||
| 					this.instanceChart.updateFollowers(i.host, true); | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
| 		//#endregion | ||||
| 	 | ||||
|  | ||||
| 		this.perUserFollowingChart.update(follower, followee, true); | ||||
| 	 | ||||
|  | ||||
| 		// Publish follow event | ||||
| 		if (this.userEntityService.isLocalUser(follower)) { | ||||
| 			this.userEntityService.pack(followee.id, follower, { | ||||
| 				detail: true, | ||||
| 			}).then(async packed => { | ||||
| 				this.globalEventService.publishUserEvent(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>); | ||||
| 				this.globalEventService.publishMainStream(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>); | ||||
| 	 | ||||
|  | ||||
| 				const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow')); | ||||
| 				for (const webhook of webhooks) { | ||||
| 					this.queueService.webhookDeliver(webhook, 'follow', { | ||||
| @@ -230,12 +247,12 @@ export class UserFollowingService { | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		// Publish followed event | ||||
| 		if (this.userEntityService.isLocalUser(followee)) { | ||||
| 			this.userEntityService.pack(follower.id, followee).then(async packed => { | ||||
| 				this.globalEventService.publishMainStream(followee.id, 'followed', packed); | ||||
| 	 | ||||
|  | ||||
| 				const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed')); | ||||
| 				for (const webhook of webhooks) { | ||||
| 					this.queueService.webhookDeliver(webhook, 'followed', { | ||||
| @@ -243,9 +260,9 @@ export class UserFollowingService { | ||||
| 					}); | ||||
| 				} | ||||
| 			}); | ||||
| 	 | ||||
|  | ||||
| 			// 通知を作成 | ||||
| 			this.createNotificationService.createNotification(followee.id, 'follow', { | ||||
| 			this.notificationService.createNotification(followee.id, 'follow', { | ||||
| 				notifierId: follower.id, | ||||
| 			}); | ||||
| 		} | ||||
| @@ -265,24 +282,25 @@ export class UserFollowingService { | ||||
| 			followerId: follower.id, | ||||
| 			followeeId: followee.id, | ||||
| 		}); | ||||
| 	 | ||||
|  | ||||
| 		if (following == null) { | ||||
| 			logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); | ||||
| 			return; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		await this.followingsRepository.delete(following.id); | ||||
| 	 | ||||
|  | ||||
| 		this.cacheService.userFollowingsCache.refresh(follower.id); | ||||
|  | ||||
| 		this.decrementFollowing(follower, followee); | ||||
| 	 | ||||
|  | ||||
| 		// Publish unfollow event | ||||
| 		if (!silent && this.userEntityService.isLocalUser(follower)) { | ||||
| 			this.userEntityService.pack(followee.id, follower, { | ||||
| 				detail: true, | ||||
| 			}).then(async packed => { | ||||
| 				this.globalEventService.publishUserEvent(follower.id, 'unfollow', packed); | ||||
| 				this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); | ||||
| 	 | ||||
|  | ||||
| 				const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); | ||||
| 				for (const webhook of webhooks) { | ||||
| 					this.queueService.webhookDeliver(webhook, 'unfollow', { | ||||
| @@ -291,47 +309,51 @@ export class UserFollowingService { | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { | ||||
| 			const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); | ||||
| 			this.queueService.deliver(follower, content, followee.inbox); | ||||
| 			this.queueService.deliver(follower, content, followee.inbox, false); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) { | ||||
| 			// local user has null host | ||||
| 			const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee)); | ||||
| 			this.queueService.deliver(followee, content, follower.inbox); | ||||
| 			this.queueService.deliver(followee, content, follower.inbox, false); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
|  | ||||
| 	@bindThis | ||||
| 	private async decrementFollowing( | ||||
| 		follower: {id: User['id']; host: User['host']; }, | ||||
| 		follower: { id: User['id']; host: User['host']; }, | ||||
| 		followee: { id: User['id']; host: User['host']; }, | ||||
| 	): Promise<void> { | ||||
| 		this.globalEventService.publishInternalEvent('unfollow', { followerId: follower.id, followeeId: followee.id }); | ||||
| 	 | ||||
|  | ||||
| 		//#region Decrement following / followers counts | ||||
| 		await Promise.all([ | ||||
| 			this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), | ||||
| 			this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), | ||||
| 		]); | ||||
| 		//#endregion | ||||
| 	 | ||||
|  | ||||
| 		//#region Update instance stats | ||||
| 		if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { | ||||
| 			this.federatedInstanceService.fetch(follower.host).then(i => { | ||||
| 			this.federatedInstanceService.fetch(follower.host).then(async i => { | ||||
| 				this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); | ||||
| 				this.instanceChart.updateFollowing(i.host, false); | ||||
| 				if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { | ||||
| 					this.instanceChart.updateFollowing(i.host, false); | ||||
| 				} | ||||
| 			}); | ||||
| 		} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { | ||||
| 			this.federatedInstanceService.fetch(followee.host).then(i => { | ||||
| 			this.federatedInstanceService.fetch(followee.host).then(async i => { | ||||
| 				this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); | ||||
| 				this.instanceChart.updateFollowers(i.host, false); | ||||
| 				if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { | ||||
| 					this.instanceChart.updateFollowers(i.host, false); | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
| 		//#endregion | ||||
| 	 | ||||
|  | ||||
| 		this.perUserFollowingChart.update(follower, followee, false); | ||||
| 	} | ||||
|  | ||||
| @@ -346,23 +368,23 @@ export class UserFollowingService { | ||||
| 		requestId?: string, | ||||
| 	): Promise<void> { | ||||
| 		if (follower.id === followee.id) return; | ||||
| 	 | ||||
|  | ||||
| 		// check blocking | ||||
| 		const [blocking, blocked] = await Promise.all([ | ||||
| 			this.userBlockingService.checkBlocked(follower.id, followee.id), | ||||
| 			this.userBlockingService.checkBlocked(followee.id, follower.id), | ||||
| 		]); | ||||
| 	 | ||||
|  | ||||
| 		if (blocking) throw new Error('blocking'); | ||||
| 		if (blocked) throw new Error('blocked'); | ||||
| 	 | ||||
|  | ||||
| 		const followRequest = await this.followRequestsRepository.insert({ | ||||
| 			id: this.idService.genId(), | ||||
| 			createdAt: new Date(), | ||||
| 			followerId: follower.id, | ||||
| 			followeeId: followee.id, | ||||
| 			requestId, | ||||
| 	 | ||||
|  | ||||
| 			// 非正規化 | ||||
| 			followerHost: follower.host, | ||||
| 			followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : undefined, | ||||
| @@ -371,25 +393,25 @@ export class UserFollowingService { | ||||
| 			followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : undefined, | ||||
| 			followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : undefined, | ||||
| 		}).then(x => this.followRequestsRepository.findOneByOrFail(x.identifiers[0])); | ||||
| 	 | ||||
|  | ||||
| 		// Publish receiveRequest event | ||||
| 		if (this.userEntityService.isLocalUser(followee)) { | ||||
| 			this.userEntityService.pack(follower.id, followee).then(packed => this.globalEventService.publishMainStream(followee.id, 'receiveFollowRequest', packed)); | ||||
| 	 | ||||
|  | ||||
| 			this.userEntityService.pack(followee.id, followee, { | ||||
| 				detail: true, | ||||
| 			}).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); | ||||
| 	 | ||||
|  | ||||
| 			// 通知を作成 | ||||
| 			this.createNotificationService.createNotification(followee.id, 'receiveFollowRequest', { | ||||
| 			this.notificationService.createNotification(followee.id, 'receiveFollowRequest', { | ||||
| 				notifierId: follower.id, | ||||
| 				followRequestId: followRequest.id, | ||||
| 			}); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { | ||||
| 			const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)); | ||||
| 			this.queueService.deliver(follower, content, followee.inbox); | ||||
| 			this.queueService.deliver(follower, content, followee.inbox, false); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| @@ -404,26 +426,26 @@ export class UserFollowingService { | ||||
| 	): Promise<void> { | ||||
| 		if (this.userEntityService.isRemoteUser(followee)) { | ||||
| 			const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); | ||||
| 	 | ||||
|  | ||||
| 			if (this.userEntityService.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので | ||||
| 				this.queueService.deliver(follower, content, followee.inbox); | ||||
| 				this.queueService.deliver(follower, content, followee.inbox, false); | ||||
| 			} | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		const request = await this.followRequestsRepository.findOneBy({ | ||||
| 			followeeId: followee.id, | ||||
| 			followerId: follower.id, | ||||
| 		}); | ||||
| 	 | ||||
|  | ||||
| 		if (request == null) { | ||||
| 			throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found'); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		await this.followRequestsRepository.delete({ | ||||
| 			followeeId: followee.id, | ||||
| 			followerId: follower.id, | ||||
| 		}); | ||||
| 	 | ||||
|  | ||||
| 		this.userEntityService.pack(followee.id, followee, { | ||||
| 			detail: true, | ||||
| 		}).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); | ||||
| @@ -440,18 +462,18 @@ export class UserFollowingService { | ||||
| 			followeeId: followee.id, | ||||
| 			followerId: follower.id, | ||||
| 		}); | ||||
| 	 | ||||
|  | ||||
| 		if (request == null) { | ||||
| 			throw new IdentifiableError('8884c2dd-5795-4ac9-b27e-6a01d38190f9', 'No follow request.'); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		await this.insertFollowingDoc(followee, follower); | ||||
| 	 | ||||
|  | ||||
| 		if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { | ||||
| 			const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee)); | ||||
| 			this.queueService.deliver(followee, content, follower.inbox); | ||||
| 			this.queueService.deliver(followee, content, follower.inbox, false); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		this.userEntityService.pack(followee.id, followee, { | ||||
| 			detail: true, | ||||
| 		}).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); | ||||
| @@ -466,13 +488,13 @@ export class UserFollowingService { | ||||
| 		const requests = await this.followRequestsRepository.findBy({ | ||||
| 			followeeId: user.id, | ||||
| 		}); | ||||
| 	 | ||||
|  | ||||
| 		for (const request of requests) { | ||||
| 			const follower = await this.usersRepository.findOneByOrFail({ id: request.followerId }); | ||||
| 			this.acceptFollowRequest(user, follower); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
|  | ||||
| 	/** | ||||
| 	 * API following/request/reject | ||||
| 	 */ | ||||
| @@ -557,7 +579,7 @@ export class UserFollowingService { | ||||
| 		}); | ||||
|  | ||||
| 		const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request?.requestId ?? undefined), followee)); | ||||
| 		this.queueService.deliver(followee, content, follower.inbox); | ||||
| 		this.queueService.deliver(followee, content, follower.inbox, false); | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| @@ -569,7 +591,6 @@ export class UserFollowingService { | ||||
| 			detail: true, | ||||
| 		}); | ||||
|  | ||||
| 		this.globalEventService.publishUserEvent(follower.id, 'unfollow', packedFollowee); | ||||
| 		this.globalEventService.publishMainStream(follower.id, 'unfollow', packedFollowee); | ||||
|  | ||||
| 		const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); | ||||
|   | ||||
							
								
								
									
										34
									
								
								packages/backend/src/core/UserKeypairService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								packages/backend/src/core/UserKeypairService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import Redis from 'ioredis'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { UserKeypairsRepository } from '@/models/index.js'; | ||||
| import { RedisKVCache } from '@/misc/cache.js'; | ||||
| import type { UserKeypair } from '@/models/entities/UserKeypair.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class UserKeypairService { | ||||
| 	private cache: RedisKVCache<UserKeypair>; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.redis) | ||||
| 		private redisClient: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.userKeypairsRepository) | ||||
| 		private userKeypairsRepository: UserKeypairsRepository, | ||||
| 	) { | ||||
| 		this.cache = new RedisKVCache<UserKeypair>(this.redisClient, 'userKeypair', { | ||||
| 			lifetime: 1000 * 60 * 60 * 24, // 24h | ||||
| 			memoryCacheLifetime: Infinity, | ||||
| 			fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }), | ||||
| 			toRedisConverter: (value) => JSON.stringify(value), | ||||
| 			fromRedisConverter: (value) => JSON.parse(value), | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async getUserKeypair(userId: User['id']): Promise<UserKeypair> { | ||||
| 		return await this.cache.fetch(userId); | ||||
| 	} | ||||
| } | ||||
| @@ -1,24 +0,0 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { UserKeypairsRepository } from '@/models/index.js'; | ||||
| import { Cache } from '@/misc/cache.js'; | ||||
| import type { UserKeypair } from '@/models/entities/UserKeypair.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class UserKeypairStoreService { | ||||
| 	private cache: Cache<UserKeypair>; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.userKeypairsRepository) | ||||
| 		private userKeypairsRepository: UserKeypairsRepository, | ||||
| 	) { | ||||
| 		this.cache = new Cache<UserKeypair>(Infinity); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async getUserKeypair(userId: User['id']): Promise<UserKeypair> { | ||||
| 		return await this.cache.fetch(userId, () => this.userKeypairsRepository.findOneByOrFail({ userId: userId })); | ||||
| 	} | ||||
| } | ||||
| @@ -1,34 +1,47 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import type { UsersRepository, MutingsRepository } from '@/models/index.js'; | ||||
| import { In } from 'typeorm'; | ||||
| import type { MutingsRepository, Muting } from '@/models/index.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { QueueService } from '@/core/QueueService.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { CacheService } from '@/core/CacheService.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class UserMutingService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.mutingsRepository) | ||||
| 		private mutingsRepository: MutingsRepository, | ||||
|  | ||||
| 		private idService: IdService, | ||||
| 		private queueService: QueueService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private cacheService: CacheService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async mute(user: User, target: User): Promise<void> { | ||||
| 	public async mute(user: User, target: User, expiresAt: Date | null = null): Promise<void> { | ||||
| 		await this.mutingsRepository.insert({ | ||||
| 			id: this.idService.genId(), | ||||
| 			createdAt: new Date(), | ||||
| 			expiresAt: expiresAt ?? null, | ||||
| 			muterId: user.id, | ||||
| 			muteeId: target.id, | ||||
| 		}); | ||||
|  | ||||
| 		this.cacheService.userMutingsCache.refresh(user.id); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async unmute(mutings: Muting[]): Promise<void> { | ||||
| 		if (mutings.length === 0) return; | ||||
|  | ||||
| 		await this.mutingsRepository.delete({ | ||||
| 			id: In(mutings.map(m => m.id)), | ||||
| 		}); | ||||
|  | ||||
| 		const muterIds = [...new Set(mutings.map(m => m.muterId))]; | ||||
| 		for (const muterId of muterIds) { | ||||
| 			this.cacheService.userMutingsCache.refresh(muterId); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -54,7 +54,7 @@ export class UserSuspendService { | ||||
| 			} | ||||
| 	 | ||||
| 			for (const inbox of queue) { | ||||
| 				this.queueService.deliver(user, content, inbox); | ||||
| 				this.queueService.deliver(user, content, inbox, true); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| @@ -84,7 +84,7 @@ export class UserSuspendService { | ||||
| 			} | ||||
| 	 | ||||
| 			for (const inbox of queue) { | ||||
| 				this.queueService.deliver(user as any, content, inbox); | ||||
| 				this.queueService.deliver(user as any, content, inbox, true); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -37,7 +37,7 @@ export class VideoProcessingService { | ||||
| 					}); | ||||
| 			}); | ||||
|  | ||||
| 			return await this.imageProcessingService.convertToWebp(`${dir}/out.png`, 498, 280); | ||||
| 			return await this.imageProcessingService.convertToWebp(`${dir}/out.png`, 498, 422); | ||||
| 		} finally { | ||||
| 			cleanup(); | ||||
| 		} | ||||
|   | ||||
| @@ -13,14 +13,14 @@ export class WebhookService implements OnApplicationShutdown { | ||||
| 	private webhooks: Webhook[] = []; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.redisSubscriber) | ||||
| 		private redisSubscriber: Redis.Redis, | ||||
| 		@Inject(DI.redisForPubsub) | ||||
| 		private redisForPubsub: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.webhooksRepository) | ||||
| 		private webhooksRepository: WebhooksRepository, | ||||
| 	) { | ||||
| 		//this.onMessage = this.onMessage.bind(this); | ||||
| 		this.redisSubscriber.on('message', this.onMessage); | ||||
| 		this.redisForPubsub.on('message', this.onMessage); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| @@ -82,6 +82,6 @@ export class WebhookService implements OnApplicationShutdown { | ||||
|  | ||||
| 	@bindThis | ||||
| 	public onApplicationShutdown(signal?: string | undefined) { | ||||
| 		this.redisSubscriber.off('message', this.onMessage); | ||||
| 		this.redisForPubsub.off('message', this.onMessage); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -3,9 +3,9 @@ import escapeRegexp from 'escape-regexp'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import { Cache } from '@/misc/cache.js'; | ||||
| import { MemoryKVCache } from '@/misc/cache.js'; | ||||
| import type { UserPublickey } from '@/models/entities/UserPublickey.js'; | ||||
| import { UserCacheService } from '@/core/UserCacheService.js'; | ||||
| import { CacheService } from '@/core/CacheService.js'; | ||||
| import type { Note } from '@/models/entities/Note.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { RemoteUser, User } from '@/models/entities/User.js'; | ||||
| @@ -31,8 +31,8 @@ export type UriParseResult = { | ||||
|  | ||||
| @Injectable() | ||||
| export class ApDbResolverService { | ||||
| 	private publicKeyCache: Cache<UserPublickey | null>; | ||||
| 	private publicKeyByUserIdCache: Cache<UserPublickey | null>; | ||||
| 	private publicKeyCache: MemoryKVCache<UserPublickey | null>; | ||||
| 	private publicKeyByUserIdCache: MemoryKVCache<UserPublickey | null>; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| @@ -47,11 +47,11 @@ export class ApDbResolverService { | ||||
| 		@Inject(DI.userPublickeysRepository) | ||||
| 		private userPublickeysRepository: UserPublickeysRepository, | ||||
|  | ||||
| 		private userCacheService: UserCacheService, | ||||
| 		private cacheService: CacheService, | ||||
| 		private apPersonService: ApPersonService, | ||||
| 	) { | ||||
| 		this.publicKeyCache = new Cache<UserPublickey | null>(Infinity); | ||||
| 		this.publicKeyByUserIdCache = new Cache<UserPublickey | null>(Infinity); | ||||
| 		this.publicKeyCache = new MemoryKVCache<UserPublickey | null>(Infinity); | ||||
| 		this.publicKeyByUserIdCache = new MemoryKVCache<UserPublickey | null>(Infinity); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| @@ -107,11 +107,11 @@ export class ApDbResolverService { | ||||
| 		if (parsed.local) { | ||||
| 			if (parsed.type !== 'users') return null; | ||||
|  | ||||
| 			return await this.userCacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({ | ||||
| 			return await this.cacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({ | ||||
| 				id: parsed.id, | ||||
| 			}).then(x => x ?? undefined)) ?? null; | ||||
| 		} else { | ||||
| 			return await this.userCacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({ | ||||
| 			return await this.cacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({ | ||||
| 				uri: parsed.uri, | ||||
| 			})); | ||||
| 		} | ||||
| @@ -138,7 +138,7 @@ export class ApDbResolverService { | ||||
| 		if (key == null) return null; | ||||
|  | ||||
| 		return { | ||||
| 			user: await this.userCacheService.findById(key.userId) as RemoteUser, | ||||
| 			user: await this.cacheService.findUserById(key.userId) as RemoteUser, | ||||
| 			key, | ||||
| 		}; | ||||
| 	} | ||||
|   | ||||
| @@ -157,7 +157,8 @@ class DeliverManager { | ||||
| 	public async execute() { | ||||
| 		if (!this.userEntityService.isLocalUser(this.actor)) return; | ||||
|  | ||||
| 		const inboxes = new Set<string>(); | ||||
| 		// The value flags whether it is shared or not. | ||||
| 		const inboxes = new Map<string, boolean>(); | ||||
|  | ||||
| 		/* | ||||
| 		build inbox list | ||||
| @@ -185,7 +186,7 @@ class DeliverManager { | ||||
|  | ||||
| 			for (const following of followers) { | ||||
| 				const inbox = following.followerSharedInbox ?? following.followerInbox; | ||||
| 				inboxes.add(inbox); | ||||
| 				inboxes.set(inbox, following.followerSharedInbox === null); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| @@ -197,11 +198,12 @@ class DeliverManager { | ||||
| 			// check that they actually have an inbox | ||||
| 			&& recipe.to.inbox != null, | ||||
| 		) | ||||
| 			.forEach(recipe => inboxes.add(recipe.to.inbox!)); | ||||
| 			.forEach(recipe => inboxes.set(recipe.to.inbox!, false)); | ||||
|  | ||||
| 		// deliver | ||||
| 		for (const inbox of inboxes) { | ||||
| 			this.queueService.deliver(this.actor, this.activity, inbox); | ||||
| 			// inbox[0]: inbox, inbox[1]: whether it is sharedInbox | ||||
| 			this.queueService.deliver(this.actor, this.activity, inbox[0], inbox[1]); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { In } from 'typeorm'; | ||||
| import { In, IsNull } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import { UserFollowingService } from '@/core/UserFollowingService.js'; | ||||
| @@ -22,7 +22,7 @@ import { QueueService } from '@/core/QueueService.js'; | ||||
| import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/index.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import type { RemoteUser } from '@/models/entities/User.js'; | ||||
| import { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; | ||||
| import { getApHrefNullable, getApId, getApIds, getApType, getOneApHrefNullable, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; | ||||
| import { ApNoteService } from './models/ApNoteService.js'; | ||||
| import { ApLoggerService } from './ApLoggerService.js'; | ||||
| import { ApDbResolverService } from './ApDbResolverService.js'; | ||||
| @@ -31,7 +31,7 @@ import { ApAudienceService } from './ApAudienceService.js'; | ||||
| import { ApPersonService } from './models/ApPersonService.js'; | ||||
| import { ApQuestionService } from './models/ApQuestionService.js'; | ||||
| import type { Resolver } from './ApResolverService.js'; | ||||
| import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate } from './type.js'; | ||||
| import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove } from './type.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class ApInboxService { | ||||
| @@ -80,7 +80,7 @@ export class ApInboxService { | ||||
| 	) { | ||||
| 		this.logger = this.apLoggerService.logger; | ||||
| 	} | ||||
| 	 | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async performActivity(actor: RemoteUser, activity: IObject) { | ||||
| 		if (isCollectionOrOrderedCollection(activity)) { | ||||
| @@ -139,23 +139,25 @@ export class ApInboxService { | ||||
| 			await this.block(actor, activity); | ||||
| 		} else if (isFlag(activity)) { | ||||
| 			await this.flag(actor, activity); | ||||
| 		} else if (isMove(activity)) { | ||||
| 			//await this.move(actor, activity); | ||||
| 		} else { | ||||
| 			this.logger.warn(`unrecognized activity type: ${(activity as any).type}`); | ||||
| 			this.logger.warn(`unrecognized activity type: ${activity.type}`); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	private async follow(actor: RemoteUser, activity: IFollow): Promise<string> { | ||||
| 		const followee = await this.apDbResolverService.getUserFromApId(activity.object); | ||||
| 	 | ||||
|  | ||||
| 		if (followee == null) { | ||||
| 			return 'skip: followee not found'; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		if (followee.host != null) { | ||||
| 			return 'skip: フォローしようとしているユーザーはローカルユーザーではありません'; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		await this.userFollowingService.follow(actor, followee, activity.id); | ||||
| 		return 'ok'; | ||||
| 	} | ||||
| @@ -183,16 +185,16 @@ export class ApInboxService { | ||||
| 		const uri = activity.id ?? activity; | ||||
|  | ||||
| 		this.logger.info(`Accept: ${uri}`); | ||||
| 	 | ||||
|  | ||||
| 		const resolver = this.apResolverService.createResolver(); | ||||
| 	 | ||||
|  | ||||
| 		const object = await resolver.resolve(activity.object).catch(err => { | ||||
| 			this.logger.error(`Resolution failed: ${err}`); | ||||
| 			throw err; | ||||
| 		}); | ||||
| 	 | ||||
|  | ||||
| 		if (isFollow(object)) return await this.acceptFollow(actor, object); | ||||
| 	 | ||||
|  | ||||
| 		return `skip: Unknown Accept type: ${getApType(object)}`; | ||||
| 	} | ||||
|  | ||||
| @@ -225,18 +227,18 @@ export class ApInboxService { | ||||
| 		if ('actor' in activity && actor.uri !== activity.actor) { | ||||
| 			throw new Error('invalid actor'); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		if (activity.target == null) { | ||||
| 			throw new Error('target is null'); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		if (activity.target === actor.featured) { | ||||
| 			const note = await this.apNoteService.resolveNote(activity.object); | ||||
| 			if (note == null) throw new Error('note not found'); | ||||
| 			await this.notePiningService.addPinned(actor, note.id); | ||||
| 			return; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		throw new Error(`unknown target: ${activity.target}`); | ||||
| 	} | ||||
|  | ||||
| @@ -405,10 +407,10 @@ export class ApInboxService { | ||||
| 		if ('actor' in activity && actor.uri !== activity.actor) { | ||||
| 			throw new Error('invalid actor'); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		// 削除対象objectのtype | ||||
| 		let formerType: string | undefined; | ||||
| 	 | ||||
|  | ||||
| 		if (typeof activity.object === 'string') { | ||||
| 			// typeが不明だけど、どうせ消えてるのでremote resolveしない | ||||
| 			formerType = undefined; | ||||
| @@ -420,19 +422,19 @@ export class ApInboxService { | ||||
| 				formerType = toSingle(object.type); | ||||
| 			} | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		const uri = getApId(activity.object); | ||||
| 	 | ||||
|  | ||||
| 		// type不明でもactorとobjectが同じならばそれはPersonに違いない | ||||
| 		if (!formerType && actor.uri === uri) { | ||||
| 			formerType = 'Person'; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		// それでもなかったらおそらくNote | ||||
| 		if (!formerType) { | ||||
| 			formerType = 'Note'; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		if (validPost.includes(formerType)) { | ||||
| 			return await this.deleteNote(actor, uri); | ||||
| 		} else if (validActor.includes(formerType)) { | ||||
| @@ -445,44 +447,44 @@ export class ApInboxService { | ||||
| 	@bindThis | ||||
| 	private async deleteActor(actor: RemoteUser, uri: string): Promise<string> { | ||||
| 		this.logger.info(`Deleting the Actor: ${uri}`); | ||||
| 	 | ||||
|  | ||||
| 		if (actor.uri !== uri) { | ||||
| 			return `skip: delete actor ${actor.uri} !== ${uri}`; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		const user = await this.usersRepository.findOneBy({ id: actor.id }); | ||||
| 		if (user == null) { | ||||
| 			return 'skip: actor not found'; | ||||
| 		} else if (user.isDeleted) { | ||||
| 			return 'skip: already deleted'; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		const job = await this.queueService.createDeleteAccountJob(actor); | ||||
| 	 | ||||
|  | ||||
| 		await this.usersRepository.update(actor.id, { | ||||
| 			isDeleted: true, | ||||
| 		}); | ||||
| 	 | ||||
|  | ||||
| 		return `ok: queued ${job.name} ${job.id}`; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	private async deleteNote(actor: RemoteUser, uri: string): Promise<string> { | ||||
| 		this.logger.info(`Deleting the Note: ${uri}`); | ||||
| 	 | ||||
|  | ||||
| 		const unlock = await this.appLockService.getApLock(uri); | ||||
| 	 | ||||
|  | ||||
| 		try { | ||||
| 			const note = await this.apDbResolverService.getNoteFromApId(uri); | ||||
| 	 | ||||
|  | ||||
| 			if (note == null) { | ||||
| 				return 'message not found'; | ||||
| 			} | ||||
| 	 | ||||
|  | ||||
| 			if (note.userId !== actor.id) { | ||||
| 				return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; | ||||
| 			} | ||||
| 	 | ||||
|  | ||||
| 			await this.noteDeleteService.delete(actor, note); | ||||
| 			return 'ok: note deleted'; | ||||
| 		} finally { | ||||
| @@ -536,23 +538,23 @@ export class ApInboxService { | ||||
| 	@bindThis | ||||
| 	private async rejectFollow(actor: RemoteUser, activity: IFollow): Promise<string> { | ||||
| 		// ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある | ||||
| 	 | ||||
|  | ||||
| 		const follower = await this.apDbResolverService.getUserFromApId(activity.actor); | ||||
| 	 | ||||
|  | ||||
| 		if (follower == null) { | ||||
| 			return 'skip: follower not found'; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		if (!this.userEntityService.isLocalUser(follower)) { | ||||
| 			return 'skip: follower is not a local user'; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		// relay | ||||
| 		const match = activity.id?.match(/follow-relay\/(\w+)/); | ||||
| 		if (match) { | ||||
| 			return await this.relayService.relayRejected(match[1]); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		await this.userFollowingService.remoteReject(actor, follower); | ||||
| 		return 'ok'; | ||||
| 	} | ||||
| @@ -562,18 +564,18 @@ export class ApInboxService { | ||||
| 		if ('actor' in activity && actor.uri !== activity.actor) { | ||||
| 			throw new Error('invalid actor'); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		if (activity.target == null) { | ||||
| 			throw new Error('target is null'); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		if (activity.target === actor.featured) { | ||||
| 			const note = await this.apNoteService.resolveNote(activity.object); | ||||
| 			if (note == null) throw new Error('note not found'); | ||||
| 			await this.notePiningService.removePinned(actor, note.id); | ||||
| 			return; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		throw new Error(`unknown target: ${activity.target}`); | ||||
| 	} | ||||
|  | ||||
| @@ -582,24 +584,24 @@ export class ApInboxService { | ||||
| 		if ('actor' in activity && actor.uri !== activity.actor) { | ||||
| 			throw new Error('invalid actor'); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		const uri = activity.id ?? activity; | ||||
| 	 | ||||
|  | ||||
| 		this.logger.info(`Undo: ${uri}`); | ||||
| 	 | ||||
|  | ||||
| 		const resolver = this.apResolverService.createResolver(); | ||||
| 	 | ||||
|  | ||||
| 		const object = await resolver.resolve(activity.object).catch(e => { | ||||
| 			this.logger.error(`Resolution failed: ${e}`); | ||||
| 			throw e; | ||||
| 		}); | ||||
| 	 | ||||
|  | ||||
| 		if (isFollow(object)) return await this.undoFollow(actor, object); | ||||
| 		if (isBlock(object)) return await this.undoBlock(actor, object); | ||||
| 		if (isLike(object)) return await this.undoLike(actor, object); | ||||
| 		if (isAnnounce(object)) return await this.undoAnnounce(actor, object); | ||||
| 		if (isAccept(object)) return await this.undoAccept(actor, object); | ||||
| 	 | ||||
|  | ||||
| 		return `skip: unknown object type ${getApType(object)}`; | ||||
| 	} | ||||
|  | ||||
| @@ -609,17 +611,17 @@ export class ApInboxService { | ||||
| 		if (follower == null) { | ||||
| 			return 'skip: follower not found'; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		const following = await this.followingsRepository.findOneBy({ | ||||
| 			followerId: follower.id, | ||||
| 			followeeId: actor.id, | ||||
| 		}); | ||||
| 	 | ||||
|  | ||||
| 		if (following) { | ||||
| 			await this.userFollowingService.unfollow(follower, actor); | ||||
| 			return 'ok: unfollowed'; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		return 'skip: フォローされていない'; | ||||
| 	} | ||||
|  | ||||
| @@ -708,16 +710,16 @@ export class ApInboxService { | ||||
| 		if ('actor' in activity && actor.uri !== activity.actor) { | ||||
| 			return 'skip: invalid actor'; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		this.logger.debug('Update'); | ||||
| 	 | ||||
|  | ||||
| 		const resolver = this.apResolverService.createResolver(); | ||||
| 	 | ||||
|  | ||||
| 		const object = await resolver.resolve(activity.object).catch(e => { | ||||
| 			this.logger.error(`Resolution failed: ${e}`); | ||||
| 			throw e; | ||||
| 		}); | ||||
| 	 | ||||
|  | ||||
| 		if (isActor(object)) { | ||||
| 			await this.apPersonService.updatePerson(actor.uri!, resolver, object); | ||||
| 			return 'ok: Person updated'; | ||||
| @@ -728,4 +730,59 @@ export class ApInboxService { | ||||
| 			return `skip: Unknown type: ${getApType(object)}`; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	private async move(actor: RemoteUser, activity: IMove): Promise<string> { | ||||
| 		// fetch the new and old accounts | ||||
| 		const targetUri = getApHrefNullable(activity.target); | ||||
| 		if (!targetUri) return 'skip: invalid activity target'; | ||||
| 		let new_acc = await this.apPersonService.resolvePerson(targetUri); | ||||
| 		let old_acc = await this.apPersonService.resolvePerson(actor.uri); | ||||
|  | ||||
| 		// update them if they're remote | ||||
| 		if (new_acc.uri) await this.apPersonService.updatePerson(new_acc.uri); | ||||
| 		if (old_acc.uri) await this.apPersonService.updatePerson(old_acc.uri); | ||||
|  | ||||
| 		// retrieve updated users | ||||
| 		new_acc = await this.apPersonService.resolvePerson(targetUri); | ||||
| 		old_acc = await this.apPersonService.resolvePerson(actor.uri); | ||||
|  | ||||
| 		// check if alsoKnownAs of the new account is valid | ||||
| 		let isValidMove = true; | ||||
| 		if (old_acc.uri) { | ||||
| 			if (!new_acc.alsoKnownAs?.includes(old_acc.uri)) { | ||||
| 				isValidMove = false; | ||||
| 			} | ||||
| 		} else if (!new_acc.alsoKnownAs?.includes(old_acc.id)) { | ||||
| 			isValidMove = false; | ||||
| 		} | ||||
| 		if (!isValidMove) { | ||||
| 			return 'skip: accounts invalid'; | ||||
| 		} | ||||
|  | ||||
| 		// add target uri to movedToUri in order to indicate that the user has moved | ||||
| 		await this.usersRepository.update(old_acc.id, { movedToUri: targetUri }); | ||||
|  | ||||
| 		// follow the new account and unfollow the old one | ||||
| 		const followings = await this.followingsRepository.find({ | ||||
| 			relations: { | ||||
| 				follower: true, | ||||
| 			}, | ||||
| 			where: { | ||||
| 				followeeId: old_acc.id, | ||||
| 				followerHost: IsNull(), // follower is local | ||||
| 			}, | ||||
| 		}); | ||||
| 		for (const following of followings) { | ||||
| 			if (!following.follower) continue; | ||||
| 			try { | ||||
| 				await this.userFollowingService.follow(following.follower, new_acc); | ||||
| 				await this.userFollowingService.unfollow(following.follower, old_acc); | ||||
| 			} catch { | ||||
| 				/* empty */ | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return 'ok'; | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -14,16 +14,18 @@ import type { NoteReaction } from '@/models/entities/NoteReaction.js'; | ||||
| import type { Emoji } from '@/models/entities/Emoji.js'; | ||||
| import type { Poll } from '@/models/entities/Poll.js'; | ||||
| import type { PollVote } from '@/models/entities/PollVote.js'; | ||||
| import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; | ||||
| import { UserKeypairService } from '@/core/UserKeypairService.js'; | ||||
| import { MfmService } from '@/core/MfmService.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; | ||||
| import type { UserKeypair } from '@/models/entities/UserKeypair.js'; | ||||
| import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, EmojisRepository, PollsRepository } from '@/models/index.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { CustomEmojiService } from '@/core/CustomEmojiService.js'; | ||||
| import { isNotNull } from '@/misc/is-not-null.js'; | ||||
| import { LdSignatureService } from './LdSignatureService.js'; | ||||
| import { ApMfmService } from './ApMfmService.js'; | ||||
| import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; | ||||
| import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; | ||||
| import type { IIdentifier } from './models/identifier.js'; | ||||
|  | ||||
| @Injectable() | ||||
| @@ -50,10 +52,11 @@ export class ApRendererService { | ||||
| 		@Inject(DI.pollsRepository) | ||||
| 		private pollsRepository: PollsRepository, | ||||
|  | ||||
| 		private customEmojiService: CustomEmojiService, | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private driveFileEntityService: DriveFileEntityService, | ||||
| 		private ldSignatureService: LdSignatureService, | ||||
| 		private userKeypairStoreService: UserKeypairStoreService, | ||||
| 		private userKeypairService: UserKeypairService, | ||||
| 		private apMfmService: ApMfmService, | ||||
| 		private mfmService: MfmService, | ||||
| 	) { | ||||
| @@ -91,6 +94,9 @@ export class ApRendererService { | ||||
| 		} else if (note.visibility === 'home') { | ||||
| 			to = [`${attributedTo}/followers`]; | ||||
| 			cc = ['https://www.w3.org/ns/activitystreams#Public']; | ||||
| 		} else if (note.visibility === 'followers') { | ||||
| 			to = [`${attributedTo}/followers`]; | ||||
| 			cc = []; | ||||
| 		} else { | ||||
| 			throw new Error('renderAnnounce: cannot render non-public note'); | ||||
| 		} | ||||
| @@ -116,7 +122,7 @@ export class ApRendererService { | ||||
| 		if (block.blockee?.uri == null) { | ||||
| 			throw new Error('renderBlock: missing blockee uri'); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		return { | ||||
| 			type: 'Block', | ||||
| 			id: `${this.config.url}/blocks/${block.id}`, | ||||
| @@ -134,10 +140,10 @@ export class ApRendererService { | ||||
| 			published: note.createdAt.toISOString(), | ||||
| 			object, | ||||
| 		} as ICreate; | ||||
| 	 | ||||
|  | ||||
| 		if (object.to) activity.to = object.to; | ||||
| 		if (object.cc) activity.cc = object.cc; | ||||
| 	 | ||||
|  | ||||
| 		return activity; | ||||
| 	} | ||||
|  | ||||
| @@ -155,7 +161,7 @@ export class ApRendererService { | ||||
| 	public renderDocument(file: DriveFile): IApDocument { | ||||
| 		return { | ||||
| 			type: 'Document', | ||||
| 			mediaType: file.type, | ||||
| 			mediaType: file.webpublicType ?? file.type, | ||||
| 			url: this.driveFileEntityService.getPublicUrl(file), | ||||
| 			name: file.comment, | ||||
| 		}; | ||||
| @@ -269,11 +275,7 @@ export class ApRendererService { | ||||
|  | ||||
| 		if (reaction.startsWith(':')) { | ||||
| 			const name = reaction.replaceAll(':', ''); | ||||
| 			// TODO: cache | ||||
| 			const emoji = await this.emojisRepository.findOneBy({ | ||||
| 				name, | ||||
| 				host: IsNull(), | ||||
| 			}); | ||||
| 			const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name); | ||||
|  | ||||
| 			if (emoji) object.tag = [this.renderEmoji(emoji)]; | ||||
| 		} | ||||
| @@ -290,6 +292,22 @@ export class ApRendererService { | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public renderMove( | ||||
| 		src: { id: User['id']; host: User['host']; uri: User['host'] }, | ||||
| 		dst: { id: User['id']; host: User['host']; uri: User['host'] }, | ||||
| 	): IMove { | ||||
| 		const actor = this.userEntityService.isLocalUser(src) ? `${this.config.url}/users/${src.id}` : src.uri!; | ||||
| 		const target = this.userEntityService.isLocalUser(dst) ? `${this.config.url}/users/${dst.id}` : dst.uri!; | ||||
| 		return { | ||||
| 			id: `${this.config.url}/moves/${src.id}/${dst.id}`, | ||||
| 			actor, | ||||
| 			type: 'Move', | ||||
| 			object: actor, | ||||
| 			target, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async renderNote(note: Note, dive = true): Promise<IPost> { | ||||
| 		const getPromisedFiles = async (ids: string[]) => { | ||||
| @@ -297,16 +315,16 @@ export class ApRendererService { | ||||
| 			const items = await this.driveFilesRepository.findBy({ id: In(ids) }); | ||||
| 			return ids.map(id => items.find(item => item.id === id)).filter(item => item != null) as DriveFile[]; | ||||
| 		}; | ||||
| 	 | ||||
|  | ||||
| 		let inReplyTo; | ||||
| 		let inReplyToNote: Note | null; | ||||
| 	 | ||||
|  | ||||
| 		if (note.replyId) { | ||||
| 			inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); | ||||
| 	 | ||||
|  | ||||
| 			if (inReplyToNote != null) { | ||||
| 				const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId }); | ||||
| 	 | ||||
|  | ||||
| 				if (inReplyToUser != null) { | ||||
| 					if (inReplyToNote.uri) { | ||||
| 						inReplyTo = inReplyToNote.uri; | ||||
| @@ -322,24 +340,24 @@ export class ApRendererService { | ||||
| 		} else { | ||||
| 			inReplyTo = null; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		let quote; | ||||
| 	 | ||||
|  | ||||
| 		if (note.renoteId) { | ||||
| 			const renote = await this.notesRepository.findOneBy({ id: note.renoteId }); | ||||
| 	 | ||||
|  | ||||
| 			if (renote) { | ||||
| 				quote = renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`; | ||||
| 			} | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		const attributedTo = `${this.config.url}/users/${note.userId}`; | ||||
| 	 | ||||
|  | ||||
| 		const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); | ||||
| 	 | ||||
|  | ||||
| 		let to: string[] = []; | ||||
| 		let cc: string[] = []; | ||||
| 	 | ||||
|  | ||||
| 		if (note.visibility === 'public') { | ||||
| 			to = ['https://www.w3.org/ns/activitystreams#Public']; | ||||
| 			cc = [`${attributedTo}/followers`].concat(mentions); | ||||
| @@ -352,44 +370,44 @@ export class ApRendererService { | ||||
| 		} else { | ||||
| 			to = mentions; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		const mentionedUsers = note.mentions.length > 0 ? await this.usersRepository.findBy({ | ||||
| 			id: In(note.mentions), | ||||
| 		}) : []; | ||||
| 	 | ||||
|  | ||||
| 		const hashtagTags = (note.tags ?? []).map(tag => this.renderHashtag(tag)); | ||||
| 		const mentionTags = mentionedUsers.map(u => this.renderMention(u)); | ||||
| 	 | ||||
|  | ||||
| 		const files = await getPromisedFiles(note.fileIds); | ||||
| 	 | ||||
|  | ||||
| 		const text = note.text ?? ''; | ||||
| 		let poll: Poll | null = null; | ||||
| 	 | ||||
|  | ||||
| 		if (note.hasPoll) { | ||||
| 			poll = await this.pollsRepository.findOneBy({ noteId: note.id }); | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		let apText = text; | ||||
| 	 | ||||
|  | ||||
| 		if (quote) { | ||||
| 			apText += `\n\nRE: ${quote}`; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; | ||||
| 	 | ||||
|  | ||||
| 		const content = this.apMfmService.getNoteHtml(Object.assign({}, note, { | ||||
| 			text: apText, | ||||
| 		})); | ||||
| 	 | ||||
|  | ||||
| 		const emojis = await this.getEmojis(note.emojis); | ||||
| 		const apemojis = emojis.map(emoji => this.renderEmoji(emoji)); | ||||
| 	 | ||||
|  | ||||
| 		const tag = [ | ||||
| 			...hashtagTags, | ||||
| 			...mentionTags, | ||||
| 			...apemojis, | ||||
| 		]; | ||||
| 	 | ||||
|  | ||||
| 		const asPoll = poll ? { | ||||
| 			type: 'Question', | ||||
| 			content: this.apMfmService.getNoteHtml(Object.assign({}, note, { | ||||
| @@ -470,7 +488,7 @@ export class ApRendererService { | ||||
| 			...hashtagTags, | ||||
| 		]; | ||||
|  | ||||
| 		const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); | ||||
| 		const keypair = await this.userKeypairService.getUserKeypair(user.id); | ||||
|  | ||||
| 		const person = { | ||||
| 			type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person', | ||||
| @@ -496,6 +514,14 @@ export class ApRendererService { | ||||
| 			attachment: attachment.length ? attachment : undefined, | ||||
| 		} as any; | ||||
|  | ||||
| 		if (user.movedToUri) { | ||||
| 			person.movedTo = user.movedToUri; | ||||
| 		} | ||||
|  | ||||
| 		if (user.alsoKnownAs) { | ||||
| 			person.alsoKnownAs = user.alsoKnownAs; | ||||
| 		} | ||||
|  | ||||
| 		if (profile.birthday) { | ||||
| 			person['vcard:bday'] = profile.birthday; | ||||
| 		} | ||||
| @@ -601,7 +627,7 @@ export class ApRendererService { | ||||
| 		if (typeof x === 'object' && x.id == null) { | ||||
| 			x.id = `${this.config.url}/${uuid()}`; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		return Object.assign({ | ||||
| 			'@context': [ | ||||
| 				'https://www.w3.org/ns/activitystreams', | ||||
| @@ -634,18 +660,18 @@ export class ApRendererService { | ||||
| 			], | ||||
| 		}, x as T & { id: string; }); | ||||
| 	} | ||||
| 	 | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async attachLdSignature(activity: any, user: { id: User['id']; host: null; }): Promise<IActivity> { | ||||
| 		const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); | ||||
| 	 | ||||
| 		const keypair = await this.userKeypairService.getUserKeypair(user.id); | ||||
|  | ||||
| 		const ldSignature = this.ldSignatureService.use(); | ||||
| 		ldSignature.debug = false; | ||||
| 		activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); | ||||
| 	 | ||||
|  | ||||
| 		return activity; | ||||
| 	} | ||||
| 	 | ||||
|  | ||||
| 	/** | ||||
| 	 * Render OrderedCollectionPage | ||||
| 	 * @param id URL of self | ||||
| @@ -686,11 +712,11 @@ export class ApRendererService { | ||||
| 			type: 'OrderedCollection', | ||||
| 			totalItems, | ||||
| 		}; | ||||
| 	 | ||||
|  | ||||
| 		if (first) page.first = first; | ||||
| 		if (last) page.last = last; | ||||
| 		if (orderedItems) page.orderedItems = orderedItems; | ||||
| 	 | ||||
|  | ||||
| 		return page; | ||||
| 	} | ||||
|  | ||||
| @@ -698,13 +724,9 @@ export class ApRendererService { | ||||
| 	private async getEmojis(names: string[]): Promise<Emoji[]> { | ||||
| 		if (names == null || names.length === 0) return []; | ||||
|  | ||||
| 		const emojis = await Promise.all( | ||||
| 			names.map(name => this.emojisRepository.findOneBy({ | ||||
| 				name, | ||||
| 				host: IsNull(), | ||||
| 			})), | ||||
| 		); | ||||
| 		const allEmojis = await this.customEmojiService.localEmojisCache.fetch(); | ||||
| 		const emojis = names.map(name => allEmojis.get(name)).filter(isNotNull); | ||||
|  | ||||
| 		return emojis.filter(emoji => emoji != null) as Emoji[]; | ||||
| 		return emojis; | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -4,7 +4,7 @@ import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; | ||||
| import { UserKeypairService } from '@/core/UserKeypairService.js'; | ||||
| import { HttpRequestService } from '@/core/HttpRequestService.js'; | ||||
| import { LoggerService } from '@/core/LoggerService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| @@ -131,7 +131,7 @@ export class ApRequestService { | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 		private userKeypairStoreService: UserKeypairStoreService, | ||||
| 		private userKeypairService: UserKeypairService, | ||||
| 		private httpRequestService: HttpRequestService, | ||||
| 		private loggerService: LoggerService, | ||||
| 	) { | ||||
| @@ -143,7 +143,7 @@ export class ApRequestService { | ||||
| 	public async signedPost(user: { id: User['id'] }, url: string, object: any) { | ||||
| 		const body = JSON.stringify(object); | ||||
|  | ||||
| 		const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); | ||||
| 		const keypair = await this.userKeypairService.getUserKeypair(user.id); | ||||
|  | ||||
| 		const req = ApRequestCreator.createSignedPost({ | ||||
| 			key: { | ||||
| @@ -170,7 +170,7 @@ export class ApRequestService { | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async signedGet(url: string, user: { id: User['id'] }) { | ||||
| 		const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); | ||||
| 		const keypair = await this.userKeypairService.getUserKeypair(user.id); | ||||
|  | ||||
| 		const req = ApRequestCreator.createSignedGet({ | ||||
| 			key: { | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import { forwardRef, Inject, Injectable } from '@nestjs/common'; | ||||
| import promiseLimit from 'promise-limit'; | ||||
| import { In } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { PollsRepository, EmojisRepository } from '@/models/index.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| @@ -124,7 +125,7 @@ export class ApNoteService { | ||||
| 			throw new Error('invalid note'); | ||||
| 		} | ||||
| 	 | ||||
| 		const note: IPost = object as any; | ||||
| 		const note = object as IPost; | ||||
| 	 | ||||
| 		this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); | ||||
|  | ||||
| @@ -180,7 +181,7 @@ export class ApNoteService { | ||||
| 		const reply: Note | null = note.inReplyTo | ||||
| 			? await this.resolveNote(note.inReplyTo, resolver).then(x => { | ||||
| 				if (x == null) { | ||||
| 					this.logger.warn('Specified inReplyTo, but nout found'); | ||||
| 					this.logger.warn('Specified inReplyTo, but not found'); | ||||
| 					throw new Error('inReplyTo not found'); | ||||
| 				} else { | ||||
| 					return x; | ||||
| @@ -341,15 +342,17 @@ export class ApNoteService { | ||||
| 		if (!tags) return []; | ||||
| 	 | ||||
| 		const eomjiTags = toArray(tags).filter(isEmoji); | ||||
|  | ||||
| 		const existingEmojis = await this.emojisRepository.findBy({ | ||||
| 			host, | ||||
| 			name: In(eomjiTags.map(tag => tag.name!.replaceAll(':', ''))), | ||||
| 		}); | ||||
| 	 | ||||
| 		return await Promise.all(eomjiTags.map(async tag => { | ||||
| 			const name = tag.name!.replace(/^:/, '').replace(/:$/, ''); | ||||
| 			const name = tag.name!.replaceAll(':', ''); | ||||
| 			tag.icon = toSingle(tag.icon); | ||||
| 	 | ||||
| 			const exists = await this.emojisRepository.findOneBy({ | ||||
| 				host, | ||||
| 				name, | ||||
| 			}); | ||||
| 			const exists = existingEmojis.find(x => x.name === name); | ||||
| 	 | ||||
| 			if (exists) { | ||||
| 				if ((tag.updated != null && exists.updatedAt == null) | ||||
|   | ||||
| @@ -8,7 +8,7 @@ import type { Config } from '@/config.js'; | ||||
| import type { RemoteUser } from '@/models/entities/User.js'; | ||||
| import { User } from '@/models/entities/User.js'; | ||||
| import { truncate } from '@/misc/truncate.js'; | ||||
| import type { UserCacheService } from '@/core/UserCacheService.js'; | ||||
| import type { CacheService } from '@/core/CacheService.js'; | ||||
| import { normalizeForSearch } from '@/misc/normalize-for-search.js'; | ||||
| import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; | ||||
| import type Logger from '@/logger.js'; | ||||
| @@ -30,6 +30,8 @@ import { StatusError } from '@/misc/status-error.js'; | ||||
| import type { UtilityService } from '@/core/UtilityService.js'; | ||||
| import type { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; | ||||
| import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; | ||||
| import { extractApHashtags } from './tag.js'; | ||||
| import type { OnModuleInit } from '@nestjs/common'; | ||||
| @@ -48,11 +50,13 @@ const summaryLength = 2048; | ||||
| export class ApPersonService implements OnModuleInit { | ||||
| 	private utilityService: UtilityService; | ||||
| 	private userEntityService: UserEntityService; | ||||
| 	private driveFileEntityService: DriveFileEntityService; | ||||
| 	private idService: IdService; | ||||
| 	private globalEventService: GlobalEventService; | ||||
| 	private metaService: MetaService; | ||||
| 	private federatedInstanceService: FederatedInstanceService; | ||||
| 	private fetchInstanceMetadataService: FetchInstanceMetadataService; | ||||
| 	private userCacheService: UserCacheService; | ||||
| 	private cacheService: CacheService; | ||||
| 	private apResolverService: ApResolverService; | ||||
| 	private apNoteService: ApNoteService; | ||||
| 	private apImageService: ApImageService; | ||||
| @@ -92,9 +96,10 @@ export class ApPersonService implements OnModuleInit { | ||||
| 		//private userEntityService: UserEntityService, | ||||
| 		//private idService: IdService, | ||||
| 		//private globalEventService: GlobalEventService, | ||||
| 		//private metaService: MetaService, | ||||
| 		//private federatedInstanceService: FederatedInstanceService, | ||||
| 		//private fetchInstanceMetadataService: FetchInstanceMetadataService, | ||||
| 		//private userCacheService: UserCacheService, | ||||
| 		//private cacheService: CacheService, | ||||
| 		//private apResolverService: ApResolverService, | ||||
| 		//private apNoteService: ApNoteService, | ||||
| 		//private apImageService: ApImageService, | ||||
| @@ -110,11 +115,13 @@ export class ApPersonService implements OnModuleInit { | ||||
| 	onModuleInit() { | ||||
| 		this.utilityService = this.moduleRef.get('UtilityService'); | ||||
| 		this.userEntityService = this.moduleRef.get('UserEntityService'); | ||||
| 		this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); | ||||
| 		this.idService = this.moduleRef.get('IdService'); | ||||
| 		this.globalEventService = this.moduleRef.get('GlobalEventService'); | ||||
| 		this.metaService = this.moduleRef.get('MetaService'); | ||||
| 		this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); | ||||
| 		this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService'); | ||||
| 		this.userCacheService = this.moduleRef.get('UserCacheService'); | ||||
| 		this.cacheService = this.moduleRef.get('CacheService'); | ||||
| 		this.apResolverService = this.moduleRef.get('ApResolverService'); | ||||
| 		this.apNoteService = this.moduleRef.get('ApNoteService'); | ||||
| 		this.apImageService = this.moduleRef.get('ApImageService'); | ||||
| @@ -164,6 +171,9 @@ export class ApPersonService implements OnModuleInit { | ||||
| 				throw new Error('invalid Actor: wrong name'); | ||||
| 			} | ||||
| 			x.name = truncate(x.name, nameLength); | ||||
| 		} else if (x.name === '') { | ||||
| 			// Mastodon emits empty string when the name is not set. | ||||
| 			x.name = undefined; | ||||
| 		} | ||||
| 		if (x.summary) { | ||||
| 			if (!(typeof x.summary === 'string' && x.summary.length > 0)) { | ||||
| @@ -200,14 +210,14 @@ export class ApPersonService implements OnModuleInit { | ||||
| 	public async fetchPerson(uri: string, resolver?: Resolver): Promise<User | null> { | ||||
| 		if (typeof uri !== 'string') throw new Error('uri is not string'); | ||||
|  | ||||
| 		const cached = this.userCacheService.uriPersonCache.get(uri); | ||||
| 		const cached = this.cacheService.uriPersonCache.get(uri); | ||||
| 		if (cached) return cached; | ||||
|  | ||||
| 		// URIがこのサーバーを指しているならデータベースからフェッチ | ||||
| 		if (uri.startsWith(this.config.url + '/')) { | ||||
| 			const id = uri.split('/').pop(); | ||||
| 			const u = await this.usersRepository.findOneBy({ id }); | ||||
| 			if (u) this.userCacheService.uriPersonCache.set(uri, u); | ||||
| 			if (u) this.cacheService.uriPersonCache.set(uri, u); | ||||
| 			return u; | ||||
| 		} | ||||
|  | ||||
| @@ -215,7 +225,7 @@ export class ApPersonService implements OnModuleInit { | ||||
| 		const exist = await this.usersRepository.findOneBy({ uri }); | ||||
|  | ||||
| 		if (exist) { | ||||
| 			this.userCacheService.uriPersonCache.set(uri, exist); | ||||
| 			this.cacheService.uriPersonCache.set(uri, exist); | ||||
| 			return exist; | ||||
| 		} | ||||
| 		//#endregion | ||||
| @@ -271,6 +281,8 @@ export class ApPersonService implements OnModuleInit { | ||||
| 					lastFetchedAt: new Date(), | ||||
| 					name: truncate(person.name, nameLength), | ||||
| 					isLocked: !!person.manuallyApprovesFollowers, | ||||
| 					movedToUri: person.movedTo, | ||||
| 					alsoKnownAs: person.alsoKnownAs, | ||||
| 					isExplorable: !!person.discoverable, | ||||
| 					username: person.preferredUsername, | ||||
| 					usernameLower: person.preferredUsername!.toLowerCase(), | ||||
| @@ -324,10 +336,12 @@ export class ApPersonService implements OnModuleInit { | ||||
| 		} | ||||
|  | ||||
| 		// Register host | ||||
| 		this.federatedInstanceService.fetch(host).then(i => { | ||||
| 		this.federatedInstanceService.fetch(host).then(async i => { | ||||
| 			this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); | ||||
| 			this.instanceChart.newUser(i.host); | ||||
| 			this.fetchInstanceMetadataService.fetchInstanceMetadata(i); | ||||
| 			if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { | ||||
| 				this.instanceChart.newUser(i.host); | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		this.usersChart.update(user!, true); | ||||
| @@ -347,32 +361,44 @@ export class ApPersonService implements OnModuleInit { | ||||
|  | ||||
| 		const avatarId = avatar ? avatar.id : null; | ||||
| 		const bannerId = banner ? banner.id : null; | ||||
| 		const avatarUrl = avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null; | ||||
| 		const bannerUrl = banner ? this.driveFileEntityService.getPublicUrl(banner) : null; | ||||
| 		const avatarBlurhash = avatar ? avatar.blurhash : null; | ||||
| 		const bannerBlurhash = banner ? banner.blurhash : null; | ||||
|  | ||||
| 		await this.usersRepository.update(user!.id, { | ||||
| 			avatarId, | ||||
| 			bannerId, | ||||
| 			avatarUrl, | ||||
| 			bannerUrl, | ||||
| 			avatarBlurhash, | ||||
| 			bannerBlurhash, | ||||
| 		}); | ||||
|  | ||||
| 	user!.avatarId = avatarId; | ||||
| 	user!.bannerId = bannerId; | ||||
| 	//#endregion | ||||
| 		user!.avatarId = avatarId; | ||||
| 		user!.bannerId = bannerId; | ||||
| 		user!.avatarUrl = avatarUrl; | ||||
| 		user!.bannerUrl = bannerUrl; | ||||
| 		user!.avatarBlurhash = avatarBlurhash; | ||||
| 		user!.bannerBlurhash = bannerBlurhash; | ||||
| 		//#endregion | ||||
|  | ||||
| 	//#region カスタム絵文字取得 | ||||
| 	const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => { | ||||
| 		this.logger.info(`extractEmojis: ${err}`); | ||||
| 		return [] as Emoji[]; | ||||
| 	}); | ||||
| 		//#region カスタム絵文字取得 | ||||
| 		const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => { | ||||
| 			this.logger.info(`extractEmojis: ${err}`); | ||||
| 			return [] as Emoji[]; | ||||
| 		}); | ||||
|  | ||||
| 	const emojiNames = emojis.map(emoji => emoji.name); | ||||
| 		const emojiNames = emojis.map(emoji => emoji.name); | ||||
|  | ||||
| 	await this.usersRepository.update(user!.id, { | ||||
| 		emojis: emojiNames, | ||||
| 	}); | ||||
| 	//#endregion | ||||
| 		await this.usersRepository.update(user!.id, { | ||||
| 			emojis: emojiNames, | ||||
| 		}); | ||||
| 		//#endregion | ||||
|  | ||||
| 	await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err)); | ||||
| 		await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err)); | ||||
|  | ||||
| 	return user!; | ||||
| 		return user!; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| @@ -449,15 +475,21 @@ export class ApPersonService implements OnModuleInit { | ||||
| 			isBot: getApType(object) === 'Service', | ||||
| 			isCat: (person as any).isCat === true, | ||||
| 			isLocked: !!person.manuallyApprovesFollowers, | ||||
| 			movedToUri: person.movedTo ?? null, | ||||
| 			alsoKnownAs: person.alsoKnownAs ?? null, | ||||
| 			isExplorable: !!person.discoverable, | ||||
| 		} as Partial<User>; | ||||
|  | ||||
| 		if (avatar) { | ||||
| 			updates.avatarId = avatar.id; | ||||
| 			updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); | ||||
| 			updates.avatarBlurhash = avatar.blurhash; | ||||
| 		} | ||||
|  | ||||
| 		if (banner) { | ||||
| 			updates.bannerId = banner.id; | ||||
| 			updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); | ||||
| 			updates.bannerBlurhash = banner.blurhash; | ||||
| 		} | ||||
|  | ||||
| 		// Update user | ||||
|   | ||||
| @@ -157,6 +157,8 @@ export interface IActor extends IObject { | ||||
| 	name?: string; | ||||
| 	preferredUsername?: string; | ||||
| 	manuallyApprovesFollowers?: boolean; | ||||
| 	movedTo?: string; | ||||
| 	alsoKnownAs?: string[]; | ||||
| 	discoverable?: boolean; | ||||
| 	inbox: string; | ||||
| 	sharedInbox?: string;	// 後方互換性のため | ||||
| @@ -195,7 +197,8 @@ export const isPropertyValue = (object: IObject): object is IApPropertyValue => | ||||
| 	object && | ||||
| 	getApType(object) === 'PropertyValue' && | ||||
| 	typeof object.name === 'string' && | ||||
| 	typeof (object as any).value === 'string'; | ||||
| 	'value' in object && | ||||
| 	typeof object.value === 'string'; | ||||
|  | ||||
| export interface IApMention extends IObject { | ||||
| 	type: 'Mention'; | ||||
| @@ -299,6 +302,11 @@ export interface IFlag extends IActivity { | ||||
| 	type: 'Flag'; | ||||
| } | ||||
|  | ||||
| export interface IMove extends IActivity { | ||||
| 	type: 'Move'; | ||||
| 	target: IObject | string; | ||||
| } | ||||
|  | ||||
| export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create'; | ||||
| export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete'; | ||||
| export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update'; | ||||
| @@ -313,3 +321,4 @@ export const isLike = (object: IObject): object is ILike => getApType(object) == | ||||
| export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce'; | ||||
| export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; | ||||
| export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; | ||||
| export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move'; | ||||
|   | ||||
| @@ -3,15 +3,15 @@ import Chart from '../../core.js'; | ||||
| export const name = 'activeUsers'; | ||||
|  | ||||
| export const schema = { | ||||
| 	'readWrite': { intersection: ['read', 'write'], range: 'small' }, | ||||
| 	'read': { uniqueIncrement: true, range: 'small' }, | ||||
| 	'write': { uniqueIncrement: true, range: 'small' }, | ||||
| 	'registeredWithinWeek': { uniqueIncrement: true, range: 'small' }, | ||||
| 	'registeredWithinMonth': { uniqueIncrement: true, range: 'small' }, | ||||
| 	'registeredWithinYear': { uniqueIncrement: true, range: 'small' }, | ||||
| 	'registeredOutsideWeek': { uniqueIncrement: true, range: 'small' }, | ||||
| 	'registeredOutsideMonth': { uniqueIncrement: true, range: 'small' }, | ||||
| 	'registeredOutsideYear': { uniqueIncrement: true, range: 'small' }, | ||||
| 	'readWrite': { intersection: ['read', 'write'] }, | ||||
| 	'read': { uniqueIncrement: true }, | ||||
| 	'write': { uniqueIncrement: true }, | ||||
| 	'registeredWithinWeek': { uniqueIncrement: true }, | ||||
| 	'registeredWithinMonth': { uniqueIncrement: true }, | ||||
| 	'registeredWithinYear': { uniqueIncrement: true }, | ||||
| 	'registeredOutsideWeek': { uniqueIncrement: true }, | ||||
| 	'registeredOutsideMonth': { uniqueIncrement: true }, | ||||
| 	'registeredOutsideYear': { uniqueIncrement: true }, | ||||
| } as const; | ||||
|  | ||||
| export const entity = Chart.schemaToEntity(name, schema); | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { AntennaNotesRepository, AntennasRepository } from '@/models/index.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { AntennasRepository } from '@/models/index.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { Antenna } from '@/models/entities/Antenna.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
|  | ||||
| @@ -10,9 +10,6 @@ export class AntennaEntityService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.antennasRepository) | ||||
| 		private antennasRepository: AntennasRepository, | ||||
|  | ||||
| 		@Inject(DI.antennaNotesRepository) | ||||
| 		private antennaNotesRepository: AntennaNotesRepository, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| @@ -22,8 +19,6 @@ export class AntennaEntityService { | ||||
| 	): Promise<Packed<'Antenna'>> { | ||||
| 		const antenna = typeof src === 'object' ? src : await this.antennasRepository.findOneByOrFail({ id: src }); | ||||
|  | ||||
| 		const hasUnreadNote = (await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false })) != null; | ||||
|  | ||||
| 		return { | ||||
| 			id: antenna.id, | ||||
| 			createdAt: antenna.createdAt.toISOString(), | ||||
| @@ -37,7 +32,8 @@ export class AntennaEntityService { | ||||
| 			notify: antenna.notify, | ||||
| 			withReplies: antenna.withReplies, | ||||
| 			withFile: antenna.withFile, | ||||
| 			hasUnreadNote, | ||||
| 			isActive: antenna.isActive, | ||||
| 			hasUnreadNote: false, // TODO | ||||
| 		}; | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { AccessTokensRepository, AppsRepository } from '@/models/index.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { App } from '@/models/entities/App.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
|   | ||||
| @@ -2,11 +2,11 @@ import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { BlockingsRepository } from '@/models/index.js'; | ||||
| import { awaitAll } from '@/misc/prelude/await-all.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { Blocking } from '@/models/entities/Blocking.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import { UserEntityService } from './UserEntityService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { UserEntityService } from './UserEntityService.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class BlockingEntityService { | ||||
|   | ||||
| @@ -1,13 +1,14 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository } from '@/models/index.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository, NotesRepository } from '@/models/index.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { } from '@/models/entities/Blocking.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { Channel } from '@/models/entities/Channel.js'; | ||||
| import { UserEntityService } from './UserEntityService.js'; | ||||
| import { DriveFileEntityService } from './DriveFileEntityService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { DriveFileEntityService } from './DriveFileEntityService.js'; | ||||
| import { NoteEntityService } from './NoteEntityService.js'; | ||||
| import { In } from 'typeorm'; | ||||
|  | ||||
| @Injectable() | ||||
| export class ChannelEntityService { | ||||
| @@ -18,13 +19,19 @@ export class ChannelEntityService { | ||||
| 		@Inject(DI.channelFollowingsRepository) | ||||
| 		private channelFollowingsRepository: ChannelFollowingsRepository, | ||||
|  | ||||
| 		@Inject(DI.channelFavoritesRepository) | ||||
| 		private channelFavoritesRepository: ChannelFavoritesRepository, | ||||
|  | ||||
| 		@Inject(DI.notesRepository) | ||||
| 		private notesRepository: NotesRepository, | ||||
|  | ||||
| 		@Inject(DI.noteUnreadsRepository) | ||||
| 		private noteUnreadsRepository: NoteUnreadsRepository, | ||||
|  | ||||
| 		@Inject(DI.driveFilesRepository) | ||||
| 		private driveFilesRepository: DriveFilesRepository, | ||||
|  | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private noteEntityService: NoteEntityService, | ||||
| 		private driveFileEntityService: DriveFileEntityService, | ||||
| 	) { | ||||
| 	} | ||||
| @@ -33,6 +40,7 @@ export class ChannelEntityService { | ||||
| 	public async pack( | ||||
| 		src: Channel['id'] | Channel, | ||||
| 		me?: { id: User['id'] } | null | undefined, | ||||
| 		detailed?: boolean, | ||||
| 	): Promise<Packed<'Channel'>> { | ||||
| 		const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src }); | ||||
| 		const meId = me ? me.id : null; | ||||
| @@ -46,6 +54,17 @@ export class ChannelEntityService { | ||||
| 			followeeId: channel.id, | ||||
| 		}) : null; | ||||
|  | ||||
| 		const favorite = meId ? await this.channelFavoritesRepository.findOneBy({ | ||||
| 			userId: meId, | ||||
| 			channelId: channel.id, | ||||
| 		}) : null; | ||||
|  | ||||
| 		const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({ | ||||
| 			where: { | ||||
| 				id: In(channel.pinnedNoteIds), | ||||
| 			}, | ||||
| 		}) : []; | ||||
|  | ||||
| 		return { | ||||
| 			id: channel.id, | ||||
| 			createdAt: channel.createdAt.toISOString(), | ||||
| @@ -54,13 +73,19 @@ export class ChannelEntityService { | ||||
| 			description: channel.description, | ||||
| 			userId: channel.userId, | ||||
| 			bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null, | ||||
| 			pinnedNoteIds: channel.pinnedNoteIds, | ||||
| 			usersCount: channel.usersCount, | ||||
| 			notesCount: channel.notesCount, | ||||
|  | ||||
| 			...(me ? { | ||||
| 				isFollowing: following != null, | ||||
| 				isFavorited: favorite != null, | ||||
| 				hasUnreadNote, | ||||
| 			} : {}), | ||||
|  | ||||
| 			...(detailed ? { | ||||
| 				pinnedNotes: await this.noteEntityService.packMany(pinnedNotes, me), | ||||
| 			} : {}), | ||||
| 		}; | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { ClipsRepository } from '@/models/index.js'; | ||||
| import type { ClipFavoritesRepository, ClipsRepository, User } from '@/models/index.js'; | ||||
| import { awaitAll } from '@/misc/prelude/await-all.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { } from '@/models/entities/Blocking.js'; | ||||
| import type { Clip } from '@/models/entities/Clip.js'; | ||||
| import { UserEntityService } from './UserEntityService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { UserEntityService } from './UserEntityService.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class ClipEntityService { | ||||
| @@ -14,6 +14,9 @@ export class ClipEntityService { | ||||
| 		@Inject(DI.clipsRepository) | ||||
| 		private clipsRepository: ClipsRepository, | ||||
|  | ||||
| 		@Inject(DI.clipFavoritesRepository) | ||||
| 		private clipFavoritesRepository: ClipFavoritesRepository, | ||||
|  | ||||
| 		private userEntityService: UserEntityService, | ||||
| 	) { | ||||
| 	} | ||||
| @@ -21,25 +24,31 @@ export class ClipEntityService { | ||||
| 	@bindThis | ||||
| 	public async pack( | ||||
| 		src: Clip['id'] | Clip, | ||||
| 		me?: { id: User['id'] } | null | undefined, | ||||
| 	): Promise<Packed<'Clip'>> { | ||||
| 		const meId = me ? me.id : null; | ||||
| 		const clip = typeof src === 'object' ? src : await this.clipsRepository.findOneByOrFail({ id: src }); | ||||
|  | ||||
| 		return await awaitAll({ | ||||
| 			id: clip.id, | ||||
| 			createdAt: clip.createdAt.toISOString(), | ||||
| 			lastClippedAt: clip.lastClippedAt ? clip.lastClippedAt.toISOString() : null, | ||||
| 			userId: clip.userId, | ||||
| 			user: this.userEntityService.pack(clip.user ?? clip.userId), | ||||
| 			name: clip.name, | ||||
| 			description: clip.description, | ||||
| 			isPublic: clip.isPublic, | ||||
| 			favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }), | ||||
| 			isFavorited: meId ? await this.clipFavoritesRepository.findOneBy({ clipId: clip.id, userId: meId }).then(x => x != null) : undefined, | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public packMany( | ||||
| 		clips: Clip[], | ||||
| 		me?: { id: User['id'] } | null | undefined, | ||||
| 	) { | ||||
| 		return Promise.all(clips.map(x => this.pack(x))); | ||||
| 		return Promise.all(clips.map(x => this.pack(x, me))); | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| import { forwardRef, Inject, Injectable } from '@nestjs/common'; | ||||
| import { DataSource } from 'typeorm'; | ||||
| import { DataSource, In } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { NotesRepository, DriveFilesRepository } from '@/models/index.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import { awaitAll } from '@/misc/prelude/await-all.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { DriveFile } from '@/models/entities/DriveFile.js'; | ||||
| @@ -21,6 +21,7 @@ type PackOptions = { | ||||
| }; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { isMimeImage } from '@/misc/is-mime-image.js'; | ||||
| import { isNotNull } from '@/misc/is-not-null.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class DriveFileEntityService { | ||||
| @@ -88,9 +89,7 @@ export class DriveFileEntityService { | ||||
| 		if (file.type.startsWith('video')) { | ||||
| 			if (file.thumbnailUrl) return file.thumbnailUrl; | ||||
|  | ||||
| 			if (this.config.videoThumbnailGenerator == null) { | ||||
| 				return this.videoProcessingService.getExternalVideoThumbnailUrl(file.webpublicUrl ?? file.url ?? file.uri); | ||||
| 			} | ||||
| 			return this.videoProcessingService.getExternalVideoThumbnailUrl(file.webpublicUrl ?? file.url ?? file.uri); | ||||
| 		} else if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) { | ||||
| 			// 動画ではなくリモートかつメディアプロキシ | ||||
| 			return this.getProxiedUrl(file.uri, 'static'); | ||||
| @@ -105,7 +104,7 @@ export class DriveFileEntityService { | ||||
|  | ||||
| 		const url = file.webpublicUrl ?? file.url; | ||||
|  | ||||
| 		return file.thumbnailUrl ?? (isMimeImage(file.type, 'sharp-convertible-image') ? this.getProxiedUrl(url, 'static') : null); | ||||
| 		return file.thumbnailUrl ?? (isMimeImage(file.type, 'sharp-convertible-image') ? url : null); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| @@ -255,10 +254,35 @@ export class DriveFileEntityService { | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async packMany( | ||||
| 		files: (DriveFile['id'] | DriveFile)[], | ||||
| 		files: DriveFile[], | ||||
| 		options?: PackOptions, | ||||
| 	): Promise<Packed<'DriveFile'>[]> { | ||||
| 		const items = await Promise.all(files.map(f => this.packNullable(f, options))); | ||||
| 		return items.filter((x): x is Packed<'DriveFile'> => x != null); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async packManyByIdsMap( | ||||
| 		fileIds: DriveFile['id'][], | ||||
| 		options?: PackOptions, | ||||
| 	): Promise<Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>> { | ||||
| 		if (fileIds.length === 0) return new Map(); | ||||
| 		const files = await this.driveFilesRepository.findBy({ id: In(fileIds) }); | ||||
| 		const packedFiles = await this.packMany(files, options); | ||||
| 		const map = new Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>(packedFiles.map(f => [f.id, f])); | ||||
| 		for (const id of fileIds) { | ||||
| 			if (!map.has(id)) map.set(id, null); | ||||
| 		} | ||||
| 		return map; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async packManyByIds( | ||||
| 		fileIds: DriveFile['id'][], | ||||
| 		options?: PackOptions, | ||||
| 	): Promise<Packed<'DriveFile'>[]> { | ||||
| 		if (fileIds.length === 0) return []; | ||||
| 		const filesMap = await this.packManyByIdsMap(fileIds, options); | ||||
| 		return fileIds.map(id => filesMap.get(id)).filter(isNotNull); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { DriveFilesRepository, DriveFoldersRepository } from '@/models/index.js'; | ||||
| import { awaitAll } from '@/misc/prelude/await-all.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { } from '@/models/entities/Blocking.js'; | ||||
| import type { DriveFolder } from '@/models/entities/DriveFolder.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { EmojisRepository } from '@/models/index.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { } from '@/models/entities/Blocking.js'; | ||||
| import type { Emoji } from '@/models/entities/Emoji.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| @@ -50,6 +50,7 @@ export class EmojiEntityService { | ||||
| 			host: emoji.host, | ||||
| 			// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) | ||||
| 			url: emoji.publicUrl || emoji.originalUrl, | ||||
| 			license: emoji.license, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { FlashsRepository, FlashLikesRepository } from '@/models/index.js'; | ||||
| import { awaitAll } from '@/misc/prelude/await-all.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { } from '@/models/entities/Blocking.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { Flash } from '@/models/entities/Flash.js'; | ||||
|   | ||||
| @@ -2,10 +2,11 @@ import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { FollowingsRepository } from '@/models/index.js'; | ||||
| import { awaitAll } from '@/misc/prelude/await-all.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { } from '@/models/entities/Blocking.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { Following } from '@/models/entities/Following.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { UserEntityService } from './UserEntityService.js'; | ||||
|  | ||||
| type LocalFollowerFollowing = Following & { | ||||
| @@ -31,7 +32,6 @@ type RemoteFolloweeFollowing = Following & { | ||||
| 	followeeInbox: string; | ||||
| 	followeeSharedInbox: string; | ||||
| }; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class FollowingEntityService { | ||||
|   | ||||
| @@ -2,13 +2,13 @@ import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { GalleryLikesRepository, GalleryPostsRepository } from '@/models/index.js'; | ||||
| import { awaitAll } from '@/misc/prelude/await-all.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { } from '@/models/entities/Blocking.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { GalleryPost } from '@/models/entities/GalleryPost.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { UserEntityService } from './UserEntityService.js'; | ||||
| import { DriveFileEntityService } from './DriveFileEntityService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class GalleryPostEntityService { | ||||
| @@ -41,7 +41,8 @@ export class GalleryPostEntityService { | ||||
| 			title: post.title, | ||||
| 			description: post.description, | ||||
| 			fileIds: post.fileIds, | ||||
| 			files: this.driveFileEntityService.packMany(post.fileIds), | ||||
| 			// TODO: packMany causes N+1 queries | ||||
| 			files: this.driveFileEntityService.packManyByIds(post.fileIds), | ||||
| 			tags: post.tags.length > 0 ? post.tags : undefined, | ||||
| 			isSensitive: post.isSensitive, | ||||
| 			likedCount: post.likedCount, | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { HashtagsRepository } from '@/models/index.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { } from '@/models/entities/Blocking.js'; | ||||
| import type { Hashtag } from '@/models/entities/Hashtag.js'; | ||||
| import { UserEntityService } from './UserEntityService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { UserEntityService } from './UserEntityService.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class HashtagEntityService { | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { InstancesRepository } from '@/models/index.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { } from '@/models/entities/Blocking.js'; | ||||
| import type { Instance } from '@/models/entities/Instance.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import { UtilityService } from '../UtilityService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { UtilityService } from '../UtilityService.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class InstanceEntityService { | ||||
|   | ||||
| @@ -2,12 +2,12 @@ import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { MutingsRepository } from '@/models/index.js'; | ||||
| import { awaitAll } from '@/misc/prelude/await-all.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { } from '@/models/entities/Blocking.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { Muting } from '@/models/entities/Muting.js'; | ||||
| import { UserEntityService } from './UserEntityService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { UserEntityService } from './UserEntityService.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class MutingEntityService { | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import { DataSource, In } from 'typeorm'; | ||||
| import * as mfm from 'mfm-js'; | ||||
| import { ModuleRef } from '@nestjs/core'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import { nyaize } from '@/misc/nyaize.js'; | ||||
| import { awaitAll } from '@/misc/prelude/await-all.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| @@ -11,6 +11,7 @@ import type { Note } from '@/models/entities/Note.js'; | ||||
| import type { NoteReaction } from '@/models/entities/NoteReaction.js'; | ||||
| import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { isNotNull } from '@/misc/is-not-null.js'; | ||||
| import type { OnModuleInit } from '@nestjs/common'; | ||||
| import type { CustomEmojiService } from '../CustomEmojiService.js'; | ||||
| import type { ReactionService } from '../ReactionService.js'; | ||||
| @@ -182,6 +183,11 @@ export class NoteEntityService implements OnModuleInit { | ||||
| 		// 実装上抜けがあるだけかもしれないので、「ヒントに含まれてなかったら(=undefinedなら)return」のようにはしない | ||||
| 		} | ||||
|  | ||||
| 		// パフォーマンスのためノートが作成されてから1秒以上経っていない場合はリアクションを取得しない | ||||
| 		if (note.createdAt.getTime() + 1000 > Date.now()) { | ||||
| 			return undefined; | ||||
| 		} | ||||
|  | ||||
| 		const reaction = await this.noteReactionsRepository.findOneBy({ | ||||
| 			userId: meId, | ||||
| 			noteId: note.id, | ||||
| @@ -248,6 +254,21 @@ export class NoteEntityService implements OnModuleInit { | ||||
| 		return true; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async packAttachedFiles(fileIds: Note['fileIds'], packedFiles: Map<Note['fileIds'][number], Packed<'DriveFile'> | null>): Promise<Packed<'DriveFile'>[]> { | ||||
| 		const missingIds = []; | ||||
| 		for (const id of fileIds) { | ||||
| 			if (!packedFiles.has(id)) missingIds.push(id); | ||||
| 		} | ||||
| 		if (missingIds.length) { | ||||
| 			const additionalMap = await this.driveFileEntityService.packManyByIdsMap(missingIds); | ||||
| 			for (const [k, v] of additionalMap) { | ||||
| 				packedFiles.set(k, v); | ||||
| 			} | ||||
| 		} | ||||
| 		return fileIds.map(id => packedFiles.get(id)).filter(isNotNull); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async pack( | ||||
| 		src: Note['id'] | Note, | ||||
| @@ -257,6 +278,7 @@ export class NoteEntityService implements OnModuleInit { | ||||
| 			skipHide?: boolean; | ||||
| 			_hint_?: { | ||||
| 				myReactions: Map<Note['id'], NoteReaction | null>; | ||||
| 				packedFiles: Map<Note['fileIds'][number], Packed<'DriveFile'> | null>; | ||||
| 			}; | ||||
| 		}, | ||||
| 	): Promise<Packed<'Note'>> { | ||||
| @@ -266,7 +288,7 @@ export class NoteEntityService implements OnModuleInit { | ||||
| 		}, options); | ||||
|  | ||||
| 		const meId = me ? me.id : null; | ||||
| 		const note = typeof src === 'object' ? src : await this.notesRepository.findOneByOrFail({ id: src }); | ||||
| 		const note = typeof src === 'object' ? src : await this.notesRepository.findOneOrFail({ where: { id: src }, relations: ['user'] }); | ||||
| 		const host = note.userHost; | ||||
|  | ||||
| 		let text = note.text; | ||||
| @@ -284,6 +306,7 @@ export class NoteEntityService implements OnModuleInit { | ||||
| 		const reactionEmojiNames = Object.keys(note.reactions) | ||||
| 			.filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ | ||||
| 			.map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); | ||||
| 		const packedFiles = options?._hint_?.packedFiles; | ||||
|  | ||||
| 		const packed: Packed<'Note'> = await awaitAll({ | ||||
| 			id: note.id, | ||||
| @@ -296,6 +319,7 @@ export class NoteEntityService implements OnModuleInit { | ||||
| 			cw: note.cw, | ||||
| 			visibility: note.visibility, | ||||
| 			localOnly: note.localOnly ?? undefined, | ||||
| 			reactionAcceptance: note.reactionAcceptance, | ||||
| 			visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, | ||||
| 			renoteCount: note.renoteCount, | ||||
| 			repliesCount: note.repliesCount, | ||||
| @@ -304,7 +328,7 @@ export class NoteEntityService implements OnModuleInit { | ||||
| 			emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined, | ||||
| 			tags: note.tags.length > 0 ? note.tags : undefined, | ||||
| 			fileIds: note.fileIds, | ||||
| 			files: this.driveFileEntityService.packMany(note.fileIds), | ||||
| 			files: packedFiles != null ? this.packAttachedFiles(note.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(note.fileIds), | ||||
| 			replyId: note.replyId, | ||||
| 			renoteId: note.renoteId, | ||||
| 			channelId: note.channelId ?? undefined, | ||||
| @@ -376,7 +400,8 @@ export class NoteEntityService implements OnModuleInit { | ||||
| 		const myReactionsMap = new Map<Note['id'], NoteReaction | null>(); | ||||
| 		if (meId) { | ||||
| 			const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); | ||||
| 			const targets = [...notes.map(n => n.id), ...renoteIds]; | ||||
| 			// パフォーマンスのためノートが作成されてから1秒以上経っていない場合はリアクションを取得しない | ||||
| 			const targets = [...notes.filter(n => n.createdAt.getTime() + 1000 < Date.now()).map(n => n.id), ...renoteIds]; | ||||
| 			const myReactions = await this.noteReactionsRepository.findBy({ | ||||
| 				userId: meId, | ||||
| 				noteId: In(targets), | ||||
| @@ -387,16 +412,44 @@ export class NoteEntityService implements OnModuleInit { | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); | ||||
| 		await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes)); | ||||
| 		// TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく | ||||
| 		const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull); | ||||
| 		const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map(); | ||||
|  | ||||
| 		return await Promise.all(notes.map(n => this.pack(n, me, { | ||||
| 			...options, | ||||
| 			_hint_: { | ||||
| 				myReactions: myReactionsMap, | ||||
| 				packedFiles, | ||||
| 			}, | ||||
| 		}))); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public aggregateNoteEmojis(notes: Note[]) { | ||||
| 		let emojis: { name: string | null; host: string | null; }[] = []; | ||||
| 		for (const note of notes) { | ||||
| 			emojis = emojis.concat(note.emojis | ||||
| 				.map(e => this.customEmojiService.parseEmojiStr(e, note.userHost))); | ||||
| 			if (note.renote) { | ||||
| 				emojis = emojis.concat(note.renote.emojis | ||||
| 					.map(e => this.customEmojiService.parseEmojiStr(e, note.renote!.userHost))); | ||||
| 				if (note.renote.user) { | ||||
| 					emojis = emojis.concat(note.renote.user.emojis | ||||
| 						.map(e => this.customEmojiService.parseEmojiStr(e, note.renote!.userHost))); | ||||
| 				} | ||||
| 			} | ||||
| 			const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis; | ||||
| 			emojis = emojis.concat(customReactions); | ||||
| 			if (note.user) { | ||||
| 				emojis = emojis.concat(note.user.emojis | ||||
| 					.map(e => this.customEmojiService.parseEmojiStr(e, note.userHost))); | ||||
| 			} | ||||
| 		} | ||||
| 		return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[]; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async countSameRenotes(userId: string, renoteId: string, excludeNoteId: string | undefined): Promise<number> { | ||||
| 		// 指定したユーザーの指定したノートのリノートがいくつあるか数える | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { NoteReactionsRepository } from '@/models/index.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import type { OnModuleInit } from '@nestjs/common'; | ||||
| import type { } from '@/models/entities/Blocking.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| @@ -10,7 +11,6 @@ import type { ReactionService } from '../ReactionService.js'; | ||||
| import type { UserEntityService } from './UserEntityService.js'; | ||||
| import type { NoteEntityService } from './NoteEntityService.js'; | ||||
| import { ModuleRef } from '@nestjs/core'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class NoteReactionEntityService implements OnModuleInit { | ||||
|   | ||||
| @@ -1,11 +1,12 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { ModuleRef } from '@nestjs/core'; | ||||
| import { In } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { AccessTokensRepository, NoteReactionsRepository, NotificationsRepository, User } from '@/models/index.js'; | ||||
| import type { AccessTokensRepository, NoteReactionsRepository, NotesRepository, User, UsersRepository } from '@/models/index.js'; | ||||
| import { awaitAll } from '@/misc/prelude/await-all.js'; | ||||
| import type { Notification } from '@/models/entities/Notification.js'; | ||||
| import type { Note } from '@/models/entities/Note.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { isNotNull } from '@/misc/is-not-null.js'; | ||||
| import { notificationTypes } from '@/types.js'; | ||||
| @@ -25,8 +26,11 @@ export class NotificationEntityService implements OnModuleInit { | ||||
| 	constructor( | ||||
| 		private moduleRef: ModuleRef, | ||||
|  | ||||
| 		@Inject(DI.notificationsRepository) | ||||
| 		private notificationsRepository: NotificationsRepository, | ||||
| 		@Inject(DI.notesRepository) | ||||
| 		private notesRepository: NotesRepository, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.noteReactionsRepository) | ||||
| 		private noteReactionsRepository: NoteReactionsRepository, | ||||
| @@ -48,30 +52,40 @@ export class NotificationEntityService implements OnModuleInit { | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async pack( | ||||
| 		src: Notification['id'] | Notification, | ||||
| 		src: Notification, | ||||
| 		meId: User['id'], | ||||
| 		// eslint-disable-next-line @typescript-eslint/ban-types | ||||
| 		options: { | ||||
| 			_hint_?: { | ||||
| 				packedNotes: Map<Note['id'], Packed<'Note'>>; | ||||
| 			}; | ||||
| 			 | ||||
| 		}, | ||||
| 		hint?: { | ||||
| 			packedNotes: Map<Note['id'], Packed<'Note'>>; | ||||
| 			packedUsers: Map<User['id'], Packed<'User'>>; | ||||
| 		}, | ||||
| 	): Promise<Packed<'Notification'>> { | ||||
| 		const notification = typeof src === 'object' ? src : await this.notificationsRepository.findOneByOrFail({ id: src }); | ||||
| 		const notification = src; | ||||
| 		const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null; | ||||
| 		const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? ( | ||||
| 			options._hint_?.packedNotes != null | ||||
| 				? options._hint_.packedNotes.get(notification.noteId) | ||||
| 				: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { | ||||
| 			hint?.packedNotes != null | ||||
| 				? hint.packedNotes.get(notification.noteId) | ||||
| 				: this.noteEntityService.pack(notification.noteId!, { id: meId }, { | ||||
| 					detail: true, | ||||
| 				}) | ||||
| 		) : undefined; | ||||
| 		const userIfNeed = notification.notifierId != null ? ( | ||||
| 			hint?.packedUsers != null | ||||
| 				? hint.packedUsers.get(notification.notifierId) | ||||
| 				: this.userEntityService.pack(notification.notifierId!, { id: meId }, { | ||||
| 					detail: false, | ||||
| 				}) | ||||
| 		) : undefined; | ||||
|  | ||||
| 		return await awaitAll({ | ||||
| 			id: notification.id, | ||||
| 			createdAt: notification.createdAt.toISOString(), | ||||
| 			createdAt: new Date(notification.createdAt).toISOString(), | ||||
| 			type: notification.type, | ||||
| 			isRead: notification.isRead, | ||||
| 			userId: notification.notifierId, | ||||
| 			user: notification.notifierId ? this.userEntityService.pack(notification.notifier ?? notification.notifierId) : null, | ||||
| 			...(userIfNeed != null ? { user: userIfNeed } : {}), | ||||
| 			...(noteIfNeed != null ? { note: noteIfNeed } : {}), | ||||
| 			...(notification.type === 'reaction' ? { | ||||
| 				reaction: notification.reaction, | ||||
| @@ -87,33 +101,39 @@ export class NotificationEntityService implements OnModuleInit { | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * @param notifications you should join "note" property when fetch from DB, and all notifieeId should be same as meId | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async packMany( | ||||
| 		notifications: Notification[], | ||||
| 		meId: User['id'], | ||||
| 	) { | ||||
| 		if (notifications.length === 0) return []; | ||||
| 		 | ||||
| 		for (const notification of notifications) { | ||||
| 			if (meId !== notification.notifieeId) { | ||||
| 				// because we call note packMany with meId, all notifieeId should be same as meId | ||||
| 				throw new Error('TRY_TO_PACK_ANOTHER_USER_NOTIFICATION'); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		const notes = notifications.map(x => x.note).filter(isNotNull); | ||||
| 		let validNotifications = notifications; | ||||
|  | ||||
| 		const noteIds = validNotifications.map(x => x.noteId).filter(isNotNull); | ||||
| 		const notes = noteIds.length > 0 ? await this.notesRepository.find({ | ||||
| 			where: { id: In(noteIds) }, | ||||
| 			relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'], | ||||
| 		}) : []; | ||||
| 		const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, { | ||||
| 			detail: true, | ||||
| 		}); | ||||
| 		const packedNotes = new Map(packedNotesArray.map(p => [p.id, p])); | ||||
|  | ||||
| 		return await Promise.all(notifications.map(x => this.pack(x, { | ||||
| 			_hint_: { | ||||
| 				packedNotes, | ||||
| 			}, | ||||
| 		validNotifications = validNotifications.filter(x => x.noteId == null || packedNotes.has(x.noteId)); | ||||
|  | ||||
| 		const userIds = validNotifications.map(x => x.notifierId).filter(isNotNull); | ||||
| 		const users = userIds.length > 0 ? await this.usersRepository.find({ | ||||
| 			where: { id: In(userIds) }, | ||||
| 		}) : []; | ||||
| 		const packedUsersArray = await this.userEntityService.packMany(users, { id: meId }, { | ||||
| 			detail: false, | ||||
| 		}); | ||||
| 		const packedUsers = new Map(packedUsersArray.map(p => [p.id, p])); | ||||
|  | ||||
| 		return await Promise.all(validNotifications.map(x => this.pack(x, meId, {}, { | ||||
| 			packedNotes, | ||||
| 			packedUsers, | ||||
| 		}))); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -2,14 +2,14 @@ import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { DriveFilesRepository, PagesRepository, PageLikesRepository } from '@/models/index.js'; | ||||
| import { awaitAll } from '@/misc/prelude/await-all.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { } from '@/models/entities/Blocking.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { Page } from '@/models/entities/Page.js'; | ||||
| import type { DriveFile } from '@/models/entities/DriveFile.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { UserEntityService } from './UserEntityService.js'; | ||||
| import { DriveFileEntityService } from './DriveFileEntityService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class PageEntityService { | ||||
|   | ||||
| @@ -0,0 +1,47 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { RenoteMutingsRepository } from '@/models/index.js'; | ||||
| import { awaitAll } from '@/misc/prelude/await-all.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { } from '@/models/entities/Blocking.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { RenoteMuting } from '@/models/entities/RenoteMuting.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { UserEntityService } from './UserEntityService.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class RenoteMutingEntityService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.renoteMutingsRepository) | ||||
| 		private renoteMutingsRepository: RenoteMutingsRepository, | ||||
|  | ||||
| 		private userEntityService: UserEntityService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async pack( | ||||
| 		src: RenoteMuting['id'] | RenoteMuting, | ||||
| 		me?: { id: User['id'] } | null | undefined, | ||||
| 	): Promise<Packed<'RenoteMuting'>> { | ||||
| 		const muting = typeof src === 'object' ? src : await this.renoteMutingsRepository.findOneByOrFail({ id: src }); | ||||
|  | ||||
| 		return await awaitAll({ | ||||
| 			id: muting.id, | ||||
| 			createdAt: muting.createdAt.toISOString(), | ||||
| 			muteeId: muting.muteeId, | ||||
| 			mutee: this.userEntityService.pack(muting.muteeId, me, { | ||||
| 				detail: true, | ||||
| 			}), | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public packMany( | ||||
| 		mutings: any[], | ||||
| 		me: { id: User['id'] }, | ||||
| 	) { | ||||
| 		return Promise.all(mutings.map(x => this.pack(x, me))); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -61,6 +61,7 @@ export class RoleEntityService { | ||||
| 			isModerator: role.isModerator, | ||||
| 			asBadge: role.asBadge, | ||||
| 			canEditMembersByModerator: role.canEditMembersByModerator, | ||||
| 			displayOrder: role.displayOrder, | ||||
| 			policies: policies, | ||||
| 			usersCount: assignedCount, | ||||
| 		}); | ||||
|   | ||||
| @@ -1,20 +1,22 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { In, Not } from 'typeorm'; | ||||
| import Redis from 'ioredis'; | ||||
| import Ajv from 'ajv'; | ||||
| import { ModuleRef } from '@nestjs/core'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { Promiseable } from '@/misc/prelude/await-all.js'; | ||||
| import { awaitAll } from '@/misc/prelude/await-all.js'; | ||||
| import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; | ||||
| import { Cache } from '@/misc/cache.js'; | ||||
| import type { Instance } from '@/models/entities/Instance.js'; | ||||
| import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; | ||||
| import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; | ||||
| import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository, UserProfile } from '@/models/index.js'; | ||||
| import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { RoleService } from '@/core/RoleService.js'; | ||||
| import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; | ||||
| import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; | ||||
| import type { OnModuleInit } from '@nestjs/common'; | ||||
| import type { AntennaService } from '../AntennaService.js'; | ||||
| import type { CustomEmojiService } from '../CustomEmojiService.js'; | ||||
| @@ -24,7 +26,7 @@ import type { PageEntityService } from './PageEntityService.js'; | ||||
|  | ||||
| type IsUserDetailed<Detailed extends boolean> = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>; | ||||
| type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends boolean> = | ||||
| 	Detailed extends true ?  | ||||
| 	Detailed extends true ? | ||||
| 		ExpectsMe extends true ? Packed<'MeDetailed'> : | ||||
| 		ExpectsMe extends false ? Packed<'UserDetailedNotMe'> : | ||||
| 		Packed<'UserDetailed'> : | ||||
| @@ -46,13 +48,14 @@ function isRemoteUser(user: User | { host: User['host'] }): boolean { | ||||
|  | ||||
| @Injectable() | ||||
| export class UserEntityService implements OnModuleInit { | ||||
| 	private apPersonService: ApPersonService; | ||||
| 	private noteEntityService: NoteEntityService; | ||||
| 	private driveFileEntityService: DriveFileEntityService; | ||||
| 	private pageEntityService: PageEntityService; | ||||
| 	private customEmojiService: CustomEmojiService; | ||||
| 	private antennaService: AntennaService; | ||||
| 	private roleService: RoleService; | ||||
| 	private userInstanceCache: Cache<Instance | null>; | ||||
| 	private federatedInstanceService: FederatedInstanceService; | ||||
|  | ||||
| 	constructor( | ||||
| 		private moduleRef: ModuleRef, | ||||
| @@ -60,6 +63,9 @@ export class UserEntityService implements OnModuleInit { | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 		@Inject(DI.redis) | ||||
| 		private redisClient: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| @@ -78,6 +84,9 @@ export class UserEntityService implements OnModuleInit { | ||||
| 		@Inject(DI.mutingsRepository) | ||||
| 		private mutingsRepository: MutingsRepository, | ||||
|  | ||||
| 		@Inject(DI.renoteMutingsRepository) | ||||
| 		private renoteMutingsRepository: RenoteMutingsRepository, | ||||
|  | ||||
| 		@Inject(DI.driveFilesRepository) | ||||
| 		private driveFilesRepository: DriveFilesRepository, | ||||
|  | ||||
| @@ -87,9 +96,6 @@ export class UserEntityService implements OnModuleInit { | ||||
| 		@Inject(DI.channelFollowingsRepository) | ||||
| 		private channelFollowingsRepository: ChannelFollowingsRepository, | ||||
|  | ||||
| 		@Inject(DI.notificationsRepository) | ||||
| 		private notificationsRepository: NotificationsRepository, | ||||
|  | ||||
| 		@Inject(DI.userNotePiningsRepository) | ||||
| 		private userNotePiningsRepository: UserNotePiningsRepository, | ||||
|  | ||||
| @@ -105,9 +111,6 @@ export class UserEntityService implements OnModuleInit { | ||||
| 		@Inject(DI.announcementsRepository) | ||||
| 		private announcementsRepository: AnnouncementsRepository, | ||||
|  | ||||
| 		@Inject(DI.antennaNotesRepository) | ||||
| 		private antennaNotesRepository: AntennaNotesRepository, | ||||
|  | ||||
| 		@Inject(DI.pagesRepository) | ||||
| 		private pagesRepository: PagesRepository, | ||||
|  | ||||
| @@ -118,16 +121,17 @@ export class UserEntityService implements OnModuleInit { | ||||
| 		//private antennaService: AntennaService, | ||||
| 		//private roleService: RoleService, | ||||
| 	) { | ||||
| 		this.userInstanceCache = new Cache<Instance | null>(1000 * 60 * 60 * 3); | ||||
| 	} | ||||
|  | ||||
| 	onModuleInit() { | ||||
| 		this.apPersonService = this.moduleRef.get('ApPersonService'); | ||||
| 		this.noteEntityService = this.moduleRef.get('NoteEntityService'); | ||||
| 		this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); | ||||
| 		this.pageEntityService = this.moduleRef.get('PageEntityService'); | ||||
| 		this.customEmojiService = this.moduleRef.get('CustomEmojiService'); | ||||
| 		this.antennaService = this.moduleRef.get('AntennaService'); | ||||
| 		this.roleService = this.moduleRef.get('RoleService'); | ||||
| 		this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); | ||||
| 	} | ||||
|  | ||||
| 	//#region Validators | ||||
| @@ -195,6 +199,13 @@ export class UserEntityService implements OnModuleInit { | ||||
| 				}, | ||||
| 				take: 1, | ||||
| 			}).then(n => n > 0), | ||||
| 			isRenoteMuted: this.renoteMutingsRepository.count({ | ||||
| 				where: { | ||||
| 					muterId: me, | ||||
| 					muteeId: target, | ||||
| 				}, | ||||
| 				take: 1, | ||||
| 			}).then(n => n > 0), | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| @@ -213,6 +224,7 @@ export class UserEntityService implements OnModuleInit { | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async getHasUnreadAntenna(userId: User['id']): Promise<boolean> { | ||||
| 		/* | ||||
| 		const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); | ||||
|  | ||||
| 		const unread = myAntennas.length > 0 ? await this.antennaNotesRepository.findOneBy({ | ||||
| @@ -221,37 +233,22 @@ export class UserEntityService implements OnModuleInit { | ||||
| 		}) : null; | ||||
|  | ||||
| 		return unread != null; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async getHasUnreadChannel(userId: User['id']): Promise<boolean> { | ||||
| 		const channels = await this.channelFollowingsRepository.findBy({ followerId: userId }); | ||||
|  | ||||
| 		const unread = channels.length > 0 ? await this.noteUnreadsRepository.findOneBy({ | ||||
| 			userId: userId, | ||||
| 			noteChannelId: In(channels.map(x => x.followeeId)), | ||||
| 		}) : null; | ||||
|  | ||||
| 		return unread != null; | ||||
| 		*/ | ||||
| 		return false; // TODO | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async getHasUnreadNotification(userId: User['id']): Promise<boolean> { | ||||
| 		const mute = await this.mutingsRepository.findBy({ | ||||
| 			muterId: userId, | ||||
| 		}); | ||||
| 		const mutedUserIds = mute.map(m => m.muteeId); | ||||
| 		const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); | ||||
|  | ||||
| 		const count = await this.notificationsRepository.count({ | ||||
| 			where: { | ||||
| 				notifieeId: userId, | ||||
| 				...(mutedUserIds.length > 0 ? { notifierId: Not(In(mutedUserIds)) } : {}), | ||||
| 				isRead: false, | ||||
| 			}, | ||||
| 			take: 1, | ||||
| 		}); | ||||
| 		const latestNotificationIdsRes = await this.redisClient.xrevrange( | ||||
| 			`notificationTimeline:${userId}`, | ||||
| 			'+', | ||||
| 			'-', | ||||
| 			'COUNT', 1); | ||||
| 		const latestNotificationId = latestNotificationIdsRes[0]?.[0]; | ||||
|  | ||||
| 		return count > 0; | ||||
| 		return latestNotificationId != null && (latestReadNotificationId == null || latestReadNotificationId < latestNotificationId); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| @@ -276,29 +273,8 @@ export class UserEntityService implements OnModuleInit { | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async getAvatarUrl(user: User): Promise<string> { | ||||
| 		if (user.avatar) { | ||||
| 			return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id); | ||||
| 		} else if (user.avatarId) { | ||||
| 			const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); | ||||
| 			return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user.id); | ||||
| 		} else { | ||||
| 			return this.getIdenticonUrl(user.id); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public getAvatarUrlSync(user: User): string { | ||||
| 		if (user.avatar) { | ||||
| 			return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id); | ||||
| 		} else { | ||||
| 			return this.getIdenticonUrl(user.id); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public getIdenticonUrl(userId: User['id']): string { | ||||
| 		return `${this.config.url}/identicon/${userId}`; | ||||
| 	public getIdenticonUrl(user: User): string { | ||||
| 		return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`; | ||||
| 	} | ||||
|  | ||||
| 	public async pack<ExpectsMe extends boolean | null = null, D extends boolean = false>( | ||||
| @@ -315,19 +291,23 @@ export class UserEntityService implements OnModuleInit { | ||||
| 			includeSecrets: false, | ||||
| 		}, options); | ||||
|  | ||||
| 		let user: User; | ||||
| 		const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src }); | ||||
|  | ||||
| 		if (typeof src === 'object') { | ||||
| 			user = src; | ||||
| 			if (src.avatar === undefined && src.avatarId) src.avatar = await this.driveFilesRepository.findOneBy({ id: src.avatarId }) ?? null; | ||||
| 			if (src.banner === undefined && src.bannerId) src.banner = await this.driveFilesRepository.findOneBy({ id: src.bannerId }) ?? null; | ||||
| 		} else { | ||||
| 			user = await this.usersRepository.findOneOrFail({ | ||||
| 				where: { id: src }, | ||||
| 				relations: { | ||||
| 					avatar: true, | ||||
| 					banner: true, | ||||
| 				}, | ||||
| 		// migration | ||||
| 		if (user.avatarId != null && user.avatarUrl === null) { | ||||
| 			const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); | ||||
| 			user.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); | ||||
| 			this.usersRepository.update(user.id, { | ||||
| 				avatarUrl: user.avatarUrl, | ||||
| 				avatarBlurhash: avatar.blurhash, | ||||
| 			}); | ||||
| 		} | ||||
| 		if (user.bannerId != null && user.bannerUrl === null) { | ||||
| 			const banner = await this.driveFilesRepository.findOneByOrFail({ id: user.bannerId }); | ||||
| 			user.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); | ||||
| 			this.usersRepository.update(user.id, { | ||||
| 				bannerUrl: user.bannerUrl, | ||||
| 				bannerBlurhash: banner.blurhash, | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| @@ -362,14 +342,11 @@ export class UserEntityService implements OnModuleInit { | ||||
| 			name: user.name, | ||||
| 			username: user.username, | ||||
| 			host: user.host, | ||||
| 			avatarUrl: this.getAvatarUrlSync(user), | ||||
| 			avatarBlurhash: user.avatar?.blurhash ?? null, | ||||
| 			avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user), | ||||
| 			avatarBlurhash: user.avatarBlurhash, | ||||
| 			isBot: user.isBot ?? falsy, | ||||
| 			isCat: user.isCat ?? falsy, | ||||
| 			instance: user.host ? this.userInstanceCache.fetch(user.host, | ||||
| 				() => this.instancesRepository.findOneBy({ host: user.host! }), | ||||
| 				v => v != null, | ||||
| 			).then(instance => instance ? { | ||||
| 			instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? { | ||||
| 				name: instance.name, | ||||
| 				softwareName: instance.softwareName, | ||||
| 				softwareVersion: instance.softwareVersion, | ||||
| @@ -380,19 +357,22 @@ export class UserEntityService implements OnModuleInit { | ||||
| 			emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), | ||||
| 			onlineStatus: this.getOnlineStatus(user), | ||||
| 			// パフォーマンス上の理由でローカルユーザーのみ | ||||
| 			badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then(rs => rs.map(r => ({ | ||||
| 			badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then(rs => rs.sort((a, b) => b.displayOrder - a.displayOrder).map(r => ({ | ||||
| 				name: r.name, | ||||
| 				iconUrl: r.iconUrl, | ||||
| 				displayOrder: r.displayOrder, | ||||
| 			}))) : undefined, | ||||
|  | ||||
| 			...(opts.detail ? { | ||||
| 				url: profile!.url, | ||||
| 				uri: user.uri, | ||||
| 				movedToUri: user.movedToUri ? await this.apPersonService.resolvePerson(user.movedToUri) : null, | ||||
| 				alsoKnownAs: user.alsoKnownAs, | ||||
| 				createdAt: user.createdAt.toISOString(), | ||||
| 				updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, | ||||
| 				lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, | ||||
| 				bannerUrl: user.banner ? this.driveFileEntityService.getPublicUrl(user.banner) : null, | ||||
| 				bannerBlurhash: user.banner?.blurhash ?? null, | ||||
| 				bannerUrl: user.bannerUrl, | ||||
| 				bannerBlurhash: user.bannerBlurhash, | ||||
| 				isLocked: user.isLocked, | ||||
| 				isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), | ||||
| 				isSuspended: user.isSuspended ?? falsy, | ||||
| @@ -419,7 +399,7 @@ export class UserEntityService implements OnModuleInit { | ||||
| 						userId: user.id, | ||||
| 					}).then(result => result >= 1) | ||||
| 					: false, | ||||
| 				roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).map(role => ({ | ||||
| 				roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ | ||||
| 					id: role.id, | ||||
| 					name: role.name, | ||||
| 					color: role.color, | ||||
| @@ -427,6 +407,7 @@ export class UserEntityService implements OnModuleInit { | ||||
| 					description: role.description, | ||||
| 					isModerator: role.isModerator, | ||||
| 					isAdministrator: role.isAdministrator, | ||||
| 					displayOrder: role.displayOrder, | ||||
| 				}))), | ||||
| 			} : {}), | ||||
|  | ||||
| @@ -455,7 +436,7 @@ export class UserEntityService implements OnModuleInit { | ||||
| 				}).then(count => count > 0), | ||||
| 				hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id), | ||||
| 				hasUnreadAntenna: this.getHasUnreadAntenna(user.id), | ||||
| 				hasUnreadChannel: this.getHasUnreadChannel(user.id), | ||||
| 				hasUnreadChannel: false, // 後方互換性のため | ||||
| 				hasUnreadNotification: this.getHasUnreadNotification(user.id), | ||||
| 				hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), | ||||
| 				mutedWords: profile!.mutedWords, | ||||
| @@ -493,6 +474,7 @@ export class UserEntityService implements OnModuleInit { | ||||
| 				isBlocking: relation.isBlocking, | ||||
| 				isBlocked: relation.isBlocked, | ||||
| 				isMuted: relation.isMuted, | ||||
| 				isRenoteMuted: relation.isRenoteMuted, | ||||
| 			} : {}), | ||||
| 		} as Promiseable<Packed<'User'>> as Promiseable<IsMeAndIsUserDetailed<ExpectsMe, D>>; | ||||
|  | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { UserListJoiningsRepository, UserListsRepository } from '@/models/index.js'; | ||||
| import type { Packed } from '@/misc/schema.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { } from '@/models/entities/Blocking.js'; | ||||
| import type { UserList } from '@/models/entities/UserList.js'; | ||||
| import { UserEntityService } from './UserEntityService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { UserEntityService } from './UserEntityService.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class UserListEntityService { | ||||
|   | ||||
| @@ -2,7 +2,7 @@ export const DI = { | ||||
| 	config: Symbol('config'), | ||||
| 	db: Symbol('db'), | ||||
| 	redis: Symbol('redis'), | ||||
| 	redisSubscriber: Symbol('redisSubscriber'), | ||||
| 	redisForPubsub: Symbol('redisForPubsub'), | ||||
|  | ||||
| 	//#region Repositories | ||||
| 	usersRepository: Symbol('usersRepository'), | ||||
| @@ -33,9 +33,9 @@ export const DI = { | ||||
| 	emojisRepository: Symbol('emojisRepository'), | ||||
| 	driveFilesRepository: Symbol('driveFilesRepository'), | ||||
| 	driveFoldersRepository: Symbol('driveFoldersRepository'), | ||||
| 	notificationsRepository: Symbol('notificationsRepository'), | ||||
| 	metasRepository: Symbol('metasRepository'), | ||||
| 	mutingsRepository: Symbol('mutingsRepository'), | ||||
| 	renoteMutingsRepository: Symbol('renoteMutingsRepository'), | ||||
| 	blockingsRepository: Symbol('blockingsRepository'), | ||||
| 	swSubscriptionsRepository: Symbol('swSubscriptionsRepository'), | ||||
| 	hashtagsRepository: Symbol('hashtagsRepository'), | ||||
| @@ -51,15 +51,15 @@ export const DI = { | ||||
| 	moderationLogsRepository: Symbol('moderationLogsRepository'), | ||||
| 	clipsRepository: Symbol('clipsRepository'), | ||||
| 	clipNotesRepository: Symbol('clipNotesRepository'), | ||||
| 	clipFavoritesRepository: Symbol('clipFavoritesRepository'), | ||||
| 	antennasRepository: Symbol('antennasRepository'), | ||||
| 	antennaNotesRepository: Symbol('antennaNotesRepository'), | ||||
| 	promoNotesRepository: Symbol('promoNotesRepository'), | ||||
| 	promoReadsRepository: Symbol('promoReadsRepository'), | ||||
| 	relaysRepository: Symbol('relaysRepository'), | ||||
| 	mutedNotesRepository: Symbol('mutedNotesRepository'), | ||||
| 	channelsRepository: Symbol('channelsRepository'), | ||||
| 	channelFollowingsRepository: Symbol('channelFollowingsRepository'), | ||||
| 	channelNotePiningsRepository: Symbol('channelNotePiningsRepository'), | ||||
| 	channelFavoritesRepository: Symbol('channelFavoritesRepository'), | ||||
| 	registryItemsRepository: Symbol('registryItemsRepository'), | ||||
| 	webhooksRepository: Symbol('webhooksRepository'), | ||||
| 	adsRepository: Symbol('adsRepository'), | ||||
|   | ||||
| @@ -1,18 +1,187 @@ | ||||
| import Redis from 'ioredis'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
|  | ||||
| export class RedisKVCache<T> { | ||||
| 	private redisClient: Redis.Redis; | ||||
| 	private name: string; | ||||
| 	private lifetime: number; | ||||
| 	private memoryCache: MemoryKVCache<T>; | ||||
| 	private fetcher: (key: string) => Promise<T>; | ||||
| 	private toRedisConverter: (value: T) => string; | ||||
| 	private fromRedisConverter: (value: string) => T; | ||||
|  | ||||
| 	constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: { | ||||
| 		lifetime: RedisKVCache<T>['lifetime']; | ||||
| 		memoryCacheLifetime: number; | ||||
| 		fetcher: RedisKVCache<T>['fetcher']; | ||||
| 		toRedisConverter: RedisKVCache<T>['toRedisConverter']; | ||||
| 		fromRedisConverter: RedisKVCache<T>['fromRedisConverter']; | ||||
| 	}) { | ||||
| 		this.redisClient = redisClient; | ||||
| 		this.name = name; | ||||
| 		this.lifetime = opts.lifetime; | ||||
| 		this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime); | ||||
| 		this.fetcher = opts.fetcher; | ||||
| 		this.toRedisConverter = opts.toRedisConverter; | ||||
| 		this.fromRedisConverter = opts.fromRedisConverter; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async set(key: string, value: T): Promise<void> { | ||||
| 		this.memoryCache.set(key, value); | ||||
| 		if (this.lifetime === Infinity) { | ||||
| 			await this.redisClient.set( | ||||
| 				`kvcache:${this.name}:${key}`, | ||||
| 				this.toRedisConverter(value), | ||||
| 			); | ||||
| 		} else { | ||||
| 			await this.redisClient.set( | ||||
| 				`kvcache:${this.name}:${key}`, | ||||
| 				this.toRedisConverter(value), | ||||
| 				'ex', Math.round(this.lifetime / 1000), | ||||
| 			); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async get(key: string): Promise<T | undefined> { | ||||
| 		const memoryCached = this.memoryCache.get(key); | ||||
| 		if (memoryCached !== undefined) return memoryCached; | ||||
|  | ||||
| 		const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`); | ||||
| 		if (cached == null) return undefined; | ||||
| 		return this.fromRedisConverter(cached); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async delete(key: string): Promise<void> { | ||||
| 		this.memoryCache.delete(key); | ||||
| 		await this.redisClient.del(`kvcache:${this.name}:${key}`); | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async fetch(key: string): Promise<T> { | ||||
| 		const cachedValue = await this.get(key); | ||||
| 		if (cachedValue !== undefined) { | ||||
| 			// Cache HIT | ||||
| 			return cachedValue; | ||||
| 		} | ||||
|  | ||||
| 		// Cache MISS | ||||
| 		const value = await this.fetcher(key); | ||||
| 		this.set(key, value); | ||||
| 		return value; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async refresh(key: string) { | ||||
| 		const value = await this.fetcher(key); | ||||
| 		this.set(key, value); | ||||
|  | ||||
| 		// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export class RedisSingleCache<T> { | ||||
| 	private redisClient: Redis.Redis; | ||||
| 	private name: string; | ||||
| 	private lifetime: number; | ||||
| 	private memoryCache: MemorySingleCache<T>; | ||||
| 	private fetcher: () => Promise<T>; | ||||
| 	private toRedisConverter: (value: T) => string; | ||||
| 	private fromRedisConverter: (value: string) => T; | ||||
|  | ||||
| 	constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: { | ||||
| 		lifetime: RedisSingleCache<T>['lifetime']; | ||||
| 		memoryCacheLifetime: number; | ||||
| 		fetcher: RedisSingleCache<T>['fetcher']; | ||||
| 		toRedisConverter: RedisSingleCache<T>['toRedisConverter']; | ||||
| 		fromRedisConverter: RedisSingleCache<T>['fromRedisConverter']; | ||||
| 	}) { | ||||
| 		this.redisClient = redisClient; | ||||
| 		this.name = name; | ||||
| 		this.lifetime = opts.lifetime; | ||||
| 		this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime); | ||||
| 		this.fetcher = opts.fetcher; | ||||
| 		this.toRedisConverter = opts.toRedisConverter; | ||||
| 		this.fromRedisConverter = opts.fromRedisConverter; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async set(value: T): Promise<void> { | ||||
| 		this.memoryCache.set(value); | ||||
| 		if (this.lifetime === Infinity) { | ||||
| 			await this.redisClient.set( | ||||
| 				`singlecache:${this.name}`, | ||||
| 				this.toRedisConverter(value), | ||||
| 			); | ||||
| 		} else { | ||||
| 			await this.redisClient.set( | ||||
| 				`singlecache:${this.name}`, | ||||
| 				this.toRedisConverter(value), | ||||
| 				'ex', Math.round(this.lifetime / 1000), | ||||
| 			); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async get(): Promise<T | undefined> { | ||||
| 		const memoryCached = this.memoryCache.get(); | ||||
| 		if (memoryCached !== undefined) return memoryCached; | ||||
|  | ||||
| 		const cached = await this.redisClient.get(`singlecache:${this.name}`); | ||||
| 		if (cached == null) return undefined; | ||||
| 		return this.fromRedisConverter(cached); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async delete(): Promise<void> { | ||||
| 		this.memoryCache.delete(); | ||||
| 		await this.redisClient.del(`singlecache:${this.name}`); | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async fetch(): Promise<T> { | ||||
| 		const cachedValue = await this.get(); | ||||
| 		if (cachedValue !== undefined) { | ||||
| 			// Cache HIT | ||||
| 			return cachedValue; | ||||
| 		} | ||||
|  | ||||
| 		// Cache MISS | ||||
| 		const value = await this.fetcher(); | ||||
| 		this.set(value); | ||||
| 		return value; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async refresh() { | ||||
| 		const value = await this.fetcher(); | ||||
| 		this.set(value); | ||||
|  | ||||
| 		// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? | ||||
|  | ||||
| export class Cache<T> { | ||||
| 	public cache: Map<string | null, { date: number; value: T; }>; | ||||
| export class MemoryKVCache<T> { | ||||
| 	public cache: Map<string, { date: number; value: T; }>; | ||||
| 	private lifetime: number; | ||||
|  | ||||
| 	constructor(lifetime: Cache<never>['lifetime']) { | ||||
| 	constructor(lifetime: MemoryKVCache<never>['lifetime']) { | ||||
| 		this.cache = new Map(); | ||||
| 		this.lifetime = lifetime; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public set(key: string | null, value: T): void { | ||||
| 	public set(key: string, value: T): void { | ||||
| 		this.cache.set(key, { | ||||
| 			date: Date.now(), | ||||
| 			value, | ||||
| @@ -20,7 +189,7 @@ export class Cache<T> { | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public get(key: string | null): T | undefined { | ||||
| 	public get(key: string): T | undefined { | ||||
| 		const cached = this.cache.get(key); | ||||
| 		if (cached == null) return undefined; | ||||
| 		if ((Date.now() - cached.date) > this.lifetime) { | ||||
| @@ -31,7 +200,7 @@ export class Cache<T> { | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public delete(key: string | null) { | ||||
| 	public delete(key: string) { | ||||
| 		this.cache.delete(key); | ||||
| 	} | ||||
|  | ||||
| @@ -40,7 +209,7 @@ export class Cache<T> { | ||||
| 	 * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async fetch(key: string | null, fetcher: () => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> { | ||||
| 	public async fetch(key: string, fetcher: () => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> { | ||||
| 		const cachedValue = this.get(key); | ||||
| 		if (cachedValue !== undefined) { | ||||
| 			if (validator) { | ||||
| @@ -65,7 +234,7 @@ export class Cache<T> { | ||||
| 	 * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async fetchMaybe(key: string | null, fetcher: () => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> { | ||||
| 	public async fetchMaybe(key: string, fetcher: () => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> { | ||||
| 		const cachedValue = this.get(key); | ||||
| 		if (cachedValue !== undefined) { | ||||
| 			if (validator) { | ||||
| @@ -87,3 +256,88 @@ export class Cache<T> { | ||||
| 		return value; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export class MemorySingleCache<T> { | ||||
| 	private cachedAt: number | null = null; | ||||
| 	private value: T | undefined; | ||||
| 	private lifetime: number; | ||||
|  | ||||
| 	constructor(lifetime: MemorySingleCache<never>['lifetime']) { | ||||
| 		this.lifetime = lifetime; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public set(value: T): void { | ||||
| 		this.cachedAt = Date.now(); | ||||
| 		this.value = value; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public get(): T | undefined { | ||||
| 		if (this.cachedAt == null) return undefined; | ||||
| 		if ((Date.now() - this.cachedAt) > this.lifetime) { | ||||
| 			this.value = undefined; | ||||
| 			this.cachedAt = null; | ||||
| 			return undefined; | ||||
| 		} | ||||
| 		return this.value; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public delete() { | ||||
| 		this.value = undefined; | ||||
| 		this.cachedAt = null; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します | ||||
| 	 * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async fetch(fetcher: () => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> { | ||||
| 		const cachedValue = this.get(); | ||||
| 		if (cachedValue !== undefined) { | ||||
| 			if (validator) { | ||||
| 				if (validator(cachedValue)) { | ||||
| 					// Cache HIT | ||||
| 					return cachedValue; | ||||
| 				} | ||||
| 			} else { | ||||
| 				// Cache HIT | ||||
| 				return cachedValue; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// Cache MISS | ||||
| 		const value = await fetcher(); | ||||
| 		this.set(value); | ||||
| 		return value; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します | ||||
| 	 * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async fetchMaybe(fetcher: () => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> { | ||||
| 		const cachedValue = this.get(); | ||||
| 		if (cachedValue !== undefined) { | ||||
| 			if (validator) { | ||||
| 				if (validator(cachedValue)) { | ||||
| 					// Cache HIT | ||||
| 					return cachedValue; | ||||
| 				} | ||||
| 			} else { | ||||
| 				// Cache HIT | ||||
| 				return cachedValue; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// Cache MISS | ||||
| 		const value = await fetcher(); | ||||
| 		if (value !== undefined) { | ||||
| 			this.set(value); | ||||
| 		} | ||||
| 		return value; | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										15
									
								
								packages/backend/src/misc/correct-filename.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								packages/backend/src/misc/correct-filename.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| // 与えられた拡張子とファイル名が一致しているかどうかを確認し、 | ||||
| // 一致していない場合は拡張子を付与して返す | ||||
| export function correctFilename(filename: string, ext: string | null) { | ||||
| 	const dotExt = ext ? ext.startsWith('.') ? ext : `.${ext}` : '.unknown'; | ||||
| 	if (filename.endsWith(dotExt)) { | ||||
| 		return filename; | ||||
| 	} | ||||
| 	if (ext === 'jpg' && filename.endsWith('.jpeg')) { | ||||
| 		return filename; | ||||
| 	} | ||||
| 	if (ext === 'tif' && filename.endsWith('.tiff')) { | ||||
| 		return filename; | ||||
| 	} | ||||
| 	return `${filename}${dotExt}`; | ||||
| } | ||||
| @@ -1,4 +1,4 @@ | ||||
| import type { Packed } from './schema.js'; | ||||
| import type { Packed } from './json-schema.js'; | ||||
|  | ||||
| /** | ||||
|  * 投稿を表す文字列を取得します。 | ||||
|   | ||||
| @@ -23,3 +23,8 @@ export function genAid(date: Date): string { | ||||
| 	counter++; | ||||
| 	return getTimeId(t) + getNoise(); | ||||
| } | ||||
|  | ||||
| export function parseAid(id: string): { date: Date; } { | ||||
| 	const time = parseInt(id.slice(0, 8), 36) + TIME2000; | ||||
| 	return { date: new Date(time) }; | ||||
| } | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	 和風ドレッシング
					和風ドレッシング