diff --git a/locales/en-US.yml b/locales/en-US.yml index 004271869..885a487b4 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1098,6 +1098,7 @@ _permissions: "write:notes": "Create and delete notes" "read:notifications": "Read notifications" "write:notifications": "Mark notifications as read and create custom notifications" + "read:reactions": "View reactions" "write:reactions": "Create and delete reactions" "write:votes": "Vote in polls" "read:pages": "List and read pages" diff --git a/packages/backend/src/misc/fetch.ts b/packages/backend/src/misc/fetch.ts index b24eecd86..661716e25 100644 --- a/packages/backend/src/misc/fetch.ts +++ b/packages/backend/src/misc/fetch.ts @@ -35,7 +35,7 @@ export async function getHtml(url: string, accept = 'text/html, */*', timeout = return await res.text(); } -export async function getResponse(args: { url: string, method: string, body?: string, headers: Record, timeout?: number, size?: number }) { +export async function getResponse(args: { url: string, method: string, body?: string, headers: Record, timeout?: number, size?: number, redirect: 'follow' | 'manual' | 'error' = 'follow' }) { const timeout = args.timeout || 10 * SECOND; const controller = new AbortController(); @@ -47,8 +47,9 @@ export async function getResponse(args: { url: string, method: string, body?: st method: args.method, headers: args.headers, body: args.body, + redirect: args.redirect, timeout, - size: args.size || 10 * 1024 * 1024, + size: args.size || 10 * 1024 * 1024, // 10 MiB agent: getAgentByUrl, signal: controller.signal, }); diff --git a/packages/backend/src/misc/password.ts b/packages/backend/src/misc/password.ts new file mode 100644 index 000000000..4e5a3d1be --- /dev/null +++ b/packages/backend/src/misc/password.ts @@ -0,0 +1,10 @@ +import bcrypt from 'bcryptjs'; + +export async function hashPassword(password: string): Promise { + const salt = await bcrypt.genSalt(8); + return await bcrypt.hash(password, salt); +} + +export async function comparePassword(password: string, hash: string): Promise { + return await bcrypt.compare(password, hash); +} diff --git a/packages/backend/src/misc/should-block-instance.ts b/packages/backend/src/misc/should-block-instance.ts index 7396983d7..ddd25eeee 100644 --- a/packages/backend/src/misc/should-block-instance.ts +++ b/packages/backend/src/misc/should-block-instance.ts @@ -6,11 +6,10 @@ import { Meta } from '@/models/entities/meta.js'; * Returns whether a specific host (punycoded) should be blocked. * * @param host punycoded instance host - * @param meta a Promise contatining the information from the meta table (optional) + * @param meta a resolved Meta table * @returns whether the given host should be blocked */ - -export async function shouldBlockInstance(host: Instance['host'], meta: Promise = fetchMeta()): Promise { - const { blockedHosts } = await meta; +export async function shouldBlockInstance(host: Instance['host'], meta?: Meta): Promise { + const { blockedHosts } = meta ?? await fetchMeta(); return blockedHosts.some(blockedHost => host === blockedHost || host.endsWith('.' + blockedHost)); } diff --git a/packages/backend/src/misc/skipped-instances.ts b/packages/backend/src/misc/skipped-instances.ts index f5b5c3b73..0058c9bcf 100644 --- a/packages/backend/src/misc/skipped-instances.ts +++ b/packages/backend/src/misc/skipped-instances.ts @@ -15,8 +15,8 @@ const deadThreshold = 7 * DAY; * @returns array of punycoded instance hosts that should be skipped (subset of hosts parameter) */ export async function skippedInstances(hosts: Array): Promise> { - // Resolve the boolean promises before filtering - const meta = fetchMeta(); + // first check for blocked instances since that info may already be in memory + const meta = await fetchMeta(); const shouldSkip = await Promise.all(hosts.map(host => shouldBlockInstance(host, meta))); const skipped = hosts.filter((_, i) => shouldSkip[i]); diff --git a/packages/backend/src/models/repositories/drive-file.ts b/packages/backend/src/models/repositories/drive-file.ts index b4a7691b5..788949d6d 100644 --- a/packages/backend/src/models/repositories/drive-file.ts +++ b/packages/backend/src/models/repositories/drive-file.ts @@ -61,50 +61,24 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({ return thumbnail ? (file.thumbnailUrl || (isImage ? (file.webpublicUrl || file.url) : null)) : (file.webpublicUrl || file.url); }, - async calcDriveUsageOf(user: User['id'] | { id: User['id'] }): Promise { - const id = typeof user === 'object' ? user.id : user; - - const { sum } = await this - .createQueryBuilder('file') - .where('file.userId = :id', { id }) - .andWhere('file.isLink = FALSE') - .select('SUM(file.size)', 'sum') - .getRawOne(); - - return parseInt(sum, 10) || 0; + calcDriveUsageOf(id: User['id']): Promise { + return db.query('SELECT SUM(size) AS sum FROM drive_file WHERE "userId" = $1 AND NOT "isLink"', [id]) + .then(res => res[0].sum as number ?? 0); }, - async calcDriveUsageOfHost(host: string): Promise { - const { sum } = await this - .createQueryBuilder('file') - .where('file.userHost = :host', { host: toPuny(host) }) - .andWhere('file.isLink = FALSE') - .select('SUM(file.size)', 'sum') - .getRawOne(); - - return parseInt(sum, 10) || 0; + calcDriveUsageOfHost(host: string): Promise { + return db.query('SELECT SUM(size) AS sum FROM drive_file WHERE "userHost" = $1 AND NOT "isLink"', [toPuny(host)]) + .then(res => res[0].sum as number ?? 0); }, - async calcDriveUsageOfLocal(): Promise { - const { sum } = await this - .createQueryBuilder('file') - .where('file.userHost IS NULL') - .andWhere('file.isLink = FALSE') - .select('SUM(file.size)', 'sum') - .getRawOne(); - - return parseInt(sum, 10) || 0; + calcDriveUsageOfLocal(): Promise { + return db.query('SELECT SUM(size) AS sum FROM drive_file WHERE "userHost" IS NULL AND NOT "isLink"') + .then(res => res[0].sum as number ?? 0); }, - async calcDriveUsageOfRemote(): Promise { - const { sum } = await this - .createQueryBuilder('file') - .where('file.userHost IS NOT NULL') - .andWhere('file.isLink = FALSE') - .select('SUM(file.size)', 'sum') - .getRawOne(); - - return parseInt(sum, 10) || 0; + calcDriveUsageOfRemote(): Promise { + return db.query('SELECT SUM(size) AS sum FROM drive_file WHERE "userHost" IS NOT NULL AND NOT "isLink"') + .then(res => res[0].sum as number ?? 0); }, async pack( @@ -152,26 +126,7 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({ const file = typeof src === 'object' ? src : await this.findOneBy({ id: src }); if (file == null) return null; - return await awaitAll>({ - id: file.id, - createdAt: file.createdAt.toISOString(), - name: file.name, - type: file.type, - md5: file.md5, - size: file.size, - isSensitive: file.isSensitive, - blurhash: file.blurhash, - properties: opts.self ? file.properties : this.getPublicProperties(file), - url: opts.self ? file.url : this.getPublicUrl(file, false), - thumbnailUrl: this.getPublicUrl(file, true), - comment: file.comment, - folderId: file.folderId, - folder: opts.detail && file.folderId ? DriveFolders.pack(file.folderId, { - detail: true, - }) : null, - userId: opts.withUser ? file.userId : null, - user: (opts.withUser && file.userId) ? Users.pack(file.userId) : null, - }); + return await this.pack(file); }, async packMany( diff --git a/packages/backend/src/queue/processors/system/check-expired.ts b/packages/backend/src/queue/processors/system/check-expired.ts index fe7012b1d..ba344b712 100644 --- a/packages/backend/src/queue/processors/system/check-expired.ts +++ b/packages/backend/src/queue/processors/system/check-expired.ts @@ -1,8 +1,8 @@ import Bull from 'bull'; import { In, LessThan } from 'typeorm'; -import { AttestationChallenges, AuthSessions, Mutings, PasswordResetRequests, Signins } from '@/models/index.js'; +import { AttestationChallenges, AuthSessions, Mutings, Notifications, PasswordResetRequests, Signins } from '@/models/index.js'; import { publishUserEvent } from '@/services/stream.js'; -import { MINUTE, DAY } from '@/const.js'; +import { MINUTE, DAY, MONTH } from '@/const.js'; import { queueLogger } from '@/queue/logger.js'; const logger = queueLogger.createSubLogger('check-expired'); @@ -26,22 +26,30 @@ export async function checkExpired(job: Bull.Job>, done: } } + const OlderThan = (millis: number) => { + return LessThan(new Date(new Date().getTime() - millis)); + }; + await Signins.delete({ - // 60 days, or roughly equal to two months - createdAt: LessThan(new Date(new Date().getTime() - 60 * DAY)), + createdAt: OlderThan(2 * MONTH), }); await AttestationChallenges.delete({ - createdAt: LessThan(new Date(new Date().getTime() - 5 * MINUTE)), + createdAt: OlderThan(5 * MINUTE), }); await PasswordResetRequests.delete({ // this timing should be the same as in @/server/api/endpoints/reset-password.ts - createdAt: LessThan(new Date(new Date().getTime() - 30 * MINUTE)), + createdAt: OlderThan(30 * MINUTE), }); await AuthSessions.delete({ - createdAt: LessThan(new Date(new Date().getTime() - 15 * MINUTE)), + createdAt: OlderThan(15 * MINUTE), + }); + + await Notifications.delete({ + isRead: true, + createdAt: OlderThan(3 * MONTH), }); logger.succ('Deleted expired data.'); diff --git a/packages/backend/src/remote/activitypub/kernel/accept/follow.ts b/packages/backend/src/remote/activitypub/kernel/accept/follow.ts index 58249d2de..037f660c6 100644 --- a/packages/backend/src/remote/activitypub/kernel/accept/follow.ts +++ b/packages/backend/src/remote/activitypub/kernel/accept/follow.ts @@ -1,5 +1,5 @@ import { CacheableRemoteUser } from '@/models/entities/user.js'; -import accept from '@/services/following/requests/accept.js'; +import { acceptFollowRequest } from '@/services/following/requests/accept.js'; import { relayAccepted } from '@/services/relay.js'; import { IFollow } from '@/remote/activitypub/type.js'; import { DbResolver } from '@/remote/activitypub/db-resolver.js'; @@ -24,6 +24,6 @@ export default async (actor: CacheableRemoteUser, activity: IFollow): Promise { if (isCollectionOrOrderedCollection(activity)) { for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) { const act = await resolver.resolve(item); @@ -38,6 +40,11 @@ export async function performActivity(actor: CacheableRemoteUser, activity: IObj async function performOneActivity(actor: CacheableRemoteUser, activity: IObject, resolver: Resolver): Promise { if (actor.isSuspended) return; + if (typeof activity.id !== 'undefined') { + const host = extractDbHost(getApId(activity)); + if (await shouldBlockInstance(host)) return; + } + if (isCreate(activity)) { await create(actor, activity, resolver); } else if (isDelete(activity)) { @@ -55,7 +62,7 @@ async function performOneActivity(actor: CacheableRemoteUser, activity: IObject, } else if (isAdd(activity)) { await add(actor, activity, resolver).catch(err => apLogger.error(err)); } else if (isRemove(activity)) { - await remove(actor, activity).catch(err => apLogger.error(err)); + await remove(actor, activity, resolver).catch(err => apLogger.error(err)); } else if (isAnnounce(activity)) { await announce(actor, activity, resolver); } else if (isLike(activity)) { diff --git a/packages/backend/src/remote/activitypub/kernel/undo/follow.ts b/packages/backend/src/remote/activitypub/kernel/undo/follow.ts index b2607351a..d6f334e52 100644 --- a/packages/backend/src/remote/activitypub/kernel/undo/follow.ts +++ b/packages/backend/src/remote/activitypub/kernel/undo/follow.ts @@ -1,5 +1,5 @@ import unfollow from '@/services/following/delete.js'; -import cancelRequest from '@/services/following/requests/cancel.js'; +import { cancelFollowRequest } from '@/services/following/requests/cancel.js'; import { CacheableRemoteUser } from '@/models/entities/user.js'; import { FollowRequests, Followings } from '@/models/index.js'; import { IFollow } from '@/remote/activitypub/type.js'; @@ -29,7 +29,7 @@ export default async (actor: CacheableRemoteUser, activity: IFollow): Promise { const keypair = await getUserKeypair(user.id); - const req = createSignedGet({ - key: { - privateKeyPem: keypair.privateKey, - keyId: `${config.url}/users/${user.id}#main-key`, - }, - url, - additionalHeaders: { - 'User-Agent': config.userAgent, - }, - }); + for (let redirects = 0; redirects < 3; redirects++) { + const req = createSignedGet({ + key: { + privateKeyPem: keypair.privateKey, + keyId: `${config.url}/users/${user.id}#main-key`, + }, + url, + additionalHeaders: { + 'User-Agent': config.userAgent, + }, + }); - const res = await getResponse({ - url, - method: req.request.method, - headers: req.request.headers, - }); + const res = await getResponse({ + url, + method: req.request.method, + headers: req.request.headers, + redirect: 'manual', + }); - return await res.json(); + if (res.status >= 300 && res.status < 400) { + // Have been redirected, need to make a new signature. + // Use Location header and fetched URL as the base URL. + url = new URL(res.headers.get('Location'), url).href; + } else { + return await res.json(); + } + } + + throw new Error('too many redirects'); } diff --git a/packages/backend/src/remote/activitypub/resolver.ts b/packages/backend/src/remote/activitypub/resolver.ts index 8cf1ecd71..44e05b9e3 100644 --- a/packages/backend/src/remote/activitypub/resolver.ts +++ b/packages/backend/src/remote/activitypub/resolver.ts @@ -34,9 +34,7 @@ export class Resolver { } public async resolveCollection(value: string | IObject): Promise { - const collection = typeof value === 'string' - ? await this.resolve(value) - : value; + const collection = await this.resolve(value); if (isCollectionOrOrderedCollection(collection)) { return collection; @@ -45,12 +43,18 @@ export class Resolver { } } - public async resolve(value: string | IObject): Promise { + public async resolve(value?: string | IObject | null, allowRedirect = false): Promise { if (value == null) { throw new Error('resolvee is null (or undefined)'); } if (typeof value !== 'string') { + if (typeof value.id !== 'undefined') { + const host = extractDbHost(getApId(value)); + if (await shouldBlockInstance(host)) { + throw new Error('instance is blocked'); + } + } return value; } @@ -75,7 +79,7 @@ export class Resolver { } if (await shouldBlockInstance(host)) { - throw new Error('Instance is blocked'); + throw new Error('instance is blocked'); } if (!this.user) { @@ -94,7 +98,7 @@ export class Resolver { ) // Did we actually get the object that corresponds to the canonical URL? // Does the host we requested stuff from actually correspond to the host that owns the activity? - || !(getApId(object) == null || getApId(object) === value) + || !(getApId(object) == null || getApId(object) === value || allowRedirect) ) { throw new Error('invalid response'); } diff --git a/packages/backend/src/server/api/api-handler.ts b/packages/backend/src/server/api/api-handler.ts index 30a8dbcf5..de9af0890 100644 --- a/packages/backend/src/server/api/api-handler.ts +++ b/packages/backend/src/server/api/api-handler.ts @@ -5,28 +5,24 @@ import authenticate, { AuthenticationError } from './authenticate.js'; import call from './call.js'; import { ApiError } from './error.js'; -export async function handler(endpoint: IEndpoint, ctx: Koa.Context): Promise { - const body = ctx.is('multipart/form-data') - ? (ctx.request as any).body - : ctx.method === 'GET' - ? ctx.query - : ctx.request.body; - - const error = (e: ApiError): void => { - ctx.status = e.httpStatusCode; - if (e.httpStatusCode === 401) { - ctx.response.set('WWW-Authenticate', 'Bearer'); - } - ctx.body = { - error: { - message: e!.message, - code: e!.code, - ...(e!.info ? { info: e!.info } : {}), - endpoint: endpoint.name, - }, - }; +function getRequestArguments(ctx: Koa.Context): Record { + const args = { + ...(ctx.params || {}), + ...ctx.query, + ...(ctx.request.body || {}), }; + // For security reasons, we drop the i parameter if it's a GET request + if (ctx.method === 'GET') { + delete args['i']; + } + + return args; +} + +export async function handler(endpoint: IEndpoint, ctx: Koa.Context): Promise { + const body = getRequestArguments(ctx); + // Authentication // for GET requests, do not even pass on the body parameter as it is considered unsafe await authenticate(ctx.headers.authorization, ctx.method === 'GET' ? null : body['i']).then(async ([user, app]) => { @@ -43,13 +39,13 @@ export async function handler(endpoint: IEndpoint, ctx: Koa.Context): Promise { - error(e); + e.apply(ctx, endpoint.name); }); }).catch(e => { if (e instanceof AuthenticationError) { - error(new ApiError('AUTHENTICATION_FAILED', e.message)); + new ApiError('AUTHENTICATION_FAILED', e.message).apply(ctx, endpoint.name); } else { - error(new ApiError()); + new ApiError().apply(ctx, endpoint.name); } }); } diff --git a/packages/backend/src/server/api/common/signup.ts b/packages/backend/src/server/api/common/signup.ts index 150d27218..7ae1c09b0 100644 --- a/packages/backend/src/server/api/common/signup.ts +++ b/packages/backend/src/server/api/common/signup.ts @@ -1,11 +1,11 @@ import { generateKeyPair } from 'node:crypto'; -import bcrypt from 'bcryptjs'; import { IsNull } from 'typeorm'; import { User } from '@/models/entities/user.js'; import { Users, UsedUsernames } from '@/models/index.js'; import { UserProfile } from '@/models/entities/user-profile.js'; import { genId } from '@/misc/gen-id.js'; import { toPunyNullable } from '@/misc/convert-host.js'; +import { hashPassword } from '@/misc/password.js'; import { UserKeypair } from '@/models/entities/user-keypair.js'; import { usersChart } from '@/services/chart/index.js'; import { UsedUsername } from '@/models/entities/used-username.js'; @@ -33,9 +33,7 @@ export async function signup(opts: { throw new ApiError('INVALID_PASSWORD'); } - // Generate hash of password - const salt = await bcrypt.genSalt(8); - hash = await bcrypt.hash(password, salt); + hash = await hashPassword(password); } // Generate secret diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index c85bb6632..6e69b3b11 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -702,6 +702,24 @@ export interface IEndpointMeta { * 正常応答をキャッシュ (Cache-Control: public) する秒数 */ readonly cacheSec?: number; + + /** + * API v2 options + */ + readonly v2?: { + + /** + * HTTP verb this endpoint supports + */ + readonly method: 'get' | 'put' | 'post' | 'patch' | 'delete'; + + /** + * Path alias for v2 endpoint + * + * @example (v0) /api/notes/create -> /api/v2/notes + */ + readonly alias?: string; + }; } export interface IEndpoint { diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts index 8da4c3d68..3012d2127 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -1,5 +1,6 @@ import { IsNull } from 'typeorm'; import { Users } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.js'; import define from '../../../define.js'; import { signup } from '../../../common/signup.js'; @@ -17,6 +18,8 @@ export const meta = { }, }, }, + + errors: ['ACCESS_DENIED'], } as const; export const paramDef = { @@ -31,10 +34,17 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, _me) => { const me = _me ? await Users.findOneByOrFail({ id: _me.id }) : null; - const noUsers = (await Users.countBy({ - host: IsNull(), - })) === 0; - if (!noUsers && !me?.isAdmin) throw new Error('access denied'); + if (me == null) { + // check if this is the initial setup + const noUsers = (await Users.countBy({ + host: IsNull(), + })) === 0; + if (!noUsers) { + throw new ApiError('ACCESS_DENIED'); + } + } else if (!me.isAdmin) { + throw new ApiError('ACCESS_DENIED'); + } const { account, secret } = await signup({ username: ps.username, diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts index 623b909f4..7c0392e3c 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts @@ -1,4 +1,5 @@ import { Users } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.js'; import { doPostSuspend } from '@/services/suspend-user.js'; import { publishUserEvent } from '@/services/stream.js'; import { createDeleteAccountJob } from '@/queue/index.js'; @@ -9,6 +10,8 @@ export const meta = { requireCredential: true, requireModerator: true, + + errors: ['NO_SUCH_USER', 'IS_ADMIN', 'IS_MODERATOR'], } as const; export const paramDef = { @@ -24,15 +27,11 @@ export default define(meta, paramDef, async (ps, me) => { const user = await Users.findOneBy({ id: ps.userId }); if (user == null) { - throw new Error('user not found'); - } - - if (user.isAdmin) { - throw new Error('cannot suspend admin'); - } - - if (user.isModerator) { - throw new Error('cannot suspend moderator'); + throw new ApiError('NO_SUCH_USER'); + } else if (user.isAdmin) { + throw new ApiError('IS_ADMIN'); + } else if(user.isModerator) { + throw new ApiError('IS_MODERATOR'); } if (Users.isLocalUser(user)) { diff --git a/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts b/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts index 5c691a1bc..439a802e1 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts @@ -1,5 +1,6 @@ import { Instances } from '@/models/index.js'; import { toPuny } from '@/misc/convert-host.js'; +import { ApiError } from '@/server/api/error.js'; import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js'; import define from '../../../define.js'; @@ -8,6 +9,8 @@ export const meta = { requireCredential: true, requireModerator: true, + + errors: ['NO_SUCH_OBJECT'], } as const; export const paramDef = { @@ -23,7 +26,7 @@ export default define(meta, paramDef, async (ps, me) => { const instance = await Instances.findOneBy({ host: toPuny(ps.host) }); if (instance == null) { - throw new Error('instance not found'); + throw new ApiError('NO_SUCH_OBJECT'); } fetchInstanceMetadata(instance, true); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts index bcfe9077e..a810300b5 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts @@ -1,5 +1,6 @@ import { Instances } from '@/models/index.js'; import { toPuny } from '@/misc/convert-host.js'; +import { ApiError } from '@/server/api/error.js'; import define from '../../../define.js'; export const meta = { @@ -7,6 +8,8 @@ export const meta = { requireCredential: true, requireModerator: true, + + errors: ['NO_SUCH_OBJECT'], } as const; export const paramDef = { @@ -23,7 +26,7 @@ export default define(meta, paramDef, async (ps, me) => { const instance = await Instances.findOneBy({ host: toPuny(ps.host) }); if (instance == null) { - throw new Error('instance not found'); + throw new ApiError('NO_SUCH_OBJECT'); } Instances.update({ host: toPuny(ps.host) }, { diff --git a/packages/backend/src/server/api/endpoints/admin/moderators/add.ts b/packages/backend/src/server/api/endpoints/admin/moderators/add.ts index 2c46e1fc5..abea8c851 100644 --- a/packages/backend/src/server/api/endpoints/admin/moderators/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/moderators/add.ts @@ -1,12 +1,17 @@ import { Users } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.js'; import { publishInternalEvent } from '@/services/stream.js'; import define from '../../../define.js'; export const meta = { tags: ['admin'], + description: 'Grants a user moderator privileges. Administrators cannot be granted moderator privileges.', + requireCredential: true, requireAdmin: true, + + errors: ['NO_SUCH_USER', 'IS_ADMIN'], } as const; export const paramDef = { @@ -22,11 +27,11 @@ export default define(meta, paramDef, async (ps) => { const user = await Users.findOneBy({ id: ps.userId }); if (user == null) { - throw new Error('user not found'); + throw new ApiError('NO_SUCH_USER'); } if (user.isAdmin) { - throw new Error('cannot mark as moderator if admin user'); + throw new ApiError('IS_ADMIN'); } await Users.update(user.id, { diff --git a/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts b/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts index 19e507fe4..0a988b820 100644 --- a/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts +++ b/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts @@ -1,4 +1,5 @@ import { Users } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.js'; import { publishInternalEvent } from '@/services/stream.js'; import define from '../../../define.js'; @@ -7,6 +8,8 @@ export const meta = { requireCredential: true, requireAdmin: true, + + errors: ['NO_SUCH_USER'], } as const; export const paramDef = { @@ -22,7 +25,7 @@ export default define(meta, paramDef, async (ps) => { const user = await Users.findOneBy({ id: ps.userId }); if (user == null) { - throw new Error('user not found'); + throw new ApiError('NO_SUCH_USER'); } await Users.update(user.id, { diff --git a/packages/backend/src/server/api/endpoints/admin/relays/add.ts b/packages/backend/src/server/api/endpoints/admin/relays/add.ts index 147b7298c..5fbce5411 100644 --- a/packages/backend/src/server/api/endpoints/admin/relays/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/relays/add.ts @@ -51,7 +51,7 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, user) => { try { - if (new URL(ps.inbox).protocol !== 'https:') throw new Error('https only'); + if (new URL(ps.inbox).protocol !== 'https:') throw new ApiError('INVALID_URL', 'https only'); } catch (e) { throw new ApiError('INVALID_URL', e); } diff --git a/packages/backend/src/server/api/endpoints/admin/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/reset-password.ts index 97d6f51d4..5561b1507 100644 --- a/packages/backend/src/server/api/endpoints/admin/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts @@ -1,6 +1,7 @@ -import bcrypt from 'bcryptjs'; +import { hashPassword } from '@/misc/password.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { Users, UserProfiles } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.js'; import define from '../../define.js'; export const meta = { @@ -16,11 +17,11 @@ export const meta = { password: { type: 'string', optional: false, nullable: false, - minLength: 8, - maxLength: 8, }, }, }, + + errors: ['NO_SUCH_USER', 'IS_ADMIN'], } as const; export const paramDef = { @@ -36,25 +37,22 @@ export default define(meta, paramDef, async (ps) => { const user = await Users.findOneBy({ id: ps.userId }); if (user == null) { - throw new Error('user not found'); + throw new ApiError('NO_SUCH_USER'); } if (user.isAdmin) { - throw new Error('cannot reset password of admin'); + throw new ApiError('IS_ADMIN'); } - const passwd = secureRndstr(8, true); - - // Generate hash of password - const hash = bcrypt.hashSync(passwd); + const password = secureRndstr(8, true); await UserProfiles.update({ userId: user.id, }, { - password: hash, + password: await hashPassword(password), }); return { - password: passwd, + password, }; }); diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index d7cf37ea3..604426470 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -1,4 +1,5 @@ import { Signins, UserProfiles, Users } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.js'; import define from '../../define.js'; export const meta = { @@ -11,6 +12,8 @@ export const meta = { type: 'object', nullable: false, optional: false, }, + + errors: ['NO_SUCH_USER', 'IS_ADMIN'], } as const; export const paramDef = { @@ -29,12 +32,12 @@ export default define(meta, paramDef, async (ps, me) => { ]); if (user == null || profile == null) { - throw new Error('user not found'); + throw new ApiError('NO_SUCH_USER'); } const _me = await Users.findOneByOrFail({ id: me.id }); if ((_me.isModerator && !_me.isAdmin) && user.isAdmin) { - throw new Error('cannot show info of admin'); + throw new ApiError('IS_ADMIN'); } if (!_me.isAdmin) { diff --git a/packages/backend/src/server/api/endpoints/admin/silence-user.ts b/packages/backend/src/server/api/endpoints/admin/silence-user.ts index 24bd2dd3f..7b0a1bbad 100644 --- a/packages/backend/src/server/api/endpoints/admin/silence-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/silence-user.ts @@ -1,4 +1,5 @@ import { Users } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.js'; import { insertModerationLog } from '@/services/insert-moderation-log.js'; import { publishInternalEvent } from '@/services/stream.js'; import define from '../../define.js'; @@ -8,6 +9,8 @@ export const meta = { requireCredential: true, requireModerator: true, + + errors: ['NO_SUCH_USER', 'IS_ADMIN'], } as const; export const paramDef = { @@ -23,11 +26,11 @@ export default define(meta, paramDef, async (ps, me) => { const user = await Users.findOneBy({ id: ps.userId }); if (user == null) { - throw new Error('user not found'); + throw new ApiError('NO_SUCH_USER'); } if (user.isAdmin) { - throw new Error('cannot silence admin'); + throw new ApiError('IS_ADMIN'); } await Users.update(user.id, { diff --git a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts index 90ad703a3..473fa220e 100644 --- a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts @@ -1,6 +1,7 @@ import deleteFollowing from '@/services/following/delete.js'; import { Users, Followings, Notifications } from '@/models/index.js'; import { User } from '@/models/entities/user.js'; +import { ApiError } from '@/server/api/error.js'; import { insertModerationLog } from '@/services/insert-moderation-log.js'; import { doPostSuspend } from '@/services/suspend-user.js'; import { publishUserEvent } from '@/services/stream.js'; @@ -11,6 +12,8 @@ export const meta = { requireCredential: true, requireModerator: true, + + errors: ['NO_SUCH_USER', 'IS_ADMIN', 'IS_MODERATOR'], } as const; export const paramDef = { @@ -26,15 +29,11 @@ export default define(meta, paramDef, async (ps, me) => { const user = await Users.findOneBy({ id: ps.userId }); if (user == null) { - throw new Error('user not found'); - } - - if (user.isAdmin) { - throw new Error('cannot suspend admin'); - } - - if (user.isModerator) { - throw new Error('cannot suspend moderator'); + throw new ApiError('NO_SUCH_USER'); + } else if (user.isAdmin) { + throw new ApiError('IS_ADMIN'); + } else if (user.isModerator) { + throw new ApiError('IS_MODERATOR'); } await Users.update(user.id, { diff --git a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts index 6d20233c5..7c088bd76 100644 --- a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts @@ -1,4 +1,5 @@ import { Users } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.js'; import { insertModerationLog } from '@/services/insert-moderation-log.js'; import { publishInternalEvent } from '@/services/stream.js'; import define from '../../define.js'; @@ -8,6 +9,8 @@ export const meta = { requireCredential: true, requireModerator: true, + + errors: ['NO_SUCH_USER'], } as const; export const paramDef = { @@ -23,7 +26,7 @@ export default define(meta, paramDef, async (ps, me) => { const user = await Users.findOneBy({ id: ps.userId }); if (user == null) { - throw new Error('user not found'); + throw new ApiError('NO_SUCH_USER'); } await Users.update(user.id, { diff --git a/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts b/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts index 4f4fe4a0e..17fad27c4 100644 --- a/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts @@ -1,4 +1,5 @@ import { Users } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.js'; import { insertModerationLog } from '@/services/insert-moderation-log.js'; import { doPostUnsuspend } from '@/services/unsuspend-user.js'; import define from '../../define.js'; @@ -8,6 +9,8 @@ export const meta = { requireCredential: true, requireModerator: true, + + errors: ['NO_SUCH_USER'], } as const; export const paramDef = { @@ -23,7 +26,7 @@ export default define(meta, paramDef, async (ps, me) => { const user = await Users.findOneBy({ id: ps.userId }); if (user == null) { - throw new Error('user not found'); + throw new ApiError('NO_SUCH_USER'); } await Users.update(user.id, { diff --git a/packages/backend/src/server/api/endpoints/ap/get.ts b/packages/backend/src/server/api/endpoints/ap/get.ts index 7f261b0bf..3a051086a 100644 --- a/packages/backend/src/server/api/endpoints/ap/get.ts +++ b/packages/backend/src/server/api/endpoints/ap/get.ts @@ -29,6 +29,6 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps) => { const resolver = new Resolver(); - const object = await resolver.resolve(ps.uri); + const object = await resolver.resolve(ps.uri, true); return object; }); diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 4f8832d6b..7770e1220 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -98,9 +98,10 @@ async function fetchAny(uri: string, me: CacheableLocalUser | null | undefined): ])); if (local != null) return local; - // リモートから一旦オブジェクトフェッチ + // fetch object from remote const resolver = new Resolver(); - const object = await resolver.resolve(uri) as any; + // allow redirect + const object = await resolver.resolve(uri, true) as any; // /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する // これはDBに存在する可能性があるため再度DB検索 diff --git a/packages/backend/src/server/api/endpoints/drive.ts b/packages/backend/src/server/api/endpoints/drive.ts index 5af27c37f..f8e39fb57 100644 --- a/packages/backend/src/server/api/endpoints/drive.ts +++ b/packages/backend/src/server/api/endpoints/drive.ts @@ -35,7 +35,6 @@ export const paramDef = { export default define(meta, paramDef, async (ps, user) => { const instance = await fetchMeta(true); - // Calculate drive usage const usage = await DriveFiles.calcDriveUsageOf(user.id); return { diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts index fe0d23e83..862a5530d 100644 --- a/packages/backend/src/server/api/endpoints/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/drive/files.ts @@ -45,7 +45,7 @@ export default define(meta, paramDef, async (ps, user) => { if (ps.type) { if (ps.type.endsWith('/*')) { - query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); + query.andWhere('file.type like :type', { type: ps.type.slice(0, -1) + '%' }); } else { query.andWhere('file.type = :type', { type: ps.type }); } diff --git a/packages/backend/src/server/api/endpoints/following/requests/accept.ts b/packages/backend/src/server/api/endpoints/following/requests/accept.ts index cd30eab98..f31aab855 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/accept.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/accept.ts @@ -1,4 +1,4 @@ -import acceptFollowRequest from '@/services/following/requests/accept.js'; +import { acceptFollowRequest } from '@/services/following/requests/accept.js'; import define from '../../../define.js'; import { ApiError } from '../../../error.js'; import { getUser } from '../../../common/getters.js'; diff --git a/packages/backend/src/server/api/endpoints/following/requests/cancel.ts b/packages/backend/src/server/api/endpoints/following/requests/cancel.ts index 3827007f1..c1c1e722e 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/cancel.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/cancel.ts @@ -1,4 +1,4 @@ -import cancelFollowRequest from '@/services/following/requests/cancel.js'; +import { cancelFollowRequest } from '@/services/following/requests/cancel.js'; import { Users } from '@/models/index.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import define from '../../../define.js'; diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts index a22b603a5..a2c7e6038 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts @@ -3,6 +3,7 @@ import { genId } from '@/misc/gen-id.js'; import { GalleryPost } from '@/models/entities/gallery-post.js'; import { DriveFile } from '@/models/entities/drive-file.js'; import { HOUR } from '@/const.js'; +import { ApiError } from '@/server/api/error.js'; import define from '../../../define.js'; export const meta = { @@ -46,8 +47,14 @@ export default define(meta, paramDef, async (ps, user) => { }), ))).filter((file): file is DriveFile => file != null); - if (files.length === 0) { - throw new Error(); + if (files.length !== ps.fileIds.length) { + throw new ApiError( + 'INVALID_PARAM', + { + param: '#/properties/fileIds/items', + reason: 'contains invalid file IDs', + } + ); } const post = await GalleryPosts.insert(new GalleryPost({ diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts index 20cab9243..cd911bf47 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts @@ -1,6 +1,7 @@ import { DriveFiles, GalleryPosts } from '@/models/index.js'; import { DriveFile } from '@/models/entities/drive-file.js'; import { HOUR } from '@/const.js'; +import { ApiError } from '@/server/api/error.js'; import define from '../../../define.js'; export const meta = { @@ -20,6 +21,8 @@ export const meta = { optional: false, nullable: false, ref: 'GalleryPost', }, + + errors: ['INVALID_PARAM'], } as const; export const paramDef = { @@ -45,8 +48,14 @@ export default define(meta, paramDef, async (ps, user) => { }), ))).filter((file): file is DriveFile => file != null); - if (files.length === 0) { - throw new Error(); + if (files.length !== ps.fileIds.length) { + throw new ApiError( + 'INVALID_PARAM', + { + param: '#/properties/fileIds/items', + reason: 'contains invalid file IDs', + } + ); } await GalleryPosts.update({ diff --git a/packages/backend/src/server/api/endpoints/i/2fa/done.ts b/packages/backend/src/server/api/endpoints/i/2fa/done.ts index 730f77916..3800290b7 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/done.ts @@ -1,11 +1,14 @@ import * as speakeasy from 'speakeasy'; import { UserProfiles } from '@/models/index.js'; import define from '../../../define.js'; +import { ApiError } from '@/server/api/error.js'; export const meta = { requireCredential: true, secure: true, + + errors: ['INTERNAL_ERROR', 'ACCESS_DENIED'], } as const; export const paramDef = { @@ -23,7 +26,7 @@ export default define(meta, paramDef, async (ps, user) => { const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); if (profile.twoFactorTempSecret == null) { - throw new Error('二段階認証の設定が開始されていません'); + throw new ApiError('INTERNAL_ERROR', 'Two-step verification has not been initiated.'); } const verified = (speakeasy as any).totp.verify({ @@ -33,7 +36,7 @@ export default define(meta, paramDef, async (ps, user) => { }); if (!verified) { - throw new Error('not verified'); + throw new ApiError('ACCESS_DENIED', 'TOTP missmatch'); } await UserProfiles.update(user.id, { diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts index 37d480e7c..73773c8c5 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -1,7 +1,7 @@ import { promisify } from 'node:util'; -import bcrypt from 'bcryptjs'; import * as cbor from 'cbor'; import { MINUTE } from '@/const.js'; +import { comparePassword } from '@/misc/password.js'; import { UserProfiles, UserSecurityKeys, @@ -9,6 +9,7 @@ import { Users, } from '@/models/index.js'; import config from '@/config/index.js'; +import { ApiError } from '@/server/api/error.js'; import { publishMainStream } from '@/services/stream.js'; import define from '../../../define.js'; import { procedures, hash } from '../../../2fa.js'; @@ -20,6 +21,8 @@ export const meta = { requireCredential: true, secure: true, + + errors: ['ACCESS_DENIED', 'INTERNAL_ERROR', 'NO_SUCH_OBJECT'], } as const; export const paramDef = { @@ -38,24 +41,21 @@ export const paramDef = { export default define(meta, paramDef, async (ps, user) => { const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); - - if (!same) { - throw new Error('incorrect password'); + if (!(await comparePassword(ps.password, profile.password!))) { + throw new ApiError('ACCESS_DENIED'); } if (!profile.twoFactorEnabled) { - throw new Error('2fa not enabled'); + throw new ApiError('INTERNAL_ERROR', '2fa not enabled'); } const clientData = JSON.parse(ps.clientDataJSON); if (clientData.type !== 'webauthn.create') { - throw new Error('not a creation attestation'); + throw new ApiError('INTERNAL_ERROR', 'not a creation attestation'); } if (clientData.origin !== config.scheme + '://' + config.host) { - throw new Error('origin mismatch'); + throw new ApiError('INTERNAL_ERROR', 'origin mismatch'); } const clientDataJSONHash = hash(Buffer.from(ps.clientDataJSON, 'utf-8')); @@ -64,14 +64,14 @@ export default define(meta, paramDef, async (ps, user) => { const rpIdHash = attestation.authData.slice(0, 32); if (!rpIdHashReal.equals(rpIdHash)) { - throw new Error('rpIdHash mismatch'); + throw new ApiError('INTERNAL_ERROR', 'rpIdHash mismatch'); } const flags = attestation.authData[32]; // eslint:disable-next-line:no-bitwise if (!(flags & 1)) { - throw new Error('user not present'); + throw new ApiError('INTERNAL_ERROR', 'user not present'); } const authData = Buffer.from(attestation.authData); @@ -80,11 +80,11 @@ export default define(meta, paramDef, async (ps, user) => { const publicKeyData = authData.slice(55 + credentialIdLength); const publicKey: Map = await cborDecodeFirst(publicKeyData); if (publicKey.get(3) !== -7) { - throw new Error('alg mismatch'); + throw new ApiError('INTERNAL_ERROR', 'algorithm mismatch'); } if (!(procedures as any)[attestation.fmt]) { - throw new Error('unsupported fmt'); + throw new ApiError('INTERNAL_ERROR', 'unsupported fmt'); } const verificationData = (procedures as any)[attestation.fmt].verify({ @@ -95,7 +95,7 @@ export default define(meta, paramDef, async (ps, user) => { publicKey, rpIdHash, }); - if (!verificationData.valid) throw new Error('signature invalid'); + if (!verificationData.valid) throw new ApiError('INTERNAL_ERROR', 'signature invalid'); const attestationChallenge = await AttestationChallenges.findOneBy({ userId: user.id, @@ -105,7 +105,7 @@ export default define(meta, paramDef, async (ps, user) => { }); if (!attestationChallenge) { - throw new Error('non-existent challenge'); + throw new ApiError('NO_SUCH_OBJECT', 'Attestation challenge not found.'); } await AttestationChallenges.delete({ @@ -118,7 +118,7 @@ export default define(meta, paramDef, async (ps, user) => { new Date().getTime() - attestationChallenge.createdAt.getTime() >= 5 * MINUTE ) { - throw new Error('expired challenge'); + throw new ApiError('NO_SUCH_OBJECT', 'Attestation challenge expired.'); } const credentialIdString = credentialId.toString('hex'); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts index 555b98d5a..32e85463c 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts @@ -1,8 +1,9 @@ import { promisify } from 'node:util'; import * as crypto from 'node:crypto'; -import bcrypt from 'bcryptjs'; import { UserProfiles, AttestationChallenges } from '@/models/index.js'; import { genId } from '@/misc/gen-id.js'; +import { comparePassword } from '@/misc/password.js'; +import { ApiError } from '@/server/api/error.js'; import define from '../../../define.js'; import { hash } from '../../../2fa.js'; @@ -12,6 +13,8 @@ export const meta = { requireCredential: true, secure: true, + + errors: ['ACCESS_DENIED', 'INTERNAL_ERROR'], } as const; export const paramDef = { @@ -26,15 +29,12 @@ export const paramDef = { export default define(meta, paramDef, async (ps, user) => { const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); - - if (!same) { - throw new Error('incorrect password'); + if (!(await comparePassword(ps.password, profile.password!))) { + throw new ApiError('ACCESS_DENIED'); } if (!profile.twoFactorEnabled) { - throw new Error('2fa not enabled'); + throw new ApiError('INTERNAL_ERROR', '2fa not enabled'); } // 32 byte challenge diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts index 33f571772..59a7f5d8b 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -1,14 +1,17 @@ -import bcrypt from 'bcryptjs'; import * as speakeasy from 'speakeasy'; import * as QRCode from 'qrcode'; import config from '@/config/index.js'; +import { comparePassword } from '@/misc/password.js'; import { UserProfiles } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.js'; import define from '../../../define.js'; export const meta = { requireCredential: true, secure: true, + + errors: ['ACCESS_DENIED'], } as const; export const paramDef = { @@ -23,11 +26,8 @@ export const paramDef = { export default define(meta, paramDef, async (ps, user) => { const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); - - if (!same) { - throw new Error('incorrect password'); + if (!(await comparePassword(ps.password, profile.password!))) { + throw new ApiError('ACCESS_DENIED'); } // Generate user's secret key diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts index 4467290b8..4164e114c 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -1,12 +1,15 @@ -import bcrypt from 'bcryptjs'; +import { comparePassword } from '@/misc/password.js'; import { UserProfiles, UserSecurityKeys, Users } from '@/models/index.js'; import { publishMainStream } from '@/services/stream.js'; +import { ApiError } from '@/server/api/error.js'; import define from '../../../define.js'; export const meta = { requireCredential: true, secure: true, + + errors: ['ACCESS_DENIED'], } as const; export const paramDef = { @@ -22,11 +25,8 @@ export const paramDef = { export default define(meta, paramDef, async (ps, user) => { const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); - - if (!same) { - throw new Error('incorrect password'); + if (!(await comparePassword(ps.password, profile.password!))) { + throw new ApiError('ACCESS_DENIED'); } // Make sure we only delete the user's own creds diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts index 4deefa37b..c82fde0b3 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -1,11 +1,14 @@ -import bcrypt from 'bcryptjs'; +import { comparePassword } from '@/misc/password.js'; import { UserProfiles } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.js'; import define from '../../../define.js'; export const meta = { requireCredential: true, secure: true, + + errors: ['ACCESS_DENIED'], } as const; export const paramDef = { @@ -20,11 +23,8 @@ export const paramDef = { export default define(meta, paramDef, async (ps, user) => { const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); - - if (!same) { - throw new Error('incorrect password'); + if (!(await comparePassword(ps.password, profile.password!))) { + throw new ApiError('ACCESS_DENIED'); } await UserProfiles.update(user.id, { diff --git a/packages/backend/src/server/api/endpoints/i/change-password.ts b/packages/backend/src/server/api/endpoints/i/change-password.ts index 5f625b695..e7fd70391 100644 --- a/packages/backend/src/server/api/endpoints/i/change-password.ts +++ b/packages/backend/src/server/api/endpoints/i/change-password.ts @@ -1,11 +1,14 @@ -import bcrypt from 'bcryptjs'; +import { comparePassword, hashPassword } from '@/misc/password.js'; import { UserProfiles } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.js'; import define from '../../define.js'; export const meta = { requireCredential: true, secure: true, + + errors: ['ACCESS_DENIED'], } as const; export const paramDef = { @@ -21,18 +24,11 @@ export const paramDef = { export default define(meta, paramDef, async (ps, user) => { const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - // Compare password - const same = await bcrypt.compare(ps.currentPassword, profile.password!); - - if (!same) { - throw new Error('incorrect password'); + if (!(await comparePassword(ps.currentPassword, profile.password!))) { + throw new ApiError('ACCESS_DENIED'); } - // Generate hash of password - const salt = await bcrypt.genSalt(8); - const hash = await bcrypt.hash(ps.newPassword, salt); - await UserProfiles.update(user.id, { - password: hash, + password: await hashPassword(ps.newPassword), }); }); diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts index ede4a9d03..5dae620ae 100644 --- a/packages/backend/src/server/api/endpoints/i/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts @@ -1,12 +1,15 @@ -import bcrypt from 'bcryptjs'; +import { comparePassword } from '@/misc/password.js'; import { UserProfiles, Users } from '@/models/index.js'; import { deleteAccount } from '@/services/delete-account.js'; +import { ApiError } from '@/server/api/error.js'; import define from '../../define.js'; export const meta = { requireCredential: true, secure: true, + + errors: ['ACCESS_DENIED'], } as const; export const paramDef = { @@ -19,17 +22,17 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, user) => { - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - const userDetailed = await Users.findOneByOrFail({ id: user.id }); + const [profile, userDetailed] = await Promise.all([ + UserProfiles.findOneByOrFail({ userId: user.id }), + Users.findOneByOrFail({ id: user.id }), + ]); + if (userDetailed.isDeleted) { return; } - // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); - - if (!same) { - throw new Error('incorrect password'); + if (!(await comparePassword(ps.password, profile.password!))) { + throw new ApiError('ACCESS_DENIED'); } await deleteAccount(user); diff --git a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts index 37cdf4846..2bec1ed17 100644 --- a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -1,13 +1,16 @@ -import bcrypt from 'bcryptjs'; +import { comparePassword } from '@/misc/password.js'; import { publishInternalEvent, publishMainStream, publishUserEvent } from '@/services/stream.js'; import { Users, UserProfiles } from '@/models/index.js'; import generateUserToken from '../../common/generate-native-user-token.js'; +import { ApiError } from '@/server/api/error.js'; import define from '../../define.js'; export const meta = { requireCredential: true, secure: true, + + errors: ['ACCESS_DENIED'], } as const; export const paramDef = { @@ -25,11 +28,8 @@ export default define(meta, paramDef, async (ps, user) => { const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); - - if (!same) { - throw new Error('incorrect password'); + if (!(await comparePassword(ps.password, profile.password!))) { + throw new ApiError('ACCESS_DENIED'); } const newToken = generateUserToken(); diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts index 057ad5cf3..2a2413730 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -1,6 +1,6 @@ -import bcrypt from 'bcryptjs'; import { publishMainStream } from '@/services/stream.js'; import config from '@/config/index.js'; +import { comparePassword } from '@/misc/password.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { Users, UserProfiles } from '@/models/index.js'; import { sendEmail } from '@/services/send-email.js'; @@ -37,10 +37,9 @@ export const paramDef = { export default define(meta, paramDef, async (ps, user) => { const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); - - if (!same) throw new ApiError('ACCESS_DENIED'); + if (!(await comparePassword(ps.password, profile.password!))) { + throw new ApiError('ACCESS_DENIED'); + } if (ps.email != null) { const available = await validateEmailForAccount(ps.email); diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 57c349803..d73f2111a 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -2,7 +2,7 @@ import RE2 from 're2'; import * as mfm from 'mfm-js'; import { notificationTypes } from 'foundkey-js'; import { publishMainStream, publishUserEvent } from '@/services/stream.js'; -import acceptAllFollowRequests from '@/services/following/requests/accept-all.js'; +import { acceptAllFollowRequests } from '@/services/following/requests/accept-all.js'; import { publishToFollowers } from '@/services/i/update.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index cf22b0cc8..03602faac 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -233,6 +233,10 @@ export const meta = { }, }, }, + + v2: { + method: 'get' + }, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts index afd7f41cf..55ef39541 100644 --- a/packages/backend/src/server/api/endpoints/notes/children.ts +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -21,6 +21,11 @@ export const meta = { ref: 'Note', }, }, + + v2: { + method: 'get', + alias: 'notes/:noteId/children', + }, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/notes/clips.ts b/packages/backend/src/server/api/endpoints/notes/clips.ts index e20b744a1..3cd878800 100644 --- a/packages/backend/src/server/api/endpoints/notes/clips.ts +++ b/packages/backend/src/server/api/endpoints/notes/clips.ts @@ -19,6 +19,11 @@ export const meta = { }, }, + v2: { + method: 'get', + alias: 'notes/:noteId/clips', + }, + errors: ['NO_SUCH_NOTE'], } as const; diff --git a/packages/backend/src/server/api/endpoints/notes/conversation.ts b/packages/backend/src/server/api/endpoints/notes/conversation.ts index b4cbc55f0..7e736c736 100644 --- a/packages/backend/src/server/api/endpoints/notes/conversation.ts +++ b/packages/backend/src/server/api/endpoints/notes/conversation.ts @@ -19,6 +19,11 @@ export const meta = { }, }, + v2: { + method: 'get', + alias: 'notes/:noteId/conversation', + }, + errors: ['NO_SUCH_NOTE'], } as const; diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index a52f38df0..18eec6b89 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -37,6 +37,11 @@ export const meta = { }, }, + v2: { + method: 'post', + alias: 'notes', + }, + errors: ['NO_SUCH_NOTE', 'PURE_RENOTE', 'EXPIRED_POLL', 'NO_SUCH_CHANNEL', 'BLOCKED', 'LESS_RESTRICTIVE_VISIBILITY'], } as const; diff --git a/packages/backend/src/server/api/endpoints/notes/delete.ts b/packages/backend/src/server/api/endpoints/notes/delete.ts index f33d04782..3d8af2c7c 100644 --- a/packages/backend/src/server/api/endpoints/notes/delete.ts +++ b/packages/backend/src/server/api/endpoints/notes/delete.ts @@ -18,6 +18,11 @@ export const meta = { minInterval: SECOND, }, + v2: { + method: 'delete', + alias: 'notes/:noteId', + }, + errors: ['ACCESS_DENIED', 'NO_SUCH_NOTE'], } as const; diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index 902f9abd3..1382de6e5 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -18,6 +18,10 @@ export const meta = { ref: 'Note', }, }, + + v2: { + method: 'get', + } } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts index d7a63c056..03fe4bd42 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts @@ -23,6 +23,11 @@ export const meta = { }, }, + v2: { + method: 'get', + alias: 'notes/:noteId/reactions/:type?', + }, + errors: ['NO_SUCH_NOTE'], } as const; diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts index a0402cf2f..55674f9ee 100644 --- a/packages/backend/src/server/api/endpoints/notes/renotes.ts +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -22,6 +22,11 @@ export const meta = { }, }, + v2: { + method: 'get', + alias: 'notes/:noteId/renotes', + }, + errors: ['NO_SUCH_NOTE'], } as const; diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts index 26fb9ff87..34b679ae4 100644 --- a/packages/backend/src/server/api/endpoints/notes/replies.ts +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -22,6 +22,11 @@ export const meta = { }, }, + v2: { + method: 'get', + alias: 'notes/:noteId/replies', + }, + errors: ['NO_SUCH_NOTE'], } as const; diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts index b2dde8647..6c3f68407 100644 --- a/packages/backend/src/server/api/endpoints/notes/show.ts +++ b/packages/backend/src/server/api/endpoints/notes/show.ts @@ -14,6 +14,11 @@ export const meta = { ref: 'Note', }, + v2: { + method: 'get', + alias: 'notes/:noteId', + }, + errors: ['NO_SUCH_NOTE'], } as const; diff --git a/packages/backend/src/server/api/endpoints/notes/state.ts b/packages/backend/src/server/api/endpoints/notes/state.ts index 5a7d9dc40..c578e1419 100644 --- a/packages/backend/src/server/api/endpoints/notes/state.ts +++ b/packages/backend/src/server/api/endpoints/notes/state.ts @@ -27,6 +27,11 @@ export const meta = { }, }, + v2: { + method: 'get', + alias: 'notes/:noteId/status', + }, + errors: ['NO_SUCH_NOTE'], } as const; diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index 93ad1a182..0d7ce97c1 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -56,6 +56,11 @@ export const meta = { }, }, + v2: { + method: 'get', + alias: 'notes/:noteId/translate/:targetLang/:sourceLang?', + }, + errors: ['NO_SUCH_NOTE'], } as const; diff --git a/packages/backend/src/server/api/endpoints/notes/unrenote.ts b/packages/backend/src/server/api/endpoints/notes/unrenote.ts index b7bb3009a..20f3aa429 100644 --- a/packages/backend/src/server/api/endpoints/notes/unrenote.ts +++ b/packages/backend/src/server/api/endpoints/notes/unrenote.ts @@ -18,6 +18,11 @@ export const meta = { minInterval: SECOND, }, + v2: { + method: 'delete', + alias: 'notes/:noteId/renotes', + }, + errors: ['NO_SUCH_NOTE'], } as const; diff --git a/packages/backend/src/server/api/endpoints/reset-db.ts b/packages/backend/src/server/api/endpoints/reset-db.ts index 1d145c31d..ce26e28ee 100644 --- a/packages/backend/src/server/api/endpoints/reset-db.ts +++ b/packages/backend/src/server/api/endpoints/reset-db.ts @@ -1,4 +1,5 @@ import { resetDb } from '@/db/postgre.js'; +import { ApiError } from '@/server/api/error.js'; import define from '../define.js'; export const meta = { @@ -17,7 +18,7 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, user) => { - if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV is not a test'); + if (process.env.NODE_ENV !== 'test') throw new ApiError('ACCESS_DENIED'); await resetDb(); diff --git a/packages/backend/src/server/api/endpoints/reset-password.ts b/packages/backend/src/server/api/endpoints/reset-password.ts index 51b26a2b0..c46fcb6fd 100644 --- a/packages/backend/src/server/api/endpoints/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/reset-password.ts @@ -1,4 +1,4 @@ -import bcrypt from 'bcryptjs'; +import { hashPassword } from '@/misc/password.js'; import { UserProfiles, PasswordResetRequests } from '@/models/index.js'; import { DAY, MINUTE } from '@/const.js'; import define from '../define.js'; @@ -43,12 +43,8 @@ export default define(meta, paramDef, async (ps, user) => { throw new ApiError('NO_SUCH_RESET_REQUEST'); } - // Generate hash of password - const salt = await bcrypt.genSalt(8); - const hash = await bcrypt.hash(ps.password, salt); - await UserProfiles.update(req.userId, { - password: hash, + password: await hashPassword(ps.password), }); await PasswordResetRequests.delete(req.id); diff --git a/packages/backend/src/server/api/endpoints/users/stats.ts b/packages/backend/src/server/api/endpoints/users/stats.ts index 55d4f4314..f17ff8b31 100644 --- a/packages/backend/src/server/api/endpoints/users/stats.ts +++ b/packages/backend/src/server/api/endpoints/users/stats.ts @@ -177,7 +177,7 @@ export default define(meta, paramDef, async (ps, me) => { driveFilesCount: DriveFiles.createQueryBuilder('file') .where('file.userId = :userId', { userId: user.id }) .getCount(), - driveUsage: DriveFiles.calcDriveUsageOf(user), + driveUsage: DriveFiles.calcDriveUsageOf(user.id), }); result.followingCount = result.localFollowingCount + result.remoteFollowingCount; diff --git a/packages/backend/src/server/api/error.ts b/packages/backend/src/server/api/error.ts index 9323f8919..be9f4aef8 100644 --- a/packages/backend/src/server/api/error.ts +++ b/packages/backend/src/server/api/error.ts @@ -1,3 +1,5 @@ +import Koa from 'koa'; + export class ApiError extends Error { public message: string; public code: string; @@ -20,6 +22,24 @@ export class ApiError extends Error { this.message = message; this.httpStatusCode = httpStatusCode; } + + /** + * Makes the response of ctx the current error, given the respective endpoint name. + */ + public apply(ctx: Koa.Context, endpoint: string): void { + ctx.status = this.httpStatusCode; + if (ctx.status === 401) { + ctx.response.set('WWW-Authenticate', 'Bearer'); + } + ctx.body = { + error: { + message: this.message, + code: this.code, + info: this.info ?? undefined, + endpoint, + }, + }; + } } export const errors: Record = { @@ -167,6 +187,14 @@ export const errors: Record message: 'Invalid username.', httpStatusCode: 400, }, + IS_ADMIN: { + message: 'This action cannot be done to an administrator account.', + httpStatusCode: 400, + }, + IS_MODERATOR: { + message: 'This action cannot be done to a moderator account.', + httpStatusCode: 400, + }, LESS_RESTRICTIVE_VISIBILITY: { message: 'The visibility cannot be less restrictive than the parent note.', httpStatusCode: 400, diff --git a/packages/backend/src/server/api/index.ts b/packages/backend/src/server/api/index.ts index 2d2680e55..1aaee70a6 100644 --- a/packages/backend/src/server/api/index.ts +++ b/packages/backend/src/server/api/index.ts @@ -16,6 +16,7 @@ import signup from './private/signup.js'; import signin from './private/signin.js'; import signupPending from './private/signup-pending.js'; import { oauth } from './common/oauth.js'; +import { ApiError } from './error.js'; // Init app const app = new Koa(); @@ -40,6 +41,27 @@ const upload = multer({ files: 1, }, }); +/** + * Wrap multer to return an appropriate API error when something goes wrong, e.g. the file is too big. + */ +type KoaMiddleware = (ctx: Koa.Context, next: () => Promise) => Promise; +const wrapped = upload.single('file'); +function uploadWrapper(endpoint: string): KoaMiddleware { + return (ctx: Koa.Context, next: () => Promise): Promise => { + // pass a fake "next" so we can separate multer errors from other API errors + return wrapped(ctx, () => {}) + .then( + () => next(), + (err) => { + let apiErr = new ApiError('INTERNAL_ERROR', err); + if (err?.code === 'LIMIT_FILE_SIZE') { + apiErr = new ApiError('FILE_TOO_BIG', { maxFileSize: config.maxFileSize || 262144000 }); + } + apiErr.apply(ctx, endpoint); + } + ); + }; +} // Init router const router = new Router(); @@ -49,7 +71,7 @@ const router = new Router(); */ for (const endpoint of endpoints) { if (endpoint.meta.requireFile) { - router.post(`/${endpoint.name}`, upload.single('file'), handler.bind(null, endpoint)); + router.post(`/${endpoint.name}`, uploadWrapper(endpoint.name), handler.bind(null, endpoint)); } else { // 後方互換性のため if (endpoint.name.includes('-')) { @@ -69,6 +91,11 @@ for (const endpoint of endpoints) { } else { router.get(`/${endpoint.name}`, async ctx => { ctx.status = 405; }); } + + if (endpoint.meta.v2) { + const path = endpoint.meta.v2.alias ?? endpoint.name.replace(/-/g, '_'); + router[endpoint.meta.v2.method](`/v2/${path}`, handler.bind(null, endpoint)); + } } } diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts index fbb8d3163..3643887f7 100644 --- a/packages/backend/src/server/api/openapi/gen-spec.ts +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -207,6 +207,19 @@ export function genOpenapiSpec() { } spec.paths['/' + endpoint.name] = path; + + if (endpoint.meta.v2) { + // we need a clone of the API endpoint info because otherwise we change it by reference + const infoClone = structuredClone(info); + const route = `/v2/${endpoint.meta.v2.alias ?? endpoint.name.replace(/-/g, '_')}`; + + infoClone['operationId'] = infoClone['summary'] = route; + + spec.paths[route] = { + ...spec.paths[route], + [endpoint.meta.v2.method]: infoClone, + }; + } } return spec; diff --git a/packages/backend/src/server/api/private/signin.ts b/packages/backend/src/server/api/private/signin.ts index 9888a0c10..6ca957056 100644 --- a/packages/backend/src/server/api/private/signin.ts +++ b/packages/backend/src/server/api/private/signin.ts @@ -1,7 +1,6 @@ import { randomBytes } from 'node:crypto'; import { IsNull } from 'typeorm'; import Koa from 'koa'; -import bcrypt from 'bcryptjs'; import * as speakeasy from 'speakeasy'; import { SECOND, MINUTE, HOUR } from '@/const.js'; import config from '@/config/index.js'; @@ -9,10 +8,11 @@ import { Users, Signins, UserProfiles, UserSecurityKeys, AttestationChallenges } import { ILocalUser } from '@/models/entities/user.js'; import { genId } from '@/misc/gen-id.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; +import { comparePassword } from '@/misc/password.js'; import signin from '../common/signin.js'; import { verifyLogin, hash } from '../2fa.js'; import { limiter } from '../limiter.js'; -import { ApiError } from '../error.js'; +import { ApiError, errors } from '../error.js'; export default async (ctx: Koa.Context) => { ctx.set('Access-Control-Allow-Origin', config.url); @@ -21,42 +21,30 @@ export default async (ctx: Koa.Context) => { const body = ctx.request.body as any; const { username, password, token } = body; - // taken from @server/api/api-handler.ts - function error (e: ApiError): void { - ctx.status = e.httpStatusCode; - if (e.httpStatusCode === 401) { - ctx.response.set('WWW-Authenticate', 'Bearer'); - } - ctx.body = { - error: { - message: e!.message, - code: e!.code, - ...(e!.info ? { info: e!.info } : {}), - endpoint: 'signin', - }, - }; + function error(e: keyof errors, info?: Record): void { + new ApiError(e, info).apply(ctx, 'signin'); } try { // not more than 1 attempt per second and not more than 10 attempts per hour await limiter({ key: 'signin', duration: HOUR, max: 10, minInterval: SECOND }, getIpHash(ctx.ip)); } catch (err) { - error(new ApiError('RATE_LIMIT_EXCEEDED')); + error('RATE_LIMIT_EXCEEDED'); return; } if (typeof username !== 'string') { - error(new ApiError('INVALID_PARAM', { param: 'username', reason: 'not a string' })); + error('INVALID_PARAM', { param: 'username', reason: 'not a string' }); return; } if (typeof password !== 'string') { - error(new ApiError('INVALID_PARAM', { param: 'password', reason: 'not a string' })); + error('INVALID_PARAM', { param: 'password', reason: 'not a string' }); return; } if (token != null && typeof token !== 'string') { - error(new ApiError('INVALID_PARAM', { param: 'token', reason: 'provided but not a string' })); + error('INVALID_PARAM', { param: 'token', reason: 'provided but not a string' }); return; } @@ -67,19 +55,19 @@ export default async (ctx: Koa.Context) => { }) as ILocalUser; if (user == null) { - error(new ApiError('NO_SUCH_USER')); + error('NO_SUCH_USER'); return; } if (user.isSuspended) { - error(new ApiError('SUSPENDED')); + error('SUSPENDED'); return; } const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); // Compare password - const same = await bcrypt.compare(password, profile.password!); + const same = await comparePassword(password, profile.password!); async function fail(): void { // Append signin history @@ -92,7 +80,7 @@ export default async (ctx: Koa.Context) => { success: false, }); - error(new ApiError('ACCESS_DENIED')); + error('ACCESS_DENIED'); } if (!profile.twoFactorEnabled) { diff --git a/packages/backend/src/server/api/private/signup.ts b/packages/backend/src/server/api/private/signup.ts index e20fb9abb..fafa96362 100644 --- a/packages/backend/src/server/api/private/signup.ts +++ b/packages/backend/src/server/api/private/signup.ts @@ -1,7 +1,7 @@ import Koa from 'koa'; -import bcrypt from 'bcryptjs'; import { fetchMeta } from '@/misc/fetch-meta.js'; import { verifyHcaptcha, verifyRecaptcha } from '@/misc/captcha.js'; +import { hashPassword } from '@/misc/password.js'; import { Users, RegistrationTickets, UserPendings } from '@/models/index.js'; import config from '@/config/index.js'; import { sendEmail } from '@/services/send-email.js'; @@ -71,17 +71,13 @@ export default async (ctx: Koa.Context) => { if (instance.emailRequiredForSignup) { const code = secureRndstr(16); - // Generate hash of password - const salt = await bcrypt.genSalt(8); - const hash = await bcrypt.hash(password, salt); - await UserPendings.insert({ id: genId(), createdAt: new Date(), code, email: emailAddress, username, - password: hash, + password: await hashPassword(password), }); const link = `${config.url}/signup-complete/${code}`; diff --git a/packages/backend/src/services/chart/core.ts b/packages/backend/src/services/chart/core.ts index 82b964373..025fd87ab 100644 --- a/packages/backend/src/services/chart/core.ts +++ b/packages/backend/src/services/chart/core.ts @@ -7,6 +7,7 @@ import * as nestedProperty from 'nested-property'; import { EntitySchema, Repository, LessThan, Between } from 'typeorm'; import { dateUTC, isTimeSame, isTimeBefore, subtractTime, addTime } from '@/prelude/time.js'; +import { unique } from '@/prelude/array.js'; import { getChartInsertLock } from '@/misc/app-lock.js'; import { db } from '@/db/postgre.js'; import Logger from '../logger.js'; @@ -56,8 +57,6 @@ const camelToSnake = (str: string): string => { return str.replace(/([A-Z])/g, s => '_' + s.charAt(0).toLowerCase()); }; -const removeDuplicates = (array: any[]) => Array.from(new Set(array)); - type Commit = { [K in keyof S]?: S[K]['uniqueIncrement'] extends true ? string[] : number; }; @@ -483,7 +482,7 @@ export default abstract class Chart { this.buffer = this.buffer.filter(q => q.group != null && (q.group !== logHour.group)); }; - const groups = removeDuplicates(this.buffer.map(log => log.group)); + const groups = unique(this.buffer.map(log => log.group)); await Promise.all( groups.map(group => @@ -651,12 +650,7 @@ export default abstract class Chart { const res = {} as ChartResult; - /** - * Turn - * [{ foo: 1, bar: 5 }, { foo: 2, bar: 6 }, { foo: 3, bar: 7 }] - * into - * { foo: [1, 2, 3], bar: [5, 6, 7] } - */ + // Turn array of objects into object of arrays. for (const record of chart) { for (const [k, v] of Object.entries(record) as ([keyof typeof record, number])[]) { if (res[k]) { diff --git a/packages/backend/src/services/create-system-user.ts b/packages/backend/src/services/create-system-user.ts index 2285f1586..f965e82d7 100644 --- a/packages/backend/src/services/create-system-user.ts +++ b/packages/backend/src/services/create-system-user.ts @@ -1,7 +1,7 @@ -import bcrypt from 'bcryptjs'; import { v4 as uuid } from 'uuid'; import { IsNull } from 'typeorm'; import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; +import { hashPassword } from '@/misc/password.js'; import { User } from '@/models/entities/user.js'; import { UserProfile } from '@/models/entities/user-profile.js'; import { genId } from '@/misc/gen-id.js'; @@ -11,11 +11,7 @@ import { db } from '@/db/postgre.js'; import generateNativeUserToken from '@/server/api/common/generate-native-user-token.js'; export async function createSystemUser(username: string): Promise { - const password = uuid(); - - // Generate hash of password - const salt = await bcrypt.genSalt(8); - const hash = await bcrypt.hash(password, salt); + const password = await hashPassword(uuid()); // Generate secret const secret = generateNativeUserToken(); @@ -55,7 +51,7 @@ export async function createSystemUser(username: string): Promise { await transactionalEntityManager.insert(UserProfile, { userId: account.id, autoAcceptFollowed: false, - password: hash, + password, }); await transactionalEntityManager.insert(UsedUsername, { diff --git a/packages/backend/src/services/drive/add-file.ts b/packages/backend/src/services/drive/add-file.ts index 20ba24609..18bff576a 100644 --- a/packages/backend/src/services/drive/add-file.ts +++ b/packages/backend/src/services/drive/add-file.ts @@ -372,7 +372,7 @@ export async function addFile({ //#region Check drive usage if (user && !isLink) { - const usage = await DriveFiles.calcDriveUsageOf(user); + const usage = await DriveFiles.calcDriveUsageOf(user.id); const instance = await fetchMeta(); const driveCapacity = 1024 * 1024 * (Users.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb); diff --git a/packages/backend/src/services/following/create.ts b/packages/backend/src/services/following/create.ts index b866c8383..a4f546266 100644 --- a/packages/backend/src/services/following/create.ts +++ b/packages/backend/src/services/following/create.ts @@ -15,7 +15,7 @@ import { getActiveWebhooks } from '@/misc/webhook-cache.js'; import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js'; import Logger from '../logger.js'; import { createNotification } from '../create-notification.js'; -import createFollowRequest from './requests/create.js'; +import { createFollowRequest } from './requests/create.js'; const logger = new Logger('following/create'); diff --git a/packages/backend/src/services/following/reject.ts b/packages/backend/src/services/following/reject.ts index 21e472af1..ec98da635 100644 --- a/packages/backend/src/services/following/reject.ts +++ b/packages/backend/src/services/following/reject.ts @@ -24,7 +24,7 @@ type Both = Local | Remote; /** * API following/request/reject */ -export async function rejectFollowRequest(user: Local, follower: Both) { +export async function rejectFollowRequest(user: Local, follower: Both): Promise { if (Users.isRemoteUser(follower)) { deliverReject(user, follower); } @@ -39,7 +39,7 @@ export async function rejectFollowRequest(user: Local, follower: Both) { /** * API following/reject */ -export async function rejectFollow(user: Local, follower: Both) { +export async function rejectFollow(user: Local, follower: Both): Promise { if (Users.isRemoteUser(follower)) { deliverReject(user, follower); } @@ -54,7 +54,7 @@ export async function rejectFollow(user: Local, follower: Both) { /** * AP Reject/Follow */ -export async function remoteReject(actor: Remote, follower: Local) { +export async function remoteReject(actor: Remote, follower: Local): Promise { await removeFollowRequest(actor, follower); await removeFollow(actor, follower); publishUnfollow(actor, follower); @@ -63,7 +63,7 @@ export async function remoteReject(actor: Remote, follower: Local) { /** * Remove follow request record */ -async function removeFollowRequest(followee: Both, follower: Both) { +async function removeFollowRequest(followee: Both, follower: Both): Promise { const request = await FollowRequests.findOneBy({ followeeId: followee.id, followerId: follower.id, @@ -77,7 +77,7 @@ async function removeFollowRequest(followee: Both, follower: Both) { /** * Remove follow record */ -async function removeFollow(followee: Both, follower: Both) { +async function removeFollow(followee: Both, follower: Both): Promise { const following = await Followings.findOneBy({ followeeId: followee.id, followerId: follower.id, @@ -92,7 +92,7 @@ async function removeFollow(followee: Both, follower: Both) { /** * Deliver Reject to remote */ -async function deliverReject(followee: Local, follower: Remote) { +async function deliverReject(followee: Local, follower: Remote): Promise { const request = await FollowRequests.findOneBy({ followeeId: followee.id, followerId: follower.id, @@ -105,7 +105,7 @@ async function deliverReject(followee: Local, follower: Remote) { /** * Publish unfollow to local */ -async function publishUnfollow(followee: Both, follower: Local) { +async function publishUnfollow(followee: Both, follower: Local): Promise { const packedFollowee = await Users.pack(followee.id, follower, { detail: true, }); diff --git a/packages/backend/src/services/following/requests/accept-all.ts b/packages/backend/src/services/following/requests/accept-all.ts index 2a1344d65..ccc608c28 100644 --- a/packages/backend/src/services/following/requests/accept-all.ts +++ b/packages/backend/src/services/following/requests/accept-all.ts @@ -1,18 +1,18 @@ import { User } from '@/models/entities/user.js'; import { FollowRequests, Users } from '@/models/index.js'; -import accept from './accept.js'; +import { acceptFollowRequest } from './accept.js'; /** - * 指定したユーザー宛てのフォローリクエストをすべて承認 - * @param user ユーザー + * Approve all follow requests addressed to the specified user. + * @param user The user whom to accept all follow requests to */ -export default async function(user: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }) { +export async function acceptAllFollowRequests(user: User): Promise { const requests = await FollowRequests.findBy({ followeeId: user.id, }); for (const request of requests) { const follower = await Users.findOneByOrFail({ id: request.followerId }); - accept(user, follower); + acceptFollowRequest(user, follower); } } diff --git a/packages/backend/src/services/following/requests/accept.ts b/packages/backend/src/services/following/requests/accept.ts index 66abb0646..231075100 100644 --- a/packages/backend/src/services/following/requests/accept.ts +++ b/packages/backend/src/services/following/requests/accept.ts @@ -3,12 +3,17 @@ import renderFollow from '@/remote/activitypub/renderer/follow.js'; import renderAccept from '@/remote/activitypub/renderer/accept.js'; import { deliver } from '@/queue/index.js'; import { publishMainStream } from '@/services/stream.js'; -import { User, CacheableUser } from '@/models/entities/user.js'; +import { User } from '@/models/entities/user.js'; import { FollowRequests, Users } from '@/models/index.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { insertFollowingDoc } from '../create.js'; -export default async function(followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, follower: CacheableUser) { +/** + * Accept a follow request from user `followee` to follow `follower`. + * @param followee User who is being followed + * @param follower User making the follow request + */ +export async function acceptFollowRequest(followee: User, follower: User): Promise { const request = await FollowRequests.findOneBy({ followeeId: followee.id, followerId: follower.id, diff --git a/packages/backend/src/services/following/requests/cancel.ts b/packages/backend/src/services/following/requests/cancel.ts index fc13381db..999ebc64e 100644 --- a/packages/backend/src/services/following/requests/cancel.ts +++ b/packages/backend/src/services/following/requests/cancel.ts @@ -7,7 +7,12 @@ import { IdentifiableError } from '@/misc/identifiable-error.js'; import { User } from '@/models/entities/user.js'; import { Users, FollowRequests } from '@/models/index.js'; -export default async function(followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox'] }, follower: { id: User['id']; host: User['host']; uri: User['host'] }) { +/** + * Cancel a follow request from `follower` to `followee`. + * @param followee User that was going to be followed + * @param follower User who is making the follow request + */ +export async function cancelFollowRequest(followee: User, follower: User): Promise { if (Users.isRemoteUser(followee)) { const content = renderActivity(renderUndo(renderFollow(follower, followee), follower)); diff --git a/packages/backend/src/services/following/requests/create.ts b/packages/backend/src/services/following/requests/create.ts index e8971ba57..81fec5fe4 100644 --- a/packages/backend/src/services/following/requests/create.ts +++ b/packages/backend/src/services/following/requests/create.ts @@ -7,7 +7,13 @@ import { Blockings, FollowRequests, Users } from '@/models/index.js'; import { genId } from '@/misc/gen-id.js'; import { createNotification } from '@/services/create-notification.js'; -export default async function(follower: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, requestId?: string) { +/** + * Make a follow request from `follower` to `followee`. + * @param follower User making the follow request + * @param followee User to make the follow request to + * @param requestId Follow request ID + */ +export async function createFollowRequest(follower: User, followee: User, requestId?: string): Promise { if (follower.id === followee.id) return; // check blocking diff --git a/packages/client/src/components/token-generate-window.vue b/packages/client/src/components/token-generate-window.vue index 4a98af7f3..55487b67d 100644 --- a/packages/client/src/components/token-generate-window.vue +++ b/packages/client/src/components/token-generate-window.vue @@ -23,7 +23,7 @@
{{ i18n.ts.permission }}
{{ i18n.ts.disableAll }} {{ i18n.ts.enableAll }} - {{ i18n.t(`_permissions.${kind}`) }} + {{ i18n.t(`_permissions.${kind}`) }} @@ -57,16 +57,15 @@ const emit = defineEmits<{ let dialog: InstanceType | null = $ref(null); let name = $ref(props.initialName); let perms: Record = $ref({}); -const kinds = $ref(permissions); +let kinds = props.initialPermissions.length > 0 + ? props.initialPermissions + : permissions; -if (props.initialPermissions.length > 0) { - for (const kind of props.initialPermissions) { - perms[kind] = true; - } -} else { - for (const kind of kinds) { - perms[kind] = false; - } +// If there is a particular set of permissions given, enable all of them. +// Otherwise, by default disable all permissions. +const enable = props.initialPermissions.length > 0; +for (const kind of kinds) { + perms[kind] = enable; } function ok(): void { diff --git a/packages/client/src/directives/tooltip.ts b/packages/client/src/directives/tooltip.ts index d777f1bd4..44511fb3e 100644 --- a/packages/client/src/directives/tooltip.ts +++ b/packages/client/src/directives/tooltip.ts @@ -1,31 +1,83 @@ -// TODO: useTooltip関数使うようにしたい -// ただディレクティブ内でonUnmountedなどのcomposition api使えるのか不明 +// TODO: use the useTooltip function import { defineAsyncComponent, Directive, ref } from 'vue'; import { isTouchUsing } from '@/scripts/touch'; import { popup, alert } from '@/os'; -const start = isTouchUsing ? 'touchstart' : 'mouseover'; -const end = isTouchUsing ? 'touchend' : 'mouseleave'; const delay = 100; +class TooltipDirective { + public text: string | null; + private asMfm: boolean; + + private _close: null | () => void; + private showTimer: null | ReturnType; + private hideTimer: null | ReturnType; + + + constructor(binding) { + this.text = binding.value; + this.asMfm = binding.modifiers.mfm ?? false; + this._close = null; + this.showTimer = null; + this.hideTimer = null; + } + + private close(): void { + if (this.hideTimer != null) return; // already closed or closing + + // cancel any pending attempts to show + window.clearTimeout(this.showTimer); + this.showTimer = null; + + this.hideTimer = window.setTimeout(() => { + this._close?.(); + this._close = null; + }, delay); + } + + public show(el): void { + if (!document.body.contains(el)) return; + if (this.text == null) return; // no content + if (this.showTimer != null) return; // already showing or going to show + + // cancel any pending attempts to hide + window.clearTimeout(this.hideTimer); + this.hideTimer = null; + + this.showTimer = window.setTimeout(() => { + const showing = ref(true); + popup(defineAsyncComponent(() => import('@/components/ui/tooltip.vue')), { + showing, + text: this.text, + asMfm: this.asMfm, + targetElement: el, + }, {}, 'closed'); + + this._close = () => { + showing.value = false; + }; + }, delay); + } +} + +/** + * Show a tooltip on mouseover. The content of the tooltip is the text + * provided as the value of this directive. + * + * Supported arguments: + * v-tooltip:dialog -> show text as a dialog on mousedown + * + * Supported modifiers: + * v-tooltip.mfm -> show tooltip content as MFM + */ export default { + created(el: HTMLElement, binding) { + (el as any)._tooltipDirective_ = new TooltipDirective(binding); + }, + mounted(el: HTMLElement, binding) { - const self = (el as any)._tooltipDirective_ = {} as any; - - self.text = binding.value as string; - self._close = null; - self.showTimer = null; - self.hideTimer = null; - self.checkTimer = null; - - self.close = () => { - if (self._close) { - window.clearInterval(self.checkTimer); - self._close(); - self._close = null; - } - }; + const self = el._tooltipDirective_ as TooltipDirective; if (binding.arg === 'dialog') { el.addEventListener('click', (ev) => { @@ -39,53 +91,20 @@ export default { }); } - self.show = () => { - if (!document.body.contains(el)) return; - if (self._close) return; - if (self.text == null) return; - - const showing = ref(true); - popup(defineAsyncComponent(() => import('@/components/ui/tooltip.vue')), { - showing, - text: self.text, - asMfm: binding.modifiers.mfm, - targetElement: el, - }, {}, 'closed'); - - self._close = () => { - showing.value = false; - }; - }; - - el.addEventListener('selectstart', ev => { - ev.preventDefault(); - }); - - el.addEventListener(start, () => { - window.clearTimeout(self.showTimer); - window.clearTimeout(self.hideTimer); - self.showTimer = window.setTimeout(self.show, delay); - }, { passive: true }); - - el.addEventListener(end, () => { - window.clearTimeout(self.showTimer); - window.clearTimeout(self.hideTimer); - self.hideTimer = window.setTimeout(self.close, delay); - }, { passive: true }); - - el.addEventListener('click', () => { - window.clearTimeout(self.showTimer); - self.close(); - }); + // add event listeners + const start = isTouchUsing ? 'touchstart' : 'mouseover'; + const end = isTouchUsing ? 'touchend' : 'mouseleave'; + el.addEventListener(start, () => self.show(el), { passive: true }); + el.addEventListener(end, () => self.close(), { passive: true }); + el.addEventListener('click', self.close()); + el.addEventListener('selectstart', ev => ev.preventDefault()); }, - updated(el, binding) { - const self = el._tooltipDirective_; - self.text = binding.value as string; + beforeUpdate(el, binding) { + (el._tooltipDirective_ as TooltipDirective).text = binding.value as string; }, - unmounted(el) { - const self = el._tooltipDirective_; - window.clearInterval(self.checkTimer); + beforeUnmount(el) { + (el._tooltipDirective_ as TooltipDirective).close(); }, } as Directive; diff --git a/packages/client/src/pages/settings/apps.vue b/packages/client/src/pages/settings/apps.vue index fb89604e9..4270315ee 100644 --- a/packages/client/src/pages/settings/apps.vue +++ b/packages/client/src/pages/settings/apps.vue @@ -11,19 +11,25 @@
-
{{ token.name }}
-
{{ token.description }}
-
-
{{ i18n.ts.installedDate }}:
-
-
-
-
{{ i18n.ts.lastUsedDate }}:
-
-
-
- -
+ + + + + + + + + + + + + + + + + + +
{{ i18n.ts.name }}:{{ token.name }}
{{ i18n.ts.description }}:{{ token.description }}
{{ i18n.ts.installedDate }}:
{{ i18n.ts.lastUsedDate }}:
{{ i18n.ts.details }}
    @@ -82,11 +88,19 @@ definePageMetadata({ } > .body { - width: calc(100% - 62px); + width: 100%; position: relative; - > .name { - font-weight: bold; + button { + position: absolute; + top: 0; + right: 0; + } + th { + text-align: right; + } + td { + text-align: left; } } } diff --git a/packages/client/src/style.scss b/packages/client/src/style.scss index b728ed3b8..cc90e650b 100644 --- a/packages/client/src/style.scss +++ b/packages/client/src/style.scss @@ -390,14 +390,6 @@ hr { } } -._keyValue { - display: flex; - - > * { - flex: 1; - } -} - ._link { color: var(--link); }