diff --git a/locales/index.d.ts b/locales/index.d.ts index 4d86c69384..21fb11211d 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -9244,6 +9244,14 @@ export interface Locale extends ILocale { * Compose or delete scheduled notes */ "write:notes-schedule": string; + /** + * Read abuse report notification recipients + */ + "read:admin:abuse-report:notification-recipient": string; + /** + * Edit abuse report notification recipients + */ + "write:admin:abuse-report:notification-recipient": string; }; "_auth": { /** @@ -10434,6 +10442,18 @@ export interface Locale extends ILocale { * Import of {x} has been completed */ "importOfXCompleted": ParameterizedString<"x">; + /** + * Shared access granted + */ + "sharedAccessGranted": string; + /** + * Shared access revoked + */ + "sharedAccessRevoked": string; + /** + * Shared access login + */ + "sharedAccessLogin": string; }; "_deck": { /** @@ -13467,6 +13487,152 @@ export interface Locale extends ILocale { * Hide ads */ "hideAds": string; + /** + * Apps using this token will have no API access except for the functions listed below. + */ + "permissionsDescription": string; + /** + * Apps using this token will have no administrative access except for the functions enabled below. + */ + "adminPermissionsDescription": string; + /** + * Shared account + */ + "sharedAccount": string; + /** + * Shared access + */ + "sharedAccess": string; + /** + * Any accounts listed here will be granted access to the token and may use it to access this account. + */ + "sharedAccessDescription": string; + /** + * Shared access allows another user to access your account without using your password. You may select exactly which features and data are available to guest users. + */ + "sharedAccessDescription2": string; + /** + * Share access + */ + "addGrantee": string; + /** + * Remove access + */ + "removeGrantee": string; + /** + * Login with shared access + */ + "loginWithSharedAccess": string; + /** + * Login with granted access to a shared account + */ + "loginWithSharedAccessDescription": string; + /** + * You have not been granted shared access to any accounts + */ + "noSharedAccess": string; + /** + * Expand + */ + "expand": string; + /** + * Collapse + */ + "collapse": string; + /** + * Permissions + */ + "permissions": string; + /** + * Limit rank + */ + "overrideRank": string; + /** + * Limits the user rank (admin, moderator, or user) for apps using this token. + */ + "overrideRankDescription": string; + /** + * Rank + */ + "rank": string; + "_ranks": { + /** + * Admin + */ + "admin": string; + /** + * Moderator + */ + "mod": string; + /** + * User + */ + "user": string; + /** + * default + */ + "default": string; + }; + /** + * Permissions: {num} + */ + "permissionsLabel": ParameterizedString<"num">; + /** + * You have been granted shared access to {target} with {rank} rank and {perms} permissions. + */ + "sharedAccessGranted": ParameterizedString<"target" | "rank" | "perms">; + /** + * Shared access to {target} has been revoked. + */ + "sharedAccessRevoked": ParameterizedString<"target">; + /** + * {target} logged in via shared access. + */ + "sharedAccessLogin": ParameterizedString<"target">; + /** + * Unique name to record the purpose of this access token + */ + "accessTokenNameDescription": string; + /** + * Are you sure you want to revoke this token? + */ + "confirmRevokeToken": string; + /** + * Are you sure you want to revoke this token? {num} shared other users will lose shared access. + */ + "confirmRevokeSharedToken": ParameterizedString<"num">; + /** + * Grant shared access + */ + "grantSharedAccessButton": string; + /** + * No shared access listed + */ + "grantSharedAccessNoSelection": string; + /** + * No shared access users were selected. Please add at least one user in the "shared access" section. + */ + "grantSharedAccessNoSelection2": string; + /** + * Shared access granted + */ + "grantSharedAccessSuccess": string; + /** + * Shared access has been granted to {num} users. + */ + "grantSharedAccessSuccess2": ParameterizedString<"num">; + /** + * Are you sure you want to create a token with no permissions? + */ + "tokenHasNoPermissionsConfirm": string; + /** + * Enable all read-only permissions + */ + "enableAllRead": string; + /** + * Enable all write/edit permissions + */ + "enableAllWrite": string; } declare const locales: { [lang: string]: Locale; diff --git a/packages/backend/migration/1750478202328-create-shared-access-token.js b/packages/backend/migration/1750478202328-create-shared-access-token.js new file mode 100644 index 0000000000..1f7146e4fe --- /dev/null +++ b/packages/backend/migration/1750478202328-create-shared-access-token.js @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class CreateSharedAccessToken1750478202328 { + name = 'CreateSharedAccessToken1750478202328' + + async up(queryRunner) { + 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`); + } + + async down(queryRunner) { + 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"`); + } +} 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/migration/1750525832125-add-access_token-rank.js b/packages/backend/migration/1750525832125-add-access_token-rank.js new file mode 100644 index 0000000000..636685c051 --- /dev/null +++ b/packages/backend/migration/1750525832125-add-access_token-rank.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddAccessTokenRank1750525832125 { + async up(queryRunner) { + await queryRunner.query(`CREATE TYPE "public"."access_token_rank_enum" AS ENUM('user', 'mod', 'admin')`); + await queryRunner.query(`ALTER TABLE "access_token" ADD "rank" "public"."access_token_rank_enum"`); + await queryRunner.query(`COMMENT ON COLUMN "access_token"."rank" IS 'Limits the user'' rank (user, moderator, or admin) when using this token. If null (default), then uses the user''s actual rank.'`); + } + + async down(queryRunner) { + await queryRunner.query(`COMMENT ON COLUMN "access_token"."rank" IS 'Limits the user'' rank (user, moderator, or admin) when using this token. If null (default), then uses the user''s actual rank.'`); + await queryRunner.query(`ALTER TABLE "access_token" DROP COLUMN "rank"`); + await queryRunner.query(`DROP TYPE "public"."access_token_rank_enum"`); + } +} diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts index 62cf04e00e..76f4e0dbdd 100644 --- a/packages/backend/src/core/ChatService.ts +++ b/packages/backend/src/core/ChatService.ts @@ -342,11 +342,11 @@ export class ChatService { } @bindThis - public async hasPermissionToViewRoomTimeline(meId: MiUser['id'], room: MiChatRoom) { - if (await this.isRoomMember(room, meId)) { + public async hasPermissionToViewRoomTimeline(me: MiUser, room: MiChatRoom) { + if (await this.isRoomMember(room, me.id)) { return true; } else { - const iAmModerator = await this.roleService.isModerator({ id: meId }); + const iAmModerator = await this.roleService.isModerator(me); if (iAmModerator) { return true; } @@ -563,12 +563,12 @@ export class ChatService { } @bindThis - public async hasPermissionToDeleteRoom(meId: MiUser['id'], room: MiChatRoom) { - if (room.ownerId === meId) { + public async hasPermissionToDeleteRoom(me: MiUser, room: MiChatRoom) { + if (room.ownerId === me.id) { return true; } - const iAmModerator = await this.roleService.isModerator({ id: meId }); + const iAmModerator = await this.roleService.isModerator(me); if (iAmModerator) { return true; } diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 6429e304e5..1418999e9a 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -31,6 +31,7 @@ import type { Packed } from '@/misc/json-schema.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { NotificationService } from '@/core/NotificationService.js'; import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; +import { getCallerId } from '@/misc/attach-caller-id.js'; export type RolePolicies = { gtlAvailable: boolean; @@ -414,7 +415,21 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id)); const user = typeof(userOrId) === 'object' ? userOrId : roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userOrId) : null; const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula, followStats)); - return [...assignedRoles, ...matchedCondRoles]; + + let allRoles = [...assignedRoles, ...matchedCondRoles]; + + // Check for dropped token permissions + const rank = user ? getCallerId(user)?.accessToken?.rank : null; + if (rank != null) { + // Copy roles, since they come from a cache + allRoles = allRoles.map(role => ({ + ...role, + isModerator: role.isModerator && (rank === 'admin' || rank === 'mod'), + isAdministrator: role.isAdministrator && rank === 'admin', + })); + } + + return allRoles; } /** @@ -514,12 +529,22 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { @bindThis public async isModerator(user: { id: MiUser['id'] } | null): Promise { if (user == null) return false; + + // Check for dropped token permissions + const rank = getCallerId(user)?.accessToken?.rank; + if (rank != null && rank !== 'admin' && rank !== 'mod') return false; + return (this.meta.rootUserId === user.id) || (await this.getUserRoles(user.id)).some(r => r.isModerator || r.isAdministrator); } @bindThis public async isAdministrator(user: { id: MiUser['id'] } | null): Promise { if (user == null) return false; + + // Check for dropped token permissions + const rank = getCallerId(user)?.accessToken?.rank; + if (rank != null && rank !== 'admin') return false; + return (this.meta.rootUserId === user.id) || (await this.getUserRoles(user.id)).some(r => r.isAdministrator); } diff --git a/packages/backend/src/core/entities/ClipEntityService.ts b/packages/backend/src/core/entities/ClipEntityService.ts index d915645906..b700fe2efb 100644 --- a/packages/backend/src/core/entities/ClipEntityService.ts +++ b/packages/backend/src/core/entities/ClipEntityService.ts @@ -47,7 +47,7 @@ export class ClipEntityService { createdAt: this.idService.parse(clip.id).date.toISOString(), lastClippedAt: clip.lastClippedAt ? clip.lastClippedAt.toISOString() : null, userId: clip.userId, - user: hint?.packedUser ?? this.userEntityService.pack(clip.user ?? clip.userId), + user: hint?.packedUser ?? this.userEntityService.pack(clip.user ?? clip.userId, me), name: clip.name, description: clip.description, isPublic: clip.isPublic, diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index bb956b1097..c00452110f 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { In, EntityNotFoundError } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { FollowRequestsRepository, NotesRepository, MiUser, UsersRepository } from '@/models/_.js'; +import type { FollowRequestsRepository, NotesRepository, MiUser, UsersRepository, AccessTokensRepository } from '@/models/_.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { MiGroupedNotification, MiNotification } from '@/models/Notification.js'; import type { MiNote } from '@/models/Note.js'; @@ -21,7 +21,7 @@ import type { OnModuleInit } from '@nestjs/common'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; -const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited', 'scheduledNotePosted'] as (typeof groupedNotificationTypes[number])[]); +const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited', 'scheduledNotePosted']); function undefOnMissing(packPromise: Promise): Promise { return packPromise.catch(err => { @@ -49,6 +49,9 @@ export class NotificationEntityService implements OnModuleInit { @Inject(DI.followRequestsRepository) private followRequestsRepository: FollowRequestsRepository, + @Inject(DI.accessTokensRepository) + private readonly accessTokensRepository: AccessTokensRepository, + private cacheService: CacheService, ) { } @@ -199,6 +202,10 @@ export class NotificationEntityService implements OnModuleInit { header: notification.customHeader, icon: notification.customIcon, } : {}), + ...(notification.type === 'sharedAccessGranted' ? { + permCount: notification.permCount, + rank: notification.rank, + } : {}), }); } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 2abf2ee4f5..aa85e15258 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; +import * as Misskey from 'misskey-js'; import _Ajv from 'ajv'; import { ModuleRef } from '@nestjs/core'; import { In } from 'typeorm'; @@ -54,6 +55,7 @@ import { ChatService } from '@/core/ChatService.js'; import { isSystemAccount } from '@/misc/is-system-account.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { CacheService } from '@/core/CacheService.js'; +import { getCallerId } from '@/misc/attach-caller-id.js'; import type { OnModuleInit } from '@nestjs/common'; import type { NoteEntityService } from './NoteEntityService.js'; import type { PageEntityService } from './PageEntityService.js'; @@ -432,6 +434,7 @@ export class UserEntityService implements OnModuleInit { userMemos?: Map, pinNotes?: Map, iAmModerator?: boolean, + iAmAdmin?: boolean, userIdsByUri?: Map, instances?: Map, securityKeyCounts?: Map, @@ -477,7 +480,8 @@ export class UserEntityService implements OnModuleInit { const isDetailed = opts.schema !== 'UserLite'; const meId = me ? me.id : null; const isMe = meId === user.id; - const iAmModerator = opts.iAmModerator ?? (me ? await this.roleService.isModerator(me as MiUser) : false); + const iAmModerator = opts.iAmModerator ?? (me ? await this.roleService.isModerator(me) : false); + const iAmAdmin = opts.iAmAdmin ?? (me ? await this.roleService.isAdministrator(me) : false); const profile = isDetailed ? (opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) @@ -529,8 +533,6 @@ export class UserEntityService implements OnModuleInit { (profile.followersVisibility === 'followers') && (relation && relation.isFollowing) ? user.followersCount : null; - const isModerator = isMe && isDetailed ? this.roleService.isModerator(user) : null; - const isAdmin = isMe && isDetailed ? this.roleService.isAdministrator(user) : null; const unreadAnnouncements = isMe && isDetailed ? (await this.announcementService.getUnreadAnnouncements(user)).map((announcement) => ({ createdAt: this.idService.parse(announcement.id).date.toISOString(), @@ -663,8 +665,8 @@ export class UserEntityService implements OnModuleInit { bannerId: user.bannerId, backgroundId: user.backgroundId, followedMessage: profile!.followedMessage, - isModerator: isModerator, - isAdmin: isAdmin, + isModerator: iAmModerator, + isAdmin: iAmAdmin, isSystem: isSystemAccount(user), injectFeaturedNote: profile!.injectFeaturedNote, receiveAnnouncementEmail: profile!.receiveAnnouncementEmail, @@ -699,6 +701,7 @@ export class UserEntityService implements OnModuleInit { achievements: profile!.achievements, loggedInDays: profile!.loggedInDates.length, policies: fetchPolicies(), + permissions: this.getPermissions(user, iAmModerator, iAmAdmin), defaultCW: profile!.defaultCW, defaultCWPriority: profile!.defaultCWPriority, allowUnsignedFetch: user.allowUnsignedFetch, @@ -767,7 +770,10 @@ export class UserEntityService implements OnModuleInit { } const _userIds = _users.map(u => u.id); - const iAmModerator = await this.roleService.isModerator(me as MiUser); + // Sync with ApiCallService + const iAmAdmin = me ? await this.roleService.isAdministrator(me) : false; + const iAmModerator = me ? await this.roleService.isModerator(me) : false; + const meId = me ? me.id : null; const isDetailed = options && options.schema !== 'UserLite'; const isDetailedAndMod = isDetailed && iAmModerator; @@ -864,6 +870,7 @@ export class UserEntityService implements OnModuleInit { userMemos: userMemos, pinNotes: pinNotes, iAmModerator, + iAmAdmin, userIdsByUri, instances, securityKeyCounts, @@ -872,4 +879,16 @@ export class UserEntityService implements OnModuleInit { )), ); } + + @bindThis + private getPermissions(user: MiUser, isModerator: boolean, isAdmin: boolean): readonly string[] { + const token = getCallerId(user); + let permissions = token?.accessToken?.permission ?? Misskey.permissions; + + if (!isModerator && !isAdmin) { + permissions = permissions.filter(perm => !perm.startsWith('read:admin') && !perm.startsWith('write:admin')); + } + + return permissions; + } } diff --git a/packages/backend/src/misc/attach-caller-id.ts b/packages/backend/src/misc/attach-caller-id.ts new file mode 100644 index 0000000000..d5179a37ea --- /dev/null +++ b/packages/backend/src/misc/attach-caller-id.ts @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { MiAccessToken } from '@/models/AccessToken.js'; + +const callerIdSymbol = Symbol('callerId'); + +/** + * Client metadata associated with an object (typically an instance of MiUser). + */ +export interface CallerId { + /** + * Client's access token, or null if no token was used. + */ + accessToken?: MiAccessToken | null; +} + +interface ObjectWithCallerId { + [callerIdSymbol]?: CallerId; +} + +/** + * Attaches client metadata to an object. + * Calling this repeatedly will overwrite the previous value. + * Pass undefined to erase the attached data. + * @param target Object to attach to (typically an instance of MiUser). + * @param callerId Data to attach. + */ +export function attachCallerId(target: object, callerId: CallerId | undefined): void { + (target as ObjectWithCallerId)[callerIdSymbol] = callerId; +} + +/** + * Fetches client metadata from an object. + * Returns undefined if no metadata is attached. + * @param target Object to fetch from. + */ +export function getCallerId(target: object): CallerId | undefined { + return (target as ObjectWithCallerId)[callerIdSymbol]; +} diff --git a/packages/backend/src/models/AccessToken.ts b/packages/backend/src/models/AccessToken.ts index 6f98c14ec1..3c68074e8b 100644 --- a/packages/backend/src/models/AccessToken.ts +++ b/packages/backend/src/models/AccessToken.ts @@ -8,6 +8,9 @@ import { id } from './util/id.js'; import { MiUser } from './User.js'; import { MiApp } from './App.js'; +export const accessTokenRanks = ['user', 'mod', 'admin'] as const; +export type AccessTokenRank = typeof accessTokenRanks[number]; + @Entity('access_token') export class MiAccessToken { @PrimaryColumn(id()) @@ -87,4 +90,25 @@ export class MiAccessToken { default: false, }) public fetched: boolean; + + @Column('enum', { + enum: accessTokenRanks, + nullable: true, + 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/Notification.ts b/packages/backend/src/models/Notification.ts index 1f87c1068d..38773f828f 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -152,6 +152,23 @@ export type MiNotification = { id: string; createdAt: string; noteId: MiNote['id']; +} | { + type: 'sharedAccessGranted'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + permCount: number; + rank: 'user' | 'mod' | 'admin' | null; +} | { + type: 'sharedAccessRevoked'; + id: string; + createdAt: string; + notifierId: MiUser['id']; +} | { + type: 'sharedAccessLogin'; + id: string; + createdAt: string; + notifierId: MiUser['id']; }; export type MiGroupedNotification = MiNotification | { diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 225e8ac025..e362230d7e 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -327,4 +327,4 @@ export type ChatApprovalsRepository = Repository & MiRepository< export type BubbleGameRecordsRepository = Repository & MiRepository; export type ReversiGamesRepository = Repository & MiRepository; export type NoteEditRepository = Repository & MiRepository; -export type NoteScheduleRepository = Repository; +export type NoteScheduleRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index 2663b2f5f8..7f05b75fd3 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -460,6 +460,75 @@ export const packedNotificationSchema = { optional: false, nullable: false, }, }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['sharedAccessGranted'], + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + permCount: { + type: 'number', + optional: true, nullable: false, + }, + rank: { + type: 'string', + enum: ['admin', 'mod', 'user'], + optional: true, nullable: true, + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['sharedAccessRevoked'], + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['sharedAccessLogin'], + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, }, { type: 'object', properties: { diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 65ef387fb7..69e81c7448 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -739,6 +739,13 @@ export const packedMeDetailedOnlySchema = { nullable: false, optional: false, ref: 'RolePolicies', }, + permissions: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'string', + }, + }, twoFactorEnabled: { type: 'boolean', nullable: false, optional: false, diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 66d968224a..ac3f7ae0f3 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -21,13 +21,13 @@ import type { Config } from '@/config.js'; import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; import { renderInlineError } from '@/misc/render-inline-error.js'; +import { renderFullError } from '@/misc/render-full-error.js'; import { ApiError } from './error.js'; import { ApiLoggerService } from './ApiLoggerService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; import type { OnApplicationShutdown } from '@nestjs/common'; import type { IEndpointMeta, IEndpoint } from './endpoints.js'; -import { renderFullError } from '@/misc/render-full-error.js'; const accessDenied = { message: 'Access denied.', diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts index 397626c49d..3681602e1f 100644 --- a/packages/backend/src/server/api/AuthenticateService.ts +++ b/packages/backend/src/server/api/AuthenticateService.ts @@ -13,6 +13,7 @@ import type { MiApp } from '@/models/App.js'; import { CacheService } from '@/core/CacheService.js'; import { isNativeUserToken } from '@/misc/token.js'; import { bindThis } from '@/decorators.js'; +import { attachCallerId } from '@/misc/attach-caller-id.js'; export class AuthenticationError extends Error { constructor(message: string) { @@ -62,6 +63,9 @@ export class AuthenticateService implements OnApplicationShutdown { }, { token: token, // miauth }], + relations: { + user: true, + }, }); if (accessToken == null) { @@ -72,10 +76,11 @@ export class AuthenticateService implements OnApplicationShutdown { lastUsedAt: new Date(), }); - const user = await this.cacheService.localUserByIdCache.fetch(accessToken.userId, - () => this.usersRepository.findOneBy({ - id: accessToken.userId, - }) as Promise); + // Loaded by relation above + const user = accessToken.user as MiLocalUser; + + // Attach token to user - this will be read by RoleService to drop admin/moderator permissions. + attachCallerId(user, { accessToken }); if (accessToken.appId) { const app = await this.appCache.fetch(accessToken.appId, diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 90635906d6..54e8cfe841 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -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'; diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/find-by-email.ts b/packages/backend/src/server/api/endpoints/admin/accounts/find-by-email.ts index 12cd5cf295..617a1c2b39 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/find-by-email.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/find-by-email.ts @@ -57,7 +57,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.userNotFound); } - const res = await this.userEntityService.pack(profile.user!, null, { + const res = await this.userEntityService.pack(profile.user!, me, { schema: 'UserDetailedNotMe', }); diff --git a/packages/backend/src/server/api/endpoints/app/create.ts b/packages/backend/src/server/api/endpoints/app/create.ts index 6d595289de..7a9db03951 100644 --- a/packages/backend/src/server/api/endpoints/app/create.ts +++ b/packages/backend/src/server/api/endpoints/app/create.ts @@ -70,7 +70,7 @@ export default class extends Endpoint { // eslint- secret: secret, }); - return await this.appEntityService.pack(app, null, { + return await this.appEntityService.pack(app, me, { detail: true, includeSecret: true, }); diff --git a/packages/backend/src/server/api/endpoints/auth/session/userkey.ts b/packages/backend/src/server/api/endpoints/auth/session/userkey.ts index 8e9aff8058..1027eeb4d4 100644 --- a/packages/backend/src/server/api/endpoints/auth/session/userkey.ts +++ b/packages/backend/src/server/api/endpoints/auth/session/userkey.ts @@ -117,7 +117,7 @@ export default class extends Endpoint { // eslint- return { accessToken: accessToken.token, - user: await this.userEntityService.pack(session.userId, null, { + user: await this.userEntityService.pack(session.userId, me, { schema: 'UserDetailedNotMe', }), }; diff --git a/packages/backend/src/server/api/endpoints/bubble-game/ranking.ts b/packages/backend/src/server/api/endpoints/bubble-game/ranking.ts index 5597570f8e..1ac3488288 100644 --- a/packages/backend/src/server/api/endpoints/bubble-game/ranking.ts +++ b/packages/backend/src/server/api/endpoints/bubble-game/ranking.ts @@ -64,7 +64,7 @@ export default class extends Endpoint { // eslint- private userEntityService: UserEntityService, ) { - super(meta, paramDef, async (ps) => { + super(meta, paramDef, async (ps, me) => { const records = await this.bubbleGameRecordsRepository.find({ where: { gameMode: ps.gameMode, @@ -77,7 +77,7 @@ export default class extends Endpoint { // eslint- relations: ['user'], }); - const users = await this.userEntityService.packMany(records.map(r => r.user!), null); + const users = await this.userEntityService.packMany(records.map(r => r.user!), me); return records.map(r => ({ id: r.id, diff --git a/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts index c0e344b889..2bb7e93721 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts @@ -61,7 +61,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchRoom); } - if (!await this.chatService.hasPermissionToViewRoomTimeline(me.id, room)) { + if (!await this.chatService.hasPermissionToViewRoomTimeline(me, room)) { throw new ApiError(meta.errors.noSuchRoom); } diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts index 1ea81448c1..4ef90c1629 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts @@ -46,7 +46,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchRoom); } - if (!await this.chatService.hasPermissionToDeleteRoom(me.id, room)) { + if (!await this.chatService.hasPermissionToDeleteRoom(me, room)) { throw new ApiError(meta.errors.noSuchRoom); } diff --git a/packages/backend/src/server/api/endpoints/flash/create.ts b/packages/backend/src/server/api/endpoints/flash/create.ts index 64f13a577e..588de70b7b 100644 --- a/packages/backend/src/server/api/endpoints/flash/create.ts +++ b/packages/backend/src/server/api/endpoints/flash/create.ts @@ -70,7 +70,7 @@ export default class extends Endpoint { // eslint- visibility: ps.visibility, }); - return await this.flashEntityService.pack(flash); + return await this.flashEntityService.pack(flash, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/flash/my.ts b/packages/backend/src/server/api/endpoints/flash/my.ts index 48f464c337..eb1a7d328e 100644 --- a/packages/backend/src/server/api/endpoints/flash/my.ts +++ b/packages/backend/src/server/api/endpoints/flash/my.ts @@ -61,7 +61,7 @@ export default class extends Endpoint { // eslint- .limit(ps.limit) .getMany(); - return await this.flashEntityService.packMany(flashs); + return await this.flashEntityService.packMany(flashs, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts index 177bc601ac..23e90db356 100644 --- a/packages/backend/src/server/api/endpoints/i.ts +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -66,7 +66,6 @@ export default class extends Endpoint { // eslint- where: { userId: user.id, }, - relations: ['user'], }); if (userProfile == null) { @@ -80,7 +79,7 @@ export default class extends Endpoint { // eslint- userProfile.loggedInDates = [...userProfile.loggedInDates, today]; } - return await this.userEntityService.pack(userProfile.user!, userProfile.user!, { + return await this.userEntityService.pack(user, user, { schema: 'MeDetailed', includeSecrets: isSecure, userProfile, diff --git a/packages/backend/src/server/api/endpoints/i/apps.ts b/packages/backend/src/server/api/endpoints/i/apps.ts index f290ff6844..1563366da2 100644 --- a/packages/backend/src/server/api/endpoints/i/apps.ts +++ b/packages/backend/src/server/api/endpoints/i/apps.ts @@ -8,6 +8,9 @@ import { Endpoint } from '@/server/api/endpoint-base.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'; +import { QueryService } from '@/core/QueryService.js'; export const meta = { requireCredential: true, @@ -46,6 +49,19 @@ export const meta = { type: 'string', }, }, + grantees: { + type: 'array', + optional: false, + items: { + ref: 'UserLite', + }, + }, + rank: { + type: 'string', + optional: false, + nullable: true, + enum: ['admin', 'mod', 'user'], + }, }, }, }, @@ -61,6 +77,10 @@ export const paramDef = { type: 'object', properties: { sort: { type: 'string', enum: ['+createdAt', '-createdAt', '+lastUsedAt', '-lastUsedAt'] }, + onlySharedAccess: { type: 'boolean' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, }, required: [], } as const; @@ -71,11 +91,15 @@ export default class extends Endpoint { // eslint- @Inject(DI.accessTokensRepository) private accessTokensRepository: AccessTokensRepository, + private readonly userEntityService: UserEntityService, + private readonly cacheService: CacheService, + private readonly queryService: QueryService, private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.accessTokensRepository.createQueryBuilder('token') + const query = this.queryService.makePaginationQuery(this.accessTokensRepository.createQueryBuilder('token'), ps.sinceId, ps.untilId) .where('token.userId = :userId', { userId: me.id }) + .limit(ps.limit) .leftJoinAndSelect('token.app', 'app'); switch (ps.sort) { @@ -86,15 +110,27 @@ export default class extends Endpoint { // eslint- default: query.orderBy('token.id', 'ASC'); break; } + if (ps.onlySharedAccess) { + query.andWhere('token.granteeIds != \'{}\''); + } + const tokens = await query.getMany(); - return await Promise.all(tokens.map(token => ({ + const users = await this.cacheService.getUsers(tokens.flatMap(token => token.granteeIds)); + const packedUsers = await this.userEntityService.packMany(Array.from(users.values()), me); + const packedUserMap = new Map(packedUsers.map(u => [u.id, u])); + + 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..cbe482a821 100644 --- a/packages/backend/src/server/api/endpoints/i/revoke-token.ts +++ b/packages/backend/src/server/api/endpoints/i/revoke-token.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { AccessTokensRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { NotificationService } from '@/core/NotificationService.js'; export const meta = { requireCredential: true, @@ -37,21 +38,31 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.accessTokensRepository) private accessTokensRepository: AccessTokensRepository, + + private readonly notificationService: NotificationService, ) { super(meta, paramDef, async (ps, me) => { if (ps.tokenId) { - const tokenExist = await this.accessTokensRepository.exists({ where: { id: ps.tokenId } }); + const tokenExist = await this.accessTokensRepository.findOne({ where: { id: ps.tokenId } }); if (tokenExist) { + for (const granteeId of tokenExist.granteeIds) { + this.notificationService.createNotification(granteeId, 'sharedAccessRevoked', {}, me.id); + } + await this.accessTokensRepository.delete({ id: ps.tokenId, userId: me.id, }); } } else if (ps.token) { - const tokenExist = await this.accessTokensRepository.exists({ where: { token: ps.token } }); + const tokenExist = await this.accessTokensRepository.findOne({ where: { token: ps.token } }); if (tokenExist) { + for (const granteeId of tokenExist.granteeIds) { + this.notificationService.createNotification(granteeId, 'sharedAccessRevoked', {}, me.id); + } + await this.accessTokensRepository.delete({ token: ps.token, userId: me.id, 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 new file mode 100644 index 0000000000..17d4d5eabd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/shared-access/list.ts @@ -0,0 +1,103 @@ +/* + * 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 { AccessTokensRepository } from '@/models/_.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { QueryService } from '@/core/QueryService.js'; + +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, + }, + }, + rank: { + type: 'string', + enum: ['admin', 'mod', 'user'], + optional: false, nullable: true, + }, + }, + }, + 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 = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.accessTokensRepository) + private readonly accessTokensRepository: AccessTokensRepository, + + private readonly userEntityService: UserEntityService, + private readonly queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const tokens = await this.queryService.makePaginationQuery(this.accessTokensRepository.createQueryBuilder('token'), ps.sinceId, ps.untilId) + .where(':meIdAsList <@ token.granteeIds', { meIdAsList: [me.id] }) + .limit(ps.limit) + .getMany(); + + const userIds = tokens.map(token => token.userId); + const packedUsers = await this.userEntityService.packMany(userIds, me); + const packedUserMap = new Map(packedUsers.map(u => [u.id, u])); + + return tokens.map(token => ({ + id: token.id, + permissions: token.permission, + user: packedUserMap.get(token.userId), + rank: token.rank, + })); + }); + } +} 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 new file mode 100644 index 0000000000..7a624cd6ce --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/shared-access/login.ts @@ -0,0 +1,83 @@ +/* + * 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 { AccessTokensRepository } from '@/models/_.js'; +import { ApiError } from '@/server/api/error.js'; +import { NotificationService } from '@/core/NotificationService.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 { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.accessTokensRepository) + private readonly accessTokensRepository: AccessTokensRepository, + + private readonly notificationService: NotificationService, + ) { + super(meta, paramDef, async (ps, me) => { + const token = await this.accessTokensRepository.findOneBy({ id: ps.grantId }); + + if (!token) { + throw new ApiError(meta.errors.noSuchAccess); + } + + if (!token.granteeIds.includes(me.id)) { + throw new ApiError(meta.errors.noSuchAccess); + } + + this.notificationService.createNotification(token.userId, 'sharedAccessLogin', {}, me.id); + + 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 f962bd49f1..8644d40538 100644 --- a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts +++ b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts @@ -4,12 +4,14 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { ApiError } from '@/server/api/error.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { AccessTokensRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; export const meta = { tags: ['auth'], @@ -29,6 +31,19 @@ export const meta = { }, }, + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'a89abd3d-f0bc-4cce-beb1-2f446f4f1e6a', + }, + mustBeLocal: { + message: 'Grantee must be local', + code: 'MUST_BE_LOCAL', + id: '403c73e5-6f03-41b4-9394-ac128947f7ae', + }, + }, + // 10 calls per 5 seconds limit: { duration: 1000 * 5, @@ -46,6 +61,10 @@ export const paramDef = { permission: { type: 'array', uniqueItems: true, items: { type: 'string', } }, + grantees: { type: 'array', uniqueItems: true, items: { + type: 'string', + } }, + rank: { type: 'string', enum: ['admin', 'mod', 'user'], nullable: true }, }, required: ['session', 'permission'], } as const; @@ -58,8 +77,24 @@ export default class extends Endpoint { // eslint- private idService: IdService, private notificationService: NotificationService, + private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { + // Validate grantees + if (ps.grantees && ps.grantees.length > 0) { + const grantees = await this.cacheService.getUsers(ps.grantees); + + if (grantees.size !== ps.grantees.length) { + throw new ApiError(meta.errors.noSuchUser); + } + + for (const grantee of grantees.values()) { + if (grantee.host != null) { + throw new ApiError(meta.errors.mustBeLocal); + } + } + } + // Generate access token const accessToken = secureRndstr(32); @@ -77,8 +112,16 @@ export default class extends Endpoint { // eslint- description: ps.description, iconUrl: ps.iconUrl, permission: ps.permission, + rank: ps.rank, + granteeIds: ps.grantees, }); + if (ps.grantees) { + for (const granteeId of ps.grantees) { + this.notificationService.createNotification(granteeId, 'sharedAccessGranted', { permCount: ps.permission.length, rank: ps.rank ?? null }, me.id); + } + } + // アクセストークンが生成されたことを通知 this.notificationService.createNotification(me.id, 'createToken', {}); diff --git a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts index cd97a7773b..12c897d9c8 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -68,7 +68,7 @@ export default class extends Endpoint { // eslint- private activeUsersChart: ActiveUsersChart, ) { super(meta, paramDef, async (ps, me) => { - const policies = await this.roleService.getUserPolicies(me ? me.id : null); + const policies = await this.roleService.getUserPolicies(me); if (!policies.btlAvailable) { throw new ApiError(meta.errors.btlDisabled); } diff --git a/packages/backend/src/server/api/endpoints/notes/unrenote.ts b/packages/backend/src/server/api/endpoints/notes/unrenote.ts index f2a927f3c5..4a3ca8b656 100644 --- a/packages/backend/src/server/api/endpoints/notes/unrenote.ts +++ b/packages/backend/src/server/api/endpoints/notes/unrenote.ts @@ -69,9 +69,9 @@ export default class extends Endpoint { // eslint- // TODO inline this into the above query for (const note of renotes) { if (ps.quote) { - if (note.text) this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: me.id }), note, false); + if (note.text) this.noteDeleteService.delete(me, note, false); } else { - if (!note.text) this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: me.id }), note, false); + if (!note.text) this.noteDeleteService.delete(me, note, false); } } }); diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts index 6de5fe3d44..1f3ad9281e 100644 --- a/packages/backend/src/server/api/endpoints/pages/create.ts +++ b/packages/backend/src/server/api/endpoints/pages/create.ts @@ -119,7 +119,7 @@ export default class extends Endpoint { // eslint- font: ps.font, })); - return await this.pageEntityService.pack(page); + return await this.pageEntityService.pack(page, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/flashs.ts b/packages/backend/src/server/api/endpoints/users/flashs.ts index 2da46e8747..2b05f617bd 100644 --- a/packages/backend/src/server/api/endpoints/users/flashs.ts +++ b/packages/backend/src/server/api/endpoints/users/flashs.ts @@ -62,7 +62,7 @@ export default class extends Endpoint { .limit(ps.limit) .getMany(); - return await this.flashEntityService.packMany(flashs); + return await this.flashEntityService.packMany(flashs, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts index 2be3197d88..94986d22ea 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts @@ -106,7 +106,7 @@ export default class extends Endpoint { // eslint- const currentCount = await this.userListsRepository.countBy({ userId: me.id, }); - if (currentCount >= (await this.roleService.getUserPolicies(me.id)).userListLimit) { + if (currentCount >= (await this.roleService.getUserPolicies(me)).userListLimit) { throw new ApiError(meta.errors.tooManyUserLists); } diff --git a/packages/backend/src/server/api/endpoints/users/pages.ts b/packages/backend/src/server/api/endpoints/users/pages.ts index 3cb958066e..0b74a03a08 100644 --- a/packages/backend/src/server/api/endpoints/users/pages.ts +++ b/packages/backend/src/server/api/endpoints/users/pages.ts @@ -61,7 +61,7 @@ export default class extends Endpoint { // eslint- .limit(ps.limit) .getMany(); - return await this.pageEntityService.packMany(pages); + return await this.pageEntityService.packMany(pages, me); }); } } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 8f37b45004..e799447117 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -46,6 +46,9 @@ export const notificationTypes = [ 'scheduledNotePosted', 'app', 'test', + 'sharedAccessGranted', + 'sharedAccessRevoked', + 'sharedAccessLogin', ] as const; export const groupedNotificationTypes = [ diff --git a/packages/frontend/src/accounts.ts b/packages/frontend/src/accounts.ts index 4ee951bbd7..4de4249970 100644 --- a/packages/frontend/src/accounts.ts +++ b/packages/frontend/src/accounts.ts @@ -16,6 +16,7 @@ import { prefer } from '@/preferences.js'; import { store } from '@/store.js'; import { $i } from '@/i.js'; import { signout } from '@/signout.js'; +import * as os from '@/os'; type AccountWithToken = Misskey.entities.MeDetailed & { token: string }; @@ -39,7 +40,18 @@ export async function getAccounts(): Promise<{ } async function addAccount(host: string, user: Misskey.entities.User, token: AccountWithToken['token']) { - if (!prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) { + // Check for duplicate accounts + if (prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) { + if (store.s.accountTokens[host + '/' + user.id] !== token) { + // Replace account if the token changed + await removeAccount(host, user.id); + } else { + console.debug(`Not adding account ${host}/${user.id}: already logged in with same token.`); + return; + } + } + + { store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + user.id]: token }); store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + user.id]: user }); prefer.commit('accounts', [...prefer.s.accounts, [host, { id: user.id, username: user.username }]]); @@ -299,6 +311,15 @@ export async function openAccountMenu(opts: { } }); }, + }, { + text: i18n.ts.sharedAccount, + action: () => { + getAccountWithSharedAccessDialog().then((res) => { + if (res != null) { + os.success(); + } + }); + }, }], }, { type: 'link', @@ -324,6 +345,24 @@ export async function openAccountMenu(opts: { }); } +export function getAccountWithSharedAccessDialog(): Promise<{ id: string, token: string } | null> { + return new Promise((resolve) => { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/SkSigninSharedAccessDialog.vue')), {}, { + done: async (res: { id: string, i: string }) => { + const user = await fetchAccount(res.i, res.id, true); + await addAccount(host, user, res.i); + resolve({ id: res.id, token: res.i }); + }, + cancelled: () => { + resolve(null); + }, + closed: () => { + dispose(); + }, + }); + }); +} + export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> { return new Promise((resolve) => { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index bd15dab26d..e373ab1aaf 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -27,6 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_exportCompleted]: notification.type === 'exportCompleted', [$style.t_importCompleted]: notification.type === 'importCompleted', [$style.t_login]: notification.type === 'login', + [$style.t_login]: ['sharedAccessGranted', 'sharedAccessRevoked', 'sharedAccessLogin'].includes(notification.type), [$style.t_createToken]: notification.type === 'createToken', [$style.t_chatRoomInvitationReceived]: notification.type === 'chatRoomInvitationReceived', [$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null, @@ -34,7 +35,8 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_roleAssigned]: notification.type === 'scheduledNoteFailed', [$style.t_pollEnded]: notification.type === 'scheduledNotePosted', }]" - > + > + @@ -56,6 +58,9 @@ SPDX-License-Identifier: AGPL-3.0-only + + + {{ i18n.ts._notification.edited }} {{ i18n.ts._notification.scheduledNoteFailed }} {{ i18n.ts._notification.scheduledNotePosted }} + {{ i18n.ts._notification.sharedAccessGranted }} + {{ i18n.ts._notification.sharedAccessRevoked }} + {{ i18n.ts._notification.sharedAccessLogin }}
@@ -191,6 +199,28 @@ SPDX-License-Identifier: AGPL-3.0-only + +
+ + + + + + + +
+ + + + + + + + + + + +
diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index 42cb6f1e82..0b8a5dbee0 100644 --- a/packages/frontend/src/components/MkTokenGenerateWindow.vue +++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -147,4 +283,19 @@ function enableAll(): void { color: var(--MI_THEME-error); background: var(--MI_THEME-panel); } + +.grantee { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: var(--MI-marginHalf); +} + +.grantee > :first-child { + flex: 1; +} + +.divider { + border-bottom: 1px solid var(--MI_THEME-divider); +} diff --git a/packages/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue index e2e42e78f3..0c50233349 100644 --- a/packages/frontend/src/components/MkUserCardMini.vue +++ b/packages/frontend/src/components/MkUserCardMini.vue @@ -5,11 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -18,14 +18,17 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { onMounted, ref } from 'vue'; import MkMiniChart from '@/components/MkMiniChart.vue'; +import MkA from '@/components/global/MkA.vue'; import { misskeyApiGet } from '@/utility/misskey-api.js'; -import { acct } from '@/filters/user.js'; +import { acct, userPage } from '@/filters/user.js'; const props = withDefaults(defineProps<{ user: Misskey.entities.User; withChart?: boolean; + withLink?: boolean; }>(), { withChart: true, + withLink: false, }); const chartValues = ref(null); diff --git a/packages/frontend/src/components/SkSigninSharedAccessDialog.vue b/packages/frontend/src/components/SkSigninSharedAccessDialog.vue new file mode 100644 index 0000000000..c24dc1b122 --- /dev/null +++ b/packages/frontend/src/components/SkSigninSharedAccessDialog.vue @@ -0,0 +1,120 @@ + + + + + + + diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue index 2fd0a021da..bf8022c431 100644 --- a/packages/frontend/src/pages/settings/accounts.vue +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -24,7 +24,7 @@ import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { $i } from '@/i.js'; -import { switchAccount, removeAccount, login, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/accounts.js'; +import { switchAccount, removeAccount, login, getAccountWithSigninDialog, getAccountWithSignupDialog, getAccountWithSharedAccessDialog } from '@/accounts.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; @@ -59,9 +59,20 @@ function addAccount(ev: MouseEvent) { }, { text: i18n.ts.createAccount, action: () => { createAccount(); }, + }, { + text: i18n.ts.sharedAccount, + action: () => { addSharedAccount(); }, }], ev.currentTarget ?? ev.target); } +function addSharedAccount() { + getAccountWithSharedAccessDialog().then((res) => { + if (res != null) { + os.success(); + } + }); +} + function addExistingAccount() { getAccountWithSigninDialog().then((res) => { if (res != null) { diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue index 33c17e5d7f..d09fc01b32 100644 --- a/packages/frontend/src/pages/settings/apps.vue +++ b/packages/frontend/src/pages/settings/apps.vue @@ -32,12 +32,29 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + - + + + + + + + - +
    -
  • {{ i18n.ts._permissions[p] }}
  • +
  • {{ i18n.ts._permissions[p] }}
  • +
+
+ + + +
    +
  • {{ i18n.ts._permissions[p] }}
@@ -51,6 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only