diff --git a/packages/backend/migration/1750525552482-remove-shared-access-token.js b/packages/backend/migration/1750525552482-remove-shared-access-token.js new file mode 100644 index 0000000000..c71d206cdc --- /dev/null +++ b/packages/backend/migration/1750525552482-remove-shared-access-token.js @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RemoveSharedAccessToken1750525552482 { + name = 'RemoveSharedAccessToken1750525552482' + + async up(queryRunner) { + // Drop old table + await queryRunner.query(`ALTER TABLE "shared_access_token" DROP CONSTRAINT "FK_shared_access_token_granteeId"`); + await queryRunner.query(`ALTER TABLE "shared_access_token" DROP CONSTRAINT "FK_shared_access_token_accessTokenId"`); + await queryRunner.query(`DROP INDEX "public"."IDX_shared_access_token_granteeId"`); + await queryRunner.query(`DROP TABLE "shared_access_token"`); + + // Create new column + await queryRunner.query(`ALTER TABLE "access_token" ADD "granteeIds" character varying(32) array NOT NULL DEFAULT '{}'`); + await queryRunner.query(`COMMENT ON COLUMN "access_token"."granteeIds" IS 'IDs of other users who are permitted to access and use this token.'`); + + // Create custom index + await queryRunner.query(`CREATE INDEX "IDX_access_token_granteeIds" ON "access_token" USING GIN ("granteeIds" array_ops)`) + } + + async down(queryRunner) { + // Drop custom index + await queryRunner.query(`DROP INDEX "IDX_access_token_granteeIds"`); + + // Drop new column + await queryRunner.query(`ALTER TABLE "access_token" DROP COLUMN "granteeIds"`); + + // Create old table + await queryRunner.query(`CREATE TABLE "shared_access_token" ("accessTokenId" character varying(32) NOT NULL, "granteeId" character varying(32) NOT NULL, CONSTRAINT "PK_b741ebcd3988295f4140a9f31b4" PRIMARY KEY ("accessTokenId")); COMMENT ON COLUMN "shared_access_token"."accessTokenId" IS 'ID of the access token that is shared'; COMMENT ON COLUMN "shared_access_token"."granteeId" IS 'ID of the user who is allowed to use this access token'`); + await queryRunner.query(`CREATE INDEX "IDX_shared_access_token_granteeId" ON "shared_access_token" ("granteeId") `); + await queryRunner.query(`ALTER TABLE "shared_access_token" ADD CONSTRAINT "FK_shared_access_token_accessTokenId" FOREIGN KEY ("accessTokenId") REFERENCES "access_token"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "shared_access_token" ADD CONSTRAINT "FK_shared_access_token_granteeId" FOREIGN KEY ("granteeId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } +} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index f839841128..099d48c81a 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -96,6 +96,5 @@ export const DI = { bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), reversiGamesRepository: Symbol('reversiGamesRepository'), noteScheduleRepository: Symbol('noteScheduleRepository'), - sharedAccessTokensRepository: Symbol('sharedAccessTokensRepository'), //#endregion }; diff --git a/packages/backend/src/models/AccessToken.ts b/packages/backend/src/models/AccessToken.ts index ede40bde3a..3c68074e8b 100644 --- a/packages/backend/src/models/AccessToken.ts +++ b/packages/backend/src/models/AccessToken.ts @@ -97,4 +97,18 @@ export class MiAccessToken { comment: 'Limits the user\' rank (user, moderator, or admin) when using this token. If null (default), then uses the user\'s actual rank.', }) public rank: AccessTokenRank | null; + + @Index('IDX_access_token_granteeIds', { synchronize: false }) + @Column({ + ...id(), + array: true, default: '{}', + comment: 'IDs of other users who are permitted to access and use this token.', + }) + public granteeIds: string[]; + + public constructor(props?: Partial) { + if (props) { + Object.assign(this, props); + } + } } diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 27a30f9d01..5e0154fe50 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -88,7 +88,6 @@ import { SkApContext, SkApFetchLog, SkApInboxLog, - SkSharedAccessToken, } from './_.js'; import type { Provider } from '@nestjs/common'; import type { DataSource } from 'typeorm'; @@ -153,12 +152,6 @@ const $apInboxLogsRepository: Provider = { inject: [DI.db], }; -const $skSharedAccessToken: Provider = { - provide: DI.sharedAccessTokensRepository, - useFactory: (db: DataSource) => db.getRepository(SkSharedAccessToken).extend(miRepository as MiRepository), - inject: [DI.db], -}; - const $noteFavoritesRepository: Provider = { provide: DI.noteFavoritesRepository, useFactory: (db: DataSource) => db.getRepository(MiNoteFavorite).extend(miRepository as MiRepository), @@ -592,7 +585,6 @@ const $noteScheduleRepository: Provider = { $apContextRepository, $apFetchLogsRepository, $apInboxLogsRepository, - $skSharedAccessToken, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, @@ -675,7 +667,6 @@ const $noteScheduleRepository: Provider = { $apContextRepository, $apFetchLogsRepository, $apInboxLogsRepository, - $skSharedAccessToken, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, diff --git a/packages/backend/src/models/SkSharedAccessToken.ts b/packages/backend/src/models/SkSharedAccessToken.ts deleted file mode 100644 index f3db23d1b1..0000000000 --- a/packages/backend/src/models/SkSharedAccessToken.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; -import { id } from '@/models/util/id.js'; -import { MiUser } from '@/models/User.js'; -import { MiAccessToken } from '@/models/AccessToken.js'; - -@Entity('shared_access_token') -export class SkSharedAccessToken { - @PrimaryColumn({ - ...id(), - comment: 'ID of the access token that is shared', - }) - public accessTokenId: string; - - @ManyToOne(() => MiAccessToken, { - onDelete: 'CASCADE', - }) - @JoinColumn({ - name: 'accessTokenId', - referencedColumnName: 'id', - foreignKeyConstraintName: 'FK_shared_access_token_accessTokenId', - }) - public accessToken: MiAccessToken; - - @Index('IDX_shared_access_token_granteeId') - @Column({ - ...id(), - comment: 'ID of the user who is allowed to use this access token', - }) - public granteeId: string; - - @ManyToOne(() => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn({ - name: 'granteeId', - referencedColumnName: 'id', - foreignKeyConstraintName: 'FK_shared_access_token_granteeId', - }) - public grantee?: MiUser; - - constructor(props?: Partial) { - if (props) { - Object.assign(this, props); - } - } -} diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index c210b67b31..e362230d7e 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -98,7 +98,6 @@ import { SkApInboxLog } from '@/models/SkApInboxLog.js'; import { SkApFetchLog } from '@/models/SkApFetchLog.js'; import { SkApContext } from '@/models/SkApContext.js'; import { SkLatestNote } from '@/models/LatestNote.js'; -import { SkSharedAccessToken } from '@/models/SkSharedAccessToken.js'; export interface MiRepository { createTableColumnNames(this: Repository & MiRepository): string[]; @@ -169,7 +168,6 @@ export { SkApContext, SkApFetchLog, SkApInboxLog, - SkSharedAccessToken, MiAbuseUserReport, MiAbuseReportNotificationRecipient, MiAccessToken, @@ -330,4 +328,3 @@ export type BubbleGameRecordsRepository = Repository & MiRep export type ReversiGamesRepository = Repository & MiRepository; export type NoteEditRepository = Repository & MiRepository; export type NoteScheduleRepository = Repository & MiRepository; -export type SharedAccessTokensRepository = Repository & MiRepository; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 7007d014a1..45caec54ce 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -92,7 +92,6 @@ import { SkLatestNote } from '@/models/LatestNote.js'; import { SkApContext } from '@/models/SkApContext.js'; import { SkApFetchLog } from '@/models/SkApFetchLog.js'; import { SkApInboxLog } from '@/models/SkApInboxLog.js'; -import { SkSharedAccessToken } from '@/models/SkSharedAccessToken.js'; pg.types.setTypeParser(20, Number); @@ -214,7 +213,6 @@ export const entities = [ SkApContext, SkApFetchLog, SkApInboxLog, - SkSharedAccessToken, MiAnnouncement, MiAnnouncementRead, MiMeta, diff --git a/packages/backend/src/server/api/endpoints/i/apps.ts b/packages/backend/src/server/api/endpoints/i/apps.ts index 4656a696fc..59f9dbe978 100644 --- a/packages/backend/src/server/api/endpoints/i/apps.ts +++ b/packages/backend/src/server/api/endpoints/i/apps.ts @@ -5,10 +5,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AccessTokensRepository, SharedAccessTokensRepository } from '@/models/_.js'; +import type { AccessTokensRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { CacheService } from '@/core/CacheService.js'; export const meta = { requireCredential: true, @@ -85,10 +86,8 @@ export default class extends Endpoint { // eslint- @Inject(DI.accessTokensRepository) private accessTokensRepository: AccessTokensRepository, - @Inject(DI.sharedAccessTokensRepository) - private readonly sharedAccessTokenRepository: SharedAccessTokensRepository, - private readonly userEntityService: UserEntityService, + private readonly cacheService: CacheService, private idService: IdService, ) { super(meta, paramDef, async (ps, me, at) => { @@ -106,25 +105,20 @@ export default class extends Endpoint { // eslint- const tokens = await query.getMany(); - return await Promise.all(tokens.map(async token => { - // TODO inline this table into a column w/ GIN index - const sharedTokens = await this.sharedAccessTokenRepository.find({ - where: { accessTokenId: token.id }, - relations: { grantee: true }, - }); + const users = await this.cacheService.getUsers(tokens.flatMap(token => token.granteeIds)); + const packedUsers = await this.userEntityService.packMany(Array.from(users.values()), me, { token: at }); + const packedUserMap = new Map(packedUsers.map(u => [u.id, u])); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const grantees = await this.userEntityService.packMany(sharedTokens.map(t => t.grantee!), me, { token: at }); - - return { - id: token.id, - name: token.name ?? token.app?.name, - createdAt: this.idService.parse(token.id).date.toISOString(), - lastUsedAt: token.lastUsedAt?.toISOString(), - permission: token.app ? token.app.permission : token.permission, - rank: token.rank, - grantees, - }; + return tokens.map(token => ({ + id: token.id, + name: token.name ?? token.app?.name, + createdAt: this.idService.parse(token.id).date.toISOString(), + lastUsedAt: token.lastUsedAt?.toISOString(), + permission: token.app ? token.app.permission : token.permission, + rank: token.rank, + grantees: token.granteeIds + .map(id => packedUserMap.get(id)) + .filter(user => user != null), })); }); } diff --git a/packages/backend/src/server/api/endpoints/i/revoke-token.ts b/packages/backend/src/server/api/endpoints/i/revoke-token.ts index 07acddaa54..2a8e037e61 100644 --- a/packages/backend/src/server/api/endpoints/i/revoke-token.ts +++ b/packages/backend/src/server/api/endpoints/i/revoke-token.ts @@ -58,6 +58,8 @@ export default class extends Endpoint { // eslint- }); } } + + // TODO notify of access revoked }); } } diff --git a/packages/backend/src/server/api/endpoints/i/shared-access/list.ts b/packages/backend/src/server/api/endpoints/i/shared-access/list.ts index ce917c01ee..fa5924e043 100644 --- a/packages/backend/src/server/api/endpoints/i/shared-access/list.ts +++ b/packages/backend/src/server/api/endpoints/i/shared-access/list.ts @@ -6,11 +6,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; -import type { SharedAccessTokensRepository } from '@/models/_.js'; +import type { AccessTokensRepository } from '@/models/_.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { Packed } from '@/misc/json-schema.js'; - -/* eslint-disable @typescript-eslint/no-non-null-assertion */ export const meta = { requireCredential: true, @@ -65,25 +62,25 @@ export const paramDef = {} as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.sharedAccessTokensRepository) - private readonly sharedAccessTokensRepository: SharedAccessTokensRepository, + @Inject(DI.accessTokensRepository) + private readonly accessTokensRepository: AccessTokensRepository, private readonly userEntityService: UserEntityService, ) { super(meta, paramDef, async (ps, me, token) => { - const tokens = await this.sharedAccessTokensRepository.find({ - where: { granteeId: me.id }, - relations: { accessToken: true }, - }); + const tokens = await this.accessTokensRepository + .createQueryBuilder('token') + .where(':meIdAsList <@ token.granteeIds') + .getMany(); - const users = tokens.map(token => token.accessToken!.userId); - const packedUsers: Packed<'UserLite'>[] = await this.userEntityService.packMany(users, me, { token }); - const packedUserMap = new Map>(packedUsers.map(u => [u.id, u])); + const userIds = tokens.map(token => token.userId); + const packedUsers = await this.userEntityService.packMany(userIds, me, { token }); + const packedUserMap = new Map(packedUsers.map(u => [u.id, u])); return tokens.map(token => ({ - id: token.accessTokenId, - permissions: token.accessToken!.permission, - user: packedUserMap.get(token.accessToken!.userId) as Packed<'UserLite'>, + id: token.id, + permissions: token.permission, + user: packedUserMap.get(token.userId), })); }); } diff --git a/packages/backend/src/server/api/endpoints/i/shared-access/login.ts b/packages/backend/src/server/api/endpoints/i/shared-access/login.ts index 39fd1d8b28..fcad73de99 100644 --- a/packages/backend/src/server/api/endpoints/i/shared-access/login.ts +++ b/packages/backend/src/server/api/endpoints/i/shared-access/login.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; -import type { SharedAccessTokensRepository } from '@/models/_.js'; +import type { AccessTokensRepository } from '@/models/_.js'; import { ApiError } from '@/server/api/error.js'; export const meta = { @@ -47,7 +47,7 @@ export const meta = { export const paramDef = { type: 'object', properties: { - grantId: { type: 'string' }, + tokenId: { type: 'string' }, }, required: ['grantId'], } as const; @@ -55,25 +55,25 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.sharedAccessTokensRepository) - private readonly sharedAccessTokensRepository: SharedAccessTokensRepository, + @Inject(DI.accessTokensRepository) + private readonly accessTokensRepository: AccessTokensRepository, ) { super(meta, paramDef, async (ps, me) => { - const token = await this.sharedAccessTokensRepository.findOne({ - where: { accessTokenId: ps.grantId, granteeId: me.id }, - relations: { accessToken: true }, - }); + const token = await this.accessTokensRepository.findOneBy({ id: ps.tokenId }); if (!token) { throw new ApiError(meta.errors.noSuchAccess); } - return { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - token: token.accessToken!.token, + if (!token.granteeIds.includes(me.id)) { + throw new ApiError(meta.errors.noSuchAccess); + } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - userId: token.accessToken!.userId, + // TODO notify of login + + return { + token: token.token, + userId: token.userId, }; }); } diff --git a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts index 1eb1c15e39..3ed7227d79 100644 --- a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts +++ b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts @@ -4,11 +4,10 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, In } from 'typeorm'; -import { SkSharedAccessToken } from '@/models/SkSharedAccessToken.js'; +import { In } from 'typeorm'; import { ApiError } from '@/server/api/error.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AccessTokensRepository, SharedAccessTokensRepository, UsersRepository } from '@/models/_.js'; +import type { AccessTokensRepository, UsersRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; @@ -71,15 +70,9 @@ export default class extends Endpoint { // eslint- @Inject(DI.accessTokensRepository) private accessTokensRepository: AccessTokensRepository, - @Inject(DI.sharedAccessTokensRepository) - private readonly sharedAccessTokensRepository: SharedAccessTokensRepository, - @Inject(DI.usersRepository) private readonly usersRepository: UsersRepository, - @Inject(DI.db) - private readonly db: DataSource, - private idService: IdService, private notificationService: NotificationService, ) { @@ -97,27 +90,20 @@ export default class extends Endpoint { // eslint- const now = new Date(); const accessTokenId = this.idService.gen(now.getTime()); - await this.db.transaction(async tem => { - // Insert access token doc - await this.accessTokensRepository.insert({ - id: accessTokenId, - lastUsedAt: now, - session: ps.session, - userId: me.id, - token: accessToken, - hash: accessToken, - name: ps.name, - description: ps.description, - iconUrl: ps.iconUrl, - permission: ps.permission, - rank: ps.rank, - }); - - // Insert shared access grants - if (ps.grantees && ps.grantees.length > 0) { - const grants = ps.grantees.map(granteeId => new SkSharedAccessToken({ accessTokenId, granteeId })); - await this.sharedAccessTokensRepository.insert(grants); - } + // Insert access token doc + await this.accessTokensRepository.insert({ + id: accessTokenId, + lastUsedAt: now, + session: ps.session, + userId: me.id, + token: accessToken, + hash: accessToken, + name: ps.name, + description: ps.description, + iconUrl: ps.iconUrl, + permission: ps.permission, + rank: ps.rank, + granteeIds: ps.grantees, }); // TODO notify of access granted