basic support for Shared Access Accounts

This commit is contained in:
Hazelnoot 2025-06-21 00:43:17 -04:00
parent 7715f36a99
commit fa5a46f379
22 changed files with 789 additions and 32 deletions

View file

@ -96,5 +96,6 @@ export const DI = {
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
reversiGamesRepository: Symbol('reversiGamesRepository'),
noteScheduleRepository: Symbol('noteScheduleRepository'),
sharedAccessToken: Symbol('sharedAccessToken'),
//#endregion
};

View file

@ -88,6 +88,7 @@ import {
SkApContext,
SkApFetchLog,
SkApInboxLog,
SkSharedAccessToken,
} from './_.js';
import type { Provider } from '@nestjs/common';
import type { DataSource } from 'typeorm';
@ -152,6 +153,12 @@ const $apInboxLogsRepository: Provider = {
inject: [DI.db],
};
const $skSharedAccessToken: Provider = {
provide: DI.sharedAccessToken,
useFactory: (db: DataSource) => db.getRepository(SkSharedAccessToken).extend(miRepository as MiRepository<SkSharedAccessToken>),
inject: [DI.db],
};
const $noteFavoritesRepository: Provider = {
provide: DI.noteFavoritesRepository,
useFactory: (db: DataSource) => db.getRepository(MiNoteFavorite).extend(miRepository as MiRepository<MiNoteFavorite>),
@ -585,6 +592,7 @@ const $noteScheduleRepository: Provider = {
$apContextRepository,
$apFetchLogsRepository,
$apInboxLogsRepository,
$skSharedAccessToken,
$noteFavoritesRepository,
$noteThreadMutingsRepository,
$noteReactionsRepository,
@ -667,6 +675,7 @@ const $noteScheduleRepository: Provider = {
$apContextRepository,
$apFetchLogsRepository,
$apInboxLogsRepository,
$skSharedAccessToken,
$noteFavoritesRepository,
$noteThreadMutingsRepository,
$noteReactionsRepository,

View file

@ -0,0 +1,51 @@
/*
* 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<SkSharedAccessToken>) {
if (props) {
Object.assign(this, props);
}
}
}

View file

@ -98,6 +98,7 @@ 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<T extends ObjectLiteral> {
createTableColumnNames(this: Repository<T> & MiRepository<T>): string[];
@ -168,6 +169,7 @@ export {
SkApContext,
SkApFetchLog,
SkApInboxLog,
SkSharedAccessToken,
MiAbuseUserReport,
MiAbuseReportNotificationRecipient,
MiAccessToken,
@ -327,4 +329,5 @@ export type ChatApprovalsRepository = Repository<MiChatApproval> & MiRepository<
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;
export type NoteEditRepository = Repository<NoteEdit> & MiRepository<NoteEdit>;
export type NoteScheduleRepository = Repository<MiNoteSchedule>;
export type NoteScheduleRepository = Repository<MiNoteSchedule> & MiRepository<MiNoteSchedule>;
export type SharedAccessTokensRepository = Repository<SkSharedAccessToken> & MiRepository<SkSharedAccessToken>;

View file

@ -92,6 +92,7 @@ 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);
@ -213,6 +214,7 @@ export const entities = [
SkApContext,
SkApFetchLog,
SkApInboxLog,
SkSharedAccessToken,
MiAnnouncement,
MiAnnouncementRead,
MiMeta,

View file

@ -298,6 +298,8 @@ export * as 'i/registry/scopes-with-domain' from './endpoints/i/registry/scopes-
export * as 'i/registry/set' from './endpoints/i/registry/set.js';
export * as 'i/revoke-token' from './endpoints/i/revoke-token.js';
export * as 'i/signin-history' from './endpoints/i/signin-history.js';
export * as 'i/shared-access/list' from './endpoints/i/shared-access/list.js';
export * as 'i/shared-access/login' from './endpoints/i/shared-access/login.js';
export * as 'i/unpin' from './endpoints/i/unpin.js';
export * as 'i/update' from './endpoints/i/update.js';
export * as 'i/update-email' from './endpoints/i/update-email.js';

View file

@ -0,0 +1,90 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
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 { 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,
secure: true,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
id: {
type: 'string',
optional: false, nullable: false,
},
user: {
ref: 'UserLite',
optional: false, nullable: false,
},
permissions: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
},
},
properties: {
userId: {
type: 'string',
optional: false, nullable: false,
},
token: {
type: 'string',
optional: false, nullable: false,
},
},
},
// 2 calls per second
limit: {
duration: 1000,
max: 2,
},
} as const;
export const paramDef = {} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.sharedAccessToken)
private readonly sharedAccessTokensRepository: SharedAccessTokensRepository,
private readonly userEntityService: UserEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const tokens = await this.sharedAccessTokensRepository.find({
where: { granteeId: me.id },
relations: { accessToken: true },
});
const users = tokens.map(token => token.accessToken!.userId);
const packedUsers: Packed<'UserLite'>[] = await this.userEntityService.packMany(users, me);
const packedUserMap = new Map<string, Packed<'UserLite'>>(packedUsers.map(u => [u.id, u]));
return await Promise.all(tokens.map(async token => ({
id: token.accessTokenId,
permissions: token.accessToken!.permission,
user: packedUserMap.get(token.accessToken!.userId) as Packed<'UserLite'>,
})));
});
}
}

View file

@ -0,0 +1,80 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
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 { ApiError } from '@/server/api/error.js';
export const meta = {
requireCredential: true,
secure: true,
res: {
type: 'object',
optional: false, nullable: false,
properties: {
userId: {
type: 'string',
optional: false, nullable: false,
},
token: {
type: 'string',
optional: false, nullable: false,
},
},
},
errors: {
noSuchAccess: {
message: 'No such access',
code: 'NO_SUCH_ACCESS',
id: 'd536e0f2-47fc-4d66-843c-f9276e98030f',
httpStatusCode: 403,
},
},
// 2 calls per second
limit: {
duration: 1000,
max: 2,
},
} as const;
export const paramDef = {
type: 'object',
properties: {
grantId: { type: 'string' },
},
required: ['grantId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.sharedAccessToken)
private readonly sharedAccessTokensRepository: SharedAccessTokensRepository,
) {
super(meta, paramDef, async (ps, me) => {
const token = await this.sharedAccessTokensRepository.findOne({
where: { accessTokenId: ps.grantId, granteeId: me.id },
relations: { accessToken: true },
});
if (!token) {
throw new ApiError(meta.errors.noSuchAccess);
}
return {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
token: token.accessToken!.token,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
userId: token.accessToken!.userId,
};
});
}
}

View file

@ -4,8 +4,11 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In } from 'typeorm';
import { SkSharedAccessToken } from '@/models/SkSharedAccessToken.js';
import { ApiError } from '@/server/api/error.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { AccessTokensRepository } from '@/models/_.js';
import type { AccessTokensRepository, SharedAccessTokensRepository, UsersRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
@ -29,6 +32,14 @@ export const meta = {
},
},
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: 'a89abd3d-f0bc-4cce-beb1-2f446f4f1e6a',
},
},
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
@ -46,6 +57,9 @@ export const paramDef = {
permission: { type: 'array', uniqueItems: true, items: {
type: 'string',
} },
grantees: { type: 'array', uniqueItems: true, items: {
type: 'string',
} },
},
required: ['session', 'permission'],
} as const;
@ -56,29 +70,56 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.accessTokensRepository)
private accessTokensRepository: AccessTokensRepository,
@Inject(DI.sharedAccessToken)
private readonly sharedAccessTokensRepository: SharedAccessTokensRepository,
@Inject(DI.usersRepository)
private readonly usersRepository: UsersRepository,
@Inject(DI.db)
private readonly db: DataSource,
private idService: IdService,
private notificationService: NotificationService,
) {
super(meta, paramDef, async (ps, me) => {
if (ps.grantees && ps.grantees.length > 0) {
const existingCount = await this.usersRepository.countBy({ id: In(ps.grantees) });
if (existingCount !== ps.grantees.length) {
throw new ApiError(meta.errors.noSuchUser);
}
}
// Generate access token
const accessToken = secureRndstr(32);
const now = new Date();
const accessTokenId = this.idService.gen(now.getTime());
// Insert access token doc
await this.accessTokensRepository.insert({
id: this.idService.gen(now.getTime()),
lastUsedAt: now,
session: ps.session,
userId: me.id,
token: accessToken,
hash: accessToken,
name: ps.name,
description: ps.description,
iconUrl: ps.iconUrl,
permission: ps.permission,
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,
});
// 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);
}
});
// TODO notify of access granted
// アクセストークンが生成されたことを通知
this.notificationService.createNotification(me.id, 'createToken', {});