Merge branch 'main' into mk.absturztau.be

This commit is contained in:
Puniko 2022-12-26 09:09:34 +01:00
commit 55d8d5a626
82 changed files with 636 additions and 429 deletions

View file

@ -1098,6 +1098,7 @@ _permissions:
"write:notes": "Create and delete notes" "write:notes": "Create and delete notes"
"read:notifications": "Read notifications" "read:notifications": "Read notifications"
"write:notifications": "Mark notifications as read and create custom notifications" "write:notifications": "Mark notifications as read and create custom notifications"
"read:reactions": "View reactions"
"write:reactions": "Create and delete reactions" "write:reactions": "Create and delete reactions"
"write:votes": "Vote in polls" "write:votes": "Vote in polls"
"read:pages": "List and read pages" "read:pages": "List and read pages"

View file

@ -35,7 +35,7 @@ export async function getHtml(url: string, accept = 'text/html, */*', timeout =
return await res.text(); return await res.text();
} }
export async function getResponse(args: { url: string, method: string, body?: string, headers: Record<string, string>, timeout?: number, size?: number }) { export async function getResponse(args: { url: string, method: string, body?: string, headers: Record<string, string>, timeout?: number, size?: number, redirect: 'follow' | 'manual' | 'error' = 'follow' }) {
const timeout = args.timeout || 10 * SECOND; const timeout = args.timeout || 10 * SECOND;
const controller = new AbortController(); const controller = new AbortController();
@ -47,8 +47,9 @@ export async function getResponse(args: { url: string, method: string, body?: st
method: args.method, method: args.method,
headers: args.headers, headers: args.headers,
body: args.body, body: args.body,
redirect: args.redirect,
timeout, timeout,
size: args.size || 10 * 1024 * 1024, size: args.size || 10 * 1024 * 1024, // 10 MiB
agent: getAgentByUrl, agent: getAgentByUrl,
signal: controller.signal, signal: controller.signal,
}); });

View file

@ -0,0 +1,10 @@
import bcrypt from 'bcryptjs';
export async function hashPassword(password: string): Promise<string> {
const salt = await bcrypt.genSalt(8);
return await bcrypt.hash(password, salt);
}
export async function comparePassword(password: string, hash: string): Promise<boolean> {
return await bcrypt.compare(password, hash);
}

View file

@ -6,11 +6,10 @@ import { Meta } from '@/models/entities/meta.js';
* Returns whether a specific host (punycoded) should be blocked. * Returns whether a specific host (punycoded) should be blocked.
* *
* @param host punycoded instance host * @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 * @returns whether the given host should be blocked
*/ */
export async function shouldBlockInstance(host: Instance['host'], meta?: Meta): Promise<boolean> {
export async function shouldBlockInstance(host: Instance['host'], meta: Promise<Meta> = fetchMeta()): Promise<boolean> { const { blockedHosts } = meta ?? await fetchMeta();
const { blockedHosts } = await meta;
return blockedHosts.some(blockedHost => host === blockedHost || host.endsWith('.' + blockedHost)); return blockedHosts.some(blockedHost => host === blockedHost || host.endsWith('.' + blockedHost));
} }

View file

@ -15,8 +15,8 @@ const deadThreshold = 7 * DAY;
* @returns array of punycoded instance hosts that should be skipped (subset of hosts parameter) * @returns array of punycoded instance hosts that should be skipped (subset of hosts parameter)
*/ */
export async function skippedInstances(hosts: Array<Instance['host']>): Promise<Array<Instance['host']>> { export async function skippedInstances(hosts: Array<Instance['host']>): Promise<Array<Instance['host']>> {
// Resolve the boolean promises before filtering // first check for blocked instances since that info may already be in memory
const meta = fetchMeta(); const meta = await fetchMeta();
const shouldSkip = await Promise.all(hosts.map(host => shouldBlockInstance(host, meta))); const shouldSkip = await Promise.all(hosts.map(host => shouldBlockInstance(host, meta)));
const skipped = hosts.filter((_, i) => shouldSkip[i]); const skipped = hosts.filter((_, i) => shouldSkip[i]);

View file

@ -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); return thumbnail ? (file.thumbnailUrl || (isImage ? (file.webpublicUrl || file.url) : null)) : (file.webpublicUrl || file.url);
}, },
async calcDriveUsageOf(user: User['id'] | { id: User['id'] }): Promise<number> { calcDriveUsageOf(id: User['id']): Promise<number> {
const id = typeof user === 'object' ? user.id : user; 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);
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;
}, },
async calcDriveUsageOfHost(host: string): Promise<number> { calcDriveUsageOfHost(host: string): Promise<number> {
const { sum } = await this return db.query('SELECT SUM(size) AS sum FROM drive_file WHERE "userHost" = $1 AND NOT "isLink"', [toPuny(host)])
.createQueryBuilder('file') .then(res => res[0].sum as number ?? 0);
.where('file.userHost = :host', { host: toPuny(host) })
.andWhere('file.isLink = FALSE')
.select('SUM(file.size)', 'sum')
.getRawOne();
return parseInt(sum, 10) || 0;
}, },
async calcDriveUsageOfLocal(): Promise<number> { calcDriveUsageOfLocal(): Promise<number> {
const { sum } = await this return db.query('SELECT SUM(size) AS sum FROM drive_file WHERE "userHost" IS NULL AND NOT "isLink"')
.createQueryBuilder('file') .then(res => res[0].sum as number ?? 0);
.where('file.userHost IS NULL')
.andWhere('file.isLink = FALSE')
.select('SUM(file.size)', 'sum')
.getRawOne();
return parseInt(sum, 10) || 0;
}, },
async calcDriveUsageOfRemote(): Promise<number> { calcDriveUsageOfRemote(): Promise<number> {
const { sum } = await this return db.query('SELECT SUM(size) AS sum FROM drive_file WHERE "userHost" IS NOT NULL AND NOT "isLink"')
.createQueryBuilder('file') .then(res => res[0].sum as number ?? 0);
.where('file.userHost IS NOT NULL')
.andWhere('file.isLink = FALSE')
.select('SUM(file.size)', 'sum')
.getRawOne();
return parseInt(sum, 10) || 0;
}, },
async pack( async pack(
@ -152,26 +126,7 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({
const file = typeof src === 'object' ? src : await this.findOneBy({ id: src }); const file = typeof src === 'object' ? src : await this.findOneBy({ id: src });
if (file == null) return null; if (file == null) return null;
return await awaitAll<Packed<'DriveFile'>>({ return await this.pack(file);
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,
});
}, },
async packMany( async packMany(

View file

@ -1,8 +1,8 @@
import Bull from 'bull'; import Bull from 'bull';
import { In, LessThan } from 'typeorm'; 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 { publishUserEvent } from '@/services/stream.js';
import { MINUTE, DAY } from '@/const.js'; import { MINUTE, DAY, MONTH } from '@/const.js';
import { queueLogger } from '@/queue/logger.js'; import { queueLogger } from '@/queue/logger.js';
const logger = queueLogger.createSubLogger('check-expired'); const logger = queueLogger.createSubLogger('check-expired');
@ -26,22 +26,30 @@ export async function checkExpired(job: Bull.Job<Record<string, unknown>>, done:
} }
} }
const OlderThan = (millis: number) => {
return LessThan(new Date(new Date().getTime() - millis));
};
await Signins.delete({ await Signins.delete({
// 60 days, or roughly equal to two months createdAt: OlderThan(2 * MONTH),
createdAt: LessThan(new Date(new Date().getTime() - 60 * DAY)),
}); });
await AttestationChallenges.delete({ await AttestationChallenges.delete({
createdAt: LessThan(new Date(new Date().getTime() - 5 * MINUTE)), createdAt: OlderThan(5 * MINUTE),
}); });
await PasswordResetRequests.delete({ await PasswordResetRequests.delete({
// this timing should be the same as in @/server/api/endpoints/reset-password.ts // 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({ 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.'); logger.succ('Deleted expired data.');

View file

@ -1,5 +1,5 @@
import { CacheableRemoteUser } from '@/models/entities/user.js'; 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 { relayAccepted } from '@/services/relay.js';
import { IFollow } from '@/remote/activitypub/type.js'; import { IFollow } from '@/remote/activitypub/type.js';
import { DbResolver } from '@/remote/activitypub/db-resolver.js'; import { DbResolver } from '@/remote/activitypub/db-resolver.js';
@ -24,6 +24,6 @@ export default async (actor: CacheableRemoteUser, activity: IFollow): Promise<st
return await relayAccepted(match[1]); return await relayAccepted(match[1]);
} }
await accept(actor, follower); await acceptFollowRequest(actor, follower);
return 'ok'; return 'ok';
}; };

View file

@ -1,8 +1,10 @@
import { CacheableRemoteUser } from '@/models/entities/user.js'; import { CacheableRemoteUser } from '@/models/entities/user.js';
import { toArray } from '@/prelude/array.js'; import { toArray } from '@/prelude/array.js';
import { Resolver } from '@/remote/activitypub/resolver.js'; import { Resolver } from '@/remote/activitypub/resolver.js';
import { extractDbHost } from '@/misc/convert-host.js';
import { shouldBlockInstance } from '@/misc/should-block-instance.js';
import { apLogger } from '../logger.js'; import { apLogger } from '../logger.js';
import { IObject, isCreate, isDelete, isUpdate, isRead, isFollow, isAccept, isReject, isAdd, isRemove, isAnnounce, isLike, isUndo, isBlock, isCollectionOrOrderedCollection, isCollection, isFlag } from '../type.js'; import { IObject, isCreate, isDelete, isUpdate, isRead, isFollow, isAccept, isReject, isAdd, isRemove, isAnnounce, isLike, isUndo, isBlock, isCollectionOrOrderedCollection, isCollection, isFlag, getApId } from '../type.js';
import create from './create/index.js'; import create from './create/index.js';
import performDeleteActivity from './delete/index.js'; import performDeleteActivity from './delete/index.js';
import performUpdateActivity from './update/index.js'; import performUpdateActivity from './update/index.js';
@ -18,7 +20,7 @@ import remove from './remove/index.js';
import block from './block/index.js'; import block from './block/index.js';
import flag from './flag/index.js'; import flag from './flag/index.js';
export async function performActivity(actor: CacheableRemoteUser, activity: IObject, resolver: Resolver) { export async function performActivity(actor: CacheableRemoteUser, activity: IObject, resolver: Resolver): Promise<void> {
if (isCollectionOrOrderedCollection(activity)) { if (isCollectionOrOrderedCollection(activity)) {
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) { for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) {
const act = await resolver.resolve(item); 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<void> { async function performOneActivity(actor: CacheableRemoteUser, activity: IObject, resolver: Resolver): Promise<void> {
if (actor.isSuspended) return; if (actor.isSuspended) return;
if (typeof activity.id !== 'undefined') {
const host = extractDbHost(getApId(activity));
if (await shouldBlockInstance(host)) return;
}
if (isCreate(activity)) { if (isCreate(activity)) {
await create(actor, activity, resolver); await create(actor, activity, resolver);
} else if (isDelete(activity)) { } else if (isDelete(activity)) {
@ -55,7 +62,7 @@ async function performOneActivity(actor: CacheableRemoteUser, activity: IObject,
} else if (isAdd(activity)) { } else if (isAdd(activity)) {
await add(actor, activity, resolver).catch(err => apLogger.error(err)); await add(actor, activity, resolver).catch(err => apLogger.error(err));
} else if (isRemove(activity)) { } 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)) { } else if (isAnnounce(activity)) {
await announce(actor, activity, resolver); await announce(actor, activity, resolver);
} else if (isLike(activity)) { } else if (isLike(activity)) {

View file

@ -1,5 +1,5 @@
import unfollow from '@/services/following/delete.js'; 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 { CacheableRemoteUser } from '@/models/entities/user.js';
import { FollowRequests, Followings } from '@/models/index.js'; import { FollowRequests, Followings } from '@/models/index.js';
import { IFollow } from '@/remote/activitypub/type.js'; import { IFollow } from '@/remote/activitypub/type.js';
@ -29,7 +29,7 @@ export default async (actor: CacheableRemoteUser, activity: IFollow): Promise<st
]); ]);
if (req) { if (req) {
await cancelRequest(followee, actor); await cancelFollowRequest(followee, actor);
return 'ok: follow request canceled'; return 'ok: follow request canceled';
} else if (following) { } else if (following) {
await unfollow(actor, followee); await unfollow(actor, followee);

View file

@ -1,3 +1,4 @@
import { URL } from 'node:url';
import config from '@/config/index.js'; import config from '@/config/index.js';
import { getUserKeypair } from '@/misc/keypair-store.js'; import { getUserKeypair } from '@/misc/keypair-store.js';
import { User } from '@/models/entities/user.js'; import { User } from '@/models/entities/user.js';
@ -26,6 +27,8 @@ export async function request(user: { id: User['id'] }, url: string, object: any
method: req.request.method, method: req.request.method,
headers: req.request.headers, headers: req.request.headers,
body, body,
// don't allow redirects on the inbox
redirect: 'error',
}); });
}; };
@ -37,22 +40,33 @@ export async function request(user: { id: User['id'] }, url: string, object: any
export async function signedGet(url: string, user: { id: User['id'] }): Promise<any> { export async function signedGet(url: string, user: { id: User['id'] }): Promise<any> {
const keypair = await getUserKeypair(user.id); const keypair = await getUserKeypair(user.id);
const req = createSignedGet({ for (let redirects = 0; redirects < 3; redirects++) {
key: { const req = createSignedGet({
privateKeyPem: keypair.privateKey, key: {
keyId: `${config.url}/users/${user.id}#main-key`, privateKeyPem: keypair.privateKey,
}, keyId: `${config.url}/users/${user.id}#main-key`,
url, },
additionalHeaders: { url,
'User-Agent': config.userAgent, additionalHeaders: {
}, 'User-Agent': config.userAgent,
}); },
});
const res = await getResponse({ const res = await getResponse({
url, url,
method: req.request.method, method: req.request.method,
headers: req.request.headers, 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');
} }

View file

@ -34,9 +34,7 @@ export class Resolver {
} }
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> { public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
const collection = typeof value === 'string' const collection = await this.resolve(value);
? await this.resolve(value)
: value;
if (isCollectionOrOrderedCollection(collection)) { if (isCollectionOrOrderedCollection(collection)) {
return collection; return collection;
@ -45,12 +43,18 @@ export class Resolver {
} }
} }
public async resolve(value: string | IObject): Promise<IObject> { public async resolve(value?: string | IObject | null, allowRedirect = false): Promise<IObject> {
if (value == null) { if (value == null) {
throw new Error('resolvee is null (or undefined)'); throw new Error('resolvee is null (or undefined)');
} }
if (typeof value !== 'string') { 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; return value;
} }
@ -75,7 +79,7 @@ export class Resolver {
} }
if (await shouldBlockInstance(host)) { if (await shouldBlockInstance(host)) {
throw new Error('Instance is blocked'); throw new Error('instance is blocked');
} }
if (!this.user) { if (!this.user) {
@ -94,7 +98,7 @@ export class Resolver {
) )
// Did we actually get the object that corresponds to the canonical URL? // 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? // 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'); throw new Error('invalid response');
} }

View file

@ -5,28 +5,24 @@ import authenticate, { AuthenticationError } from './authenticate.js';
import call from './call.js'; import call from './call.js';
import { ApiError } from './error.js'; import { ApiError } from './error.js';
export async function handler(endpoint: IEndpoint, ctx: Koa.Context): Promise<void> { function getRequestArguments(ctx: Koa.Context): Record<string, any> {
const body = ctx.is('multipart/form-data') const args = {
? (ctx.request as any).body ...(ctx.params || {}),
: ctx.method === 'GET' ...ctx.query,
? ctx.query ...(ctx.request.body || {}),
: 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,
},
};
}; };
// 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<void> {
const body = getRequestArguments(ctx);
// Authentication // Authentication
// for GET requests, do not even pass on the body parameter as it is considered unsafe // 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]) => { 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<vo
ctx.body = typeof res === 'string' ? JSON.stringify(res) : res; ctx.body = typeof res === 'string' ? JSON.stringify(res) : res;
} }
}).catch((e: ApiError) => { }).catch((e: ApiError) => {
error(e); e.apply(ctx, endpoint.name);
}); });
}).catch(e => { }).catch(e => {
if (e instanceof AuthenticationError) { if (e instanceof AuthenticationError) {
error(new ApiError('AUTHENTICATION_FAILED', e.message)); new ApiError('AUTHENTICATION_FAILED', e.message).apply(ctx, endpoint.name);
} else { } else {
error(new ApiError()); new ApiError().apply(ctx, endpoint.name);
} }
}); });
} }

View file

@ -1,11 +1,11 @@
import { generateKeyPair } from 'node:crypto'; import { generateKeyPair } from 'node:crypto';
import bcrypt from 'bcryptjs';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import { User } from '@/models/entities/user.js'; import { User } from '@/models/entities/user.js';
import { Users, UsedUsernames } from '@/models/index.js'; import { Users, UsedUsernames } from '@/models/index.js';
import { UserProfile } from '@/models/entities/user-profile.js'; import { UserProfile } from '@/models/entities/user-profile.js';
import { genId } from '@/misc/gen-id.js'; import { genId } from '@/misc/gen-id.js';
import { toPunyNullable } from '@/misc/convert-host.js'; import { toPunyNullable } from '@/misc/convert-host.js';
import { hashPassword } from '@/misc/password.js';
import { UserKeypair } from '@/models/entities/user-keypair.js'; import { UserKeypair } from '@/models/entities/user-keypair.js';
import { usersChart } from '@/services/chart/index.js'; import { usersChart } from '@/services/chart/index.js';
import { UsedUsername } from '@/models/entities/used-username.js'; import { UsedUsername } from '@/models/entities/used-username.js';
@ -33,9 +33,7 @@ export async function signup(opts: {
throw new ApiError('INVALID_PASSWORD'); throw new ApiError('INVALID_PASSWORD');
} }
// Generate hash of password hash = await hashPassword(password);
const salt = await bcrypt.genSalt(8);
hash = await bcrypt.hash(password, salt);
} }
// Generate secret // Generate secret

View file

@ -702,6 +702,24 @@ export interface IEndpointMeta {
* (Cache-Control: public) * (Cache-Control: public)
*/ */
readonly cacheSec?: number; 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 { export interface IEndpoint {

View file

@ -1,5 +1,6 @@
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import { Users } from '@/models/index.js'; import { Users } from '@/models/index.js';
import { ApiError } from '@/server/api/error.js';
import define from '../../../define.js'; import define from '../../../define.js';
import { signup } from '../../../common/signup.js'; import { signup } from '../../../common/signup.js';
@ -17,6 +18,8 @@ export const meta = {
}, },
}, },
}, },
errors: ['ACCESS_DENIED'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -31,10 +34,17 @@ export const paramDef = {
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, _me) => { export default define(meta, paramDef, async (ps, _me) => {
const me = _me ? await Users.findOneByOrFail({ id: _me.id }) : null; const me = _me ? await Users.findOneByOrFail({ id: _me.id }) : null;
const noUsers = (await Users.countBy({ if (me == null) {
host: IsNull(), // check if this is the initial setup
})) === 0; const noUsers = (await Users.countBy({
if (!noUsers && !me?.isAdmin) throw new Error('access denied'); host: IsNull(),
})) === 0;
if (!noUsers) {
throw new ApiError('ACCESS_DENIED');
}
} else if (!me.isAdmin) {
throw new ApiError('ACCESS_DENIED');
}
const { account, secret } = await signup({ const { account, secret } = await signup({
username: ps.username, username: ps.username,

View file

@ -1,4 +1,5 @@
import { Users } from '@/models/index.js'; import { Users } from '@/models/index.js';
import { ApiError } from '@/server/api/error.js';
import { doPostSuspend } from '@/services/suspend-user.js'; import { doPostSuspend } from '@/services/suspend-user.js';
import { publishUserEvent } from '@/services/stream.js'; import { publishUserEvent } from '@/services/stream.js';
import { createDeleteAccountJob } from '@/queue/index.js'; import { createDeleteAccountJob } from '@/queue/index.js';
@ -9,6 +10,8 @@ export const meta = {
requireCredential: true, requireCredential: true,
requireModerator: true, requireModerator: true,
errors: ['NO_SUCH_USER', 'IS_ADMIN', 'IS_MODERATOR'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -24,15 +27,11 @@ export default define(meta, paramDef, async (ps, me) => {
const user = await Users.findOneBy({ id: ps.userId }); const user = await Users.findOneBy({ id: ps.userId });
if (user == null) { if (user == null) {
throw new Error('user not found'); throw new ApiError('NO_SUCH_USER');
} } else if (user.isAdmin) {
throw new ApiError('IS_ADMIN');
if (user.isAdmin) { } else if(user.isModerator) {
throw new Error('cannot suspend admin'); throw new ApiError('IS_MODERATOR');
}
if (user.isModerator) {
throw new Error('cannot suspend moderator');
} }
if (Users.isLocalUser(user)) { if (Users.isLocalUser(user)) {

View file

@ -1,5 +1,6 @@
import { Instances } from '@/models/index.js'; import { Instances } from '@/models/index.js';
import { toPuny } from '@/misc/convert-host.js'; import { toPuny } from '@/misc/convert-host.js';
import { ApiError } from '@/server/api/error.js';
import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js'; import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js';
import define from '../../../define.js'; import define from '../../../define.js';
@ -8,6 +9,8 @@ export const meta = {
requireCredential: true, requireCredential: true,
requireModerator: true, requireModerator: true,
errors: ['NO_SUCH_OBJECT'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -23,7 +26,7 @@ export default define(meta, paramDef, async (ps, me) => {
const instance = await Instances.findOneBy({ host: toPuny(ps.host) }); const instance = await Instances.findOneBy({ host: toPuny(ps.host) });
if (instance == null) { if (instance == null) {
throw new Error('instance not found'); throw new ApiError('NO_SUCH_OBJECT');
} }
fetchInstanceMetadata(instance, true); fetchInstanceMetadata(instance, true);

View file

@ -1,5 +1,6 @@
import { Instances } from '@/models/index.js'; import { Instances } from '@/models/index.js';
import { toPuny } from '@/misc/convert-host.js'; import { toPuny } from '@/misc/convert-host.js';
import { ApiError } from '@/server/api/error.js';
import define from '../../../define.js'; import define from '../../../define.js';
export const meta = { export const meta = {
@ -7,6 +8,8 @@ export const meta = {
requireCredential: true, requireCredential: true,
requireModerator: true, requireModerator: true,
errors: ['NO_SUCH_OBJECT'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -23,7 +26,7 @@ export default define(meta, paramDef, async (ps, me) => {
const instance = await Instances.findOneBy({ host: toPuny(ps.host) }); const instance = await Instances.findOneBy({ host: toPuny(ps.host) });
if (instance == null) { if (instance == null) {
throw new Error('instance not found'); throw new ApiError('NO_SUCH_OBJECT');
} }
Instances.update({ host: toPuny(ps.host) }, { Instances.update({ host: toPuny(ps.host) }, {

View file

@ -1,12 +1,17 @@
import { Users } from '@/models/index.js'; import { Users } from '@/models/index.js';
import { ApiError } from '@/server/api/error.js';
import { publishInternalEvent } from '@/services/stream.js'; import { publishInternalEvent } from '@/services/stream.js';
import define from '../../../define.js'; import define from '../../../define.js';
export const meta = { export const meta = {
tags: ['admin'], tags: ['admin'],
description: 'Grants a user moderator privileges. Administrators cannot be granted moderator privileges.',
requireCredential: true, requireCredential: true,
requireAdmin: true, requireAdmin: true,
errors: ['NO_SUCH_USER', 'IS_ADMIN'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -22,11 +27,11 @@ export default define(meta, paramDef, async (ps) => {
const user = await Users.findOneBy({ id: ps.userId }); const user = await Users.findOneBy({ id: ps.userId });
if (user == null) { if (user == null) {
throw new Error('user not found'); throw new ApiError('NO_SUCH_USER');
} }
if (user.isAdmin) { if (user.isAdmin) {
throw new Error('cannot mark as moderator if admin user'); throw new ApiError('IS_ADMIN');
} }
await Users.update(user.id, { await Users.update(user.id, {

View file

@ -1,4 +1,5 @@
import { Users } from '@/models/index.js'; import { Users } from '@/models/index.js';
import { ApiError } from '@/server/api/error.js';
import { publishInternalEvent } from '@/services/stream.js'; import { publishInternalEvent } from '@/services/stream.js';
import define from '../../../define.js'; import define from '../../../define.js';
@ -7,6 +8,8 @@ export const meta = {
requireCredential: true, requireCredential: true,
requireAdmin: true, requireAdmin: true,
errors: ['NO_SUCH_USER'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -22,7 +25,7 @@ export default define(meta, paramDef, async (ps) => {
const user = await Users.findOneBy({ id: ps.userId }); const user = await Users.findOneBy({ id: ps.userId });
if (user == null) { if (user == null) {
throw new Error('user not found'); throw new ApiError('NO_SUCH_USER');
} }
await Users.update(user.id, { await Users.update(user.id, {

View file

@ -51,7 +51,7 @@ export const paramDef = {
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
try { 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) { } catch (e) {
throw new ApiError('INVALID_URL', e); throw new ApiError('INVALID_URL', e);
} }

View file

@ -1,6 +1,7 @@
import bcrypt from 'bcryptjs'; import { hashPassword } from '@/misc/password.js';
import { secureRndstr } from '@/misc/secure-rndstr.js'; import { secureRndstr } from '@/misc/secure-rndstr.js';
import { Users, UserProfiles } from '@/models/index.js'; import { Users, UserProfiles } from '@/models/index.js';
import { ApiError } from '@/server/api/error.js';
import define from '../../define.js'; import define from '../../define.js';
export const meta = { export const meta = {
@ -16,11 +17,11 @@ export const meta = {
password: { password: {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
minLength: 8,
maxLength: 8,
}, },
}, },
}, },
errors: ['NO_SUCH_USER', 'IS_ADMIN'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -36,25 +37,22 @@ export default define(meta, paramDef, async (ps) => {
const user = await Users.findOneBy({ id: ps.userId }); const user = await Users.findOneBy({ id: ps.userId });
if (user == null) { if (user == null) {
throw new Error('user not found'); throw new ApiError('NO_SUCH_USER');
} }
if (user.isAdmin) { if (user.isAdmin) {
throw new Error('cannot reset password of admin'); throw new ApiError('IS_ADMIN');
} }
const passwd = secureRndstr(8, true); const password = secureRndstr(8, true);
// Generate hash of password
const hash = bcrypt.hashSync(passwd);
await UserProfiles.update({ await UserProfiles.update({
userId: user.id, userId: user.id,
}, { }, {
password: hash, password: await hashPassword(password),
}); });
return { return {
password: passwd, password,
}; };
}); });

View file

@ -1,4 +1,5 @@
import { Signins, UserProfiles, Users } from '@/models/index.js'; import { Signins, UserProfiles, Users } from '@/models/index.js';
import { ApiError } from '@/server/api/error.js';
import define from '../../define.js'; import define from '../../define.js';
export const meta = { export const meta = {
@ -11,6 +12,8 @@ export const meta = {
type: 'object', type: 'object',
nullable: false, optional: false, nullable: false, optional: false,
}, },
errors: ['NO_SUCH_USER', 'IS_ADMIN'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -29,12 +32,12 @@ export default define(meta, paramDef, async (ps, me) => {
]); ]);
if (user == null || profile == null) { 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 }); const _me = await Users.findOneByOrFail({ id: me.id });
if ((_me.isModerator && !_me.isAdmin) && user.isAdmin) { if ((_me.isModerator && !_me.isAdmin) && user.isAdmin) {
throw new Error('cannot show info of admin'); throw new ApiError('IS_ADMIN');
} }
if (!_me.isAdmin) { if (!_me.isAdmin) {

View file

@ -1,4 +1,5 @@
import { Users } from '@/models/index.js'; import { Users } from '@/models/index.js';
import { ApiError } from '@/server/api/error.js';
import { insertModerationLog } from '@/services/insert-moderation-log.js'; import { insertModerationLog } from '@/services/insert-moderation-log.js';
import { publishInternalEvent } from '@/services/stream.js'; import { publishInternalEvent } from '@/services/stream.js';
import define from '../../define.js'; import define from '../../define.js';
@ -8,6 +9,8 @@ export const meta = {
requireCredential: true, requireCredential: true,
requireModerator: true, requireModerator: true,
errors: ['NO_SUCH_USER', 'IS_ADMIN'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -23,11 +26,11 @@ export default define(meta, paramDef, async (ps, me) => {
const user = await Users.findOneBy({ id: ps.userId }); const user = await Users.findOneBy({ id: ps.userId });
if (user == null) { if (user == null) {
throw new Error('user not found'); throw new ApiError('NO_SUCH_USER');
} }
if (user.isAdmin) { if (user.isAdmin) {
throw new Error('cannot silence admin'); throw new ApiError('IS_ADMIN');
} }
await Users.update(user.id, { await Users.update(user.id, {

View file

@ -1,6 +1,7 @@
import deleteFollowing from '@/services/following/delete.js'; import deleteFollowing from '@/services/following/delete.js';
import { Users, Followings, Notifications } from '@/models/index.js'; import { Users, Followings, Notifications } from '@/models/index.js';
import { User } from '@/models/entities/user.js'; import { User } from '@/models/entities/user.js';
import { ApiError } from '@/server/api/error.js';
import { insertModerationLog } from '@/services/insert-moderation-log.js'; import { insertModerationLog } from '@/services/insert-moderation-log.js';
import { doPostSuspend } from '@/services/suspend-user.js'; import { doPostSuspend } from '@/services/suspend-user.js';
import { publishUserEvent } from '@/services/stream.js'; import { publishUserEvent } from '@/services/stream.js';
@ -11,6 +12,8 @@ export const meta = {
requireCredential: true, requireCredential: true,
requireModerator: true, requireModerator: true,
errors: ['NO_SUCH_USER', 'IS_ADMIN', 'IS_MODERATOR'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -26,15 +29,11 @@ export default define(meta, paramDef, async (ps, me) => {
const user = await Users.findOneBy({ id: ps.userId }); const user = await Users.findOneBy({ id: ps.userId });
if (user == null) { if (user == null) {
throw new Error('user not found'); throw new ApiError('NO_SUCH_USER');
} } else if (user.isAdmin) {
throw new ApiError('IS_ADMIN');
if (user.isAdmin) { } else if (user.isModerator) {
throw new Error('cannot suspend admin'); throw new ApiError('IS_MODERATOR');
}
if (user.isModerator) {
throw new Error('cannot suspend moderator');
} }
await Users.update(user.id, { await Users.update(user.id, {

View file

@ -1,4 +1,5 @@
import { Users } from '@/models/index.js'; import { Users } from '@/models/index.js';
import { ApiError } from '@/server/api/error.js';
import { insertModerationLog } from '@/services/insert-moderation-log.js'; import { insertModerationLog } from '@/services/insert-moderation-log.js';
import { publishInternalEvent } from '@/services/stream.js'; import { publishInternalEvent } from '@/services/stream.js';
import define from '../../define.js'; import define from '../../define.js';
@ -8,6 +9,8 @@ export const meta = {
requireCredential: true, requireCredential: true,
requireModerator: true, requireModerator: true,
errors: ['NO_SUCH_USER'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -23,7 +26,7 @@ export default define(meta, paramDef, async (ps, me) => {
const user = await Users.findOneBy({ id: ps.userId }); const user = await Users.findOneBy({ id: ps.userId });
if (user == null) { if (user == null) {
throw new Error('user not found'); throw new ApiError('NO_SUCH_USER');
} }
await Users.update(user.id, { await Users.update(user.id, {

View file

@ -1,4 +1,5 @@
import { Users } from '@/models/index.js'; import { Users } from '@/models/index.js';
import { ApiError } from '@/server/api/error.js';
import { insertModerationLog } from '@/services/insert-moderation-log.js'; import { insertModerationLog } from '@/services/insert-moderation-log.js';
import { doPostUnsuspend } from '@/services/unsuspend-user.js'; import { doPostUnsuspend } from '@/services/unsuspend-user.js';
import define from '../../define.js'; import define from '../../define.js';
@ -8,6 +9,8 @@ export const meta = {
requireCredential: true, requireCredential: true,
requireModerator: true, requireModerator: true,
errors: ['NO_SUCH_USER'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -23,7 +26,7 @@ export default define(meta, paramDef, async (ps, me) => {
const user = await Users.findOneBy({ id: ps.userId }); const user = await Users.findOneBy({ id: ps.userId });
if (user == null) { if (user == null) {
throw new Error('user not found'); throw new ApiError('NO_SUCH_USER');
} }
await Users.update(user.id, { await Users.update(user.id, {

View file

@ -29,6 +29,6 @@ export const paramDef = {
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps) => { export default define(meta, paramDef, async (ps) => {
const resolver = new Resolver(); const resolver = new Resolver();
const object = await resolver.resolve(ps.uri); const object = await resolver.resolve(ps.uri, true);
return object; return object;
}); });

View file

@ -98,9 +98,10 @@ async function fetchAny(uri: string, me: CacheableLocalUser | null | undefined):
])); ]));
if (local != null) return local; if (local != null) return local;
// リモートから一旦オブジェクトフェッチ // fetch object from remote
const resolver = new Resolver(); 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が確定する // /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する
// これはDBに存在する可能性があるため再度DB検索 // これはDBに存在する可能性があるため再度DB検索

View file

@ -35,7 +35,6 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
const instance = await fetchMeta(true); const instance = await fetchMeta(true);
// Calculate drive usage
const usage = await DriveFiles.calcDriveUsageOf(user.id); const usage = await DriveFiles.calcDriveUsageOf(user.id);
return { return {

View file

@ -45,7 +45,7 @@ export default define(meta, paramDef, async (ps, user) => {
if (ps.type) { if (ps.type) {
if (ps.type.endsWith('/*')) { 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 { } else {
query.andWhere('file.type = :type', { type: ps.type }); query.andWhere('file.type = :type', { type: ps.type });
} }

View file

@ -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 define from '../../../define.js';
import { ApiError } from '../../../error.js'; import { ApiError } from '../../../error.js';
import { getUser } from '../../../common/getters.js'; import { getUser } from '../../../common/getters.js';

View file

@ -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 { Users } from '@/models/index.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import define from '../../../define.js'; import define from '../../../define.js';

View file

@ -3,6 +3,7 @@ import { genId } from '@/misc/gen-id.js';
import { GalleryPost } from '@/models/entities/gallery-post.js'; import { GalleryPost } from '@/models/entities/gallery-post.js';
import { DriveFile } from '@/models/entities/drive-file.js'; import { DriveFile } from '@/models/entities/drive-file.js';
import { HOUR } from '@/const.js'; import { HOUR } from '@/const.js';
import { ApiError } from '@/server/api/error.js';
import define from '../../../define.js'; import define from '../../../define.js';
export const meta = { export const meta = {
@ -46,8 +47,14 @@ export default define(meta, paramDef, async (ps, user) => {
}), }),
))).filter((file): file is DriveFile => file != null); ))).filter((file): file is DriveFile => file != null);
if (files.length === 0) { if (files.length !== ps.fileIds.length) {
throw new Error(); throw new ApiError(
'INVALID_PARAM',
{
param: '#/properties/fileIds/items',
reason: 'contains invalid file IDs',
}
);
} }
const post = await GalleryPosts.insert(new GalleryPost({ const post = await GalleryPosts.insert(new GalleryPost({

View file

@ -1,6 +1,7 @@
import { DriveFiles, GalleryPosts } from '@/models/index.js'; import { DriveFiles, GalleryPosts } from '@/models/index.js';
import { DriveFile } from '@/models/entities/drive-file.js'; import { DriveFile } from '@/models/entities/drive-file.js';
import { HOUR } from '@/const.js'; import { HOUR } from '@/const.js';
import { ApiError } from '@/server/api/error.js';
import define from '../../../define.js'; import define from '../../../define.js';
export const meta = { export const meta = {
@ -20,6 +21,8 @@ export const meta = {
optional: false, nullable: false, optional: false, nullable: false,
ref: 'GalleryPost', ref: 'GalleryPost',
}, },
errors: ['INVALID_PARAM'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -45,8 +48,14 @@ export default define(meta, paramDef, async (ps, user) => {
}), }),
))).filter((file): file is DriveFile => file != null); ))).filter((file): file is DriveFile => file != null);
if (files.length === 0) { if (files.length !== ps.fileIds.length) {
throw new Error(); throw new ApiError(
'INVALID_PARAM',
{
param: '#/properties/fileIds/items',
reason: 'contains invalid file IDs',
}
);
} }
await GalleryPosts.update({ await GalleryPosts.update({

View file

@ -1,11 +1,14 @@
import * as speakeasy from 'speakeasy'; import * as speakeasy from 'speakeasy';
import { UserProfiles } from '@/models/index.js'; import { UserProfiles } from '@/models/index.js';
import define from '../../../define.js'; import define from '../../../define.js';
import { ApiError } from '@/server/api/error.js';
export const meta = { export const meta = {
requireCredential: true, requireCredential: true,
secure: true, secure: true,
errors: ['INTERNAL_ERROR', 'ACCESS_DENIED'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -23,7 +26,7 @@ export default define(meta, paramDef, async (ps, user) => {
const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
if (profile.twoFactorTempSecret == null) { 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({ const verified = (speakeasy as any).totp.verify({
@ -33,7 +36,7 @@ export default define(meta, paramDef, async (ps, user) => {
}); });
if (!verified) { if (!verified) {
throw new Error('not verified'); throw new ApiError('ACCESS_DENIED', 'TOTP missmatch');
} }
await UserProfiles.update(user.id, { await UserProfiles.update(user.id, {

View file

@ -1,7 +1,7 @@
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import bcrypt from 'bcryptjs';
import * as cbor from 'cbor'; import * as cbor from 'cbor';
import { MINUTE } from '@/const.js'; import { MINUTE } from '@/const.js';
import { comparePassword } from '@/misc/password.js';
import { import {
UserProfiles, UserProfiles,
UserSecurityKeys, UserSecurityKeys,
@ -9,6 +9,7 @@ import {
Users, Users,
} from '@/models/index.js'; } from '@/models/index.js';
import config from '@/config/index.js'; import config from '@/config/index.js';
import { ApiError } from '@/server/api/error.js';
import { publishMainStream } from '@/services/stream.js'; import { publishMainStream } from '@/services/stream.js';
import define from '../../../define.js'; import define from '../../../define.js';
import { procedures, hash } from '../../../2fa.js'; import { procedures, hash } from '../../../2fa.js';
@ -20,6 +21,8 @@ export const meta = {
requireCredential: true, requireCredential: true,
secure: true, secure: true,
errors: ['ACCESS_DENIED', 'INTERNAL_ERROR', 'NO_SUCH_OBJECT'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -38,24 +41,21 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
// Compare password if (!(await comparePassword(ps.password, profile.password!))) {
const same = await bcrypt.compare(ps.password, profile.password!); throw new ApiError('ACCESS_DENIED');
if (!same) {
throw new Error('incorrect password');
} }
if (!profile.twoFactorEnabled) { if (!profile.twoFactorEnabled) {
throw new Error('2fa not enabled'); throw new ApiError('INTERNAL_ERROR', '2fa not enabled');
} }
const clientData = JSON.parse(ps.clientDataJSON); const clientData = JSON.parse(ps.clientDataJSON);
if (clientData.type !== 'webauthn.create') { 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) { 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')); 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); const rpIdHash = attestation.authData.slice(0, 32);
if (!rpIdHashReal.equals(rpIdHash)) { if (!rpIdHashReal.equals(rpIdHash)) {
throw new Error('rpIdHash mismatch'); throw new ApiError('INTERNAL_ERROR', 'rpIdHash mismatch');
} }
const flags = attestation.authData[32]; const flags = attestation.authData[32];
// eslint:disable-next-line:no-bitwise // eslint:disable-next-line:no-bitwise
if (!(flags & 1)) { if (!(flags & 1)) {
throw new Error('user not present'); throw new ApiError('INTERNAL_ERROR', 'user not present');
} }
const authData = Buffer.from(attestation.authData); 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 publicKeyData = authData.slice(55 + credentialIdLength);
const publicKey: Map<number, any> = await cborDecodeFirst(publicKeyData); const publicKey: Map<number, any> = await cborDecodeFirst(publicKeyData);
if (publicKey.get(3) !== -7) { if (publicKey.get(3) !== -7) {
throw new Error('alg mismatch'); throw new ApiError('INTERNAL_ERROR', 'algorithm mismatch');
} }
if (!(procedures as any)[attestation.fmt]) { 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({ const verificationData = (procedures as any)[attestation.fmt].verify({
@ -95,7 +95,7 @@ export default define(meta, paramDef, async (ps, user) => {
publicKey, publicKey,
rpIdHash, rpIdHash,
}); });
if (!verificationData.valid) throw new Error('signature invalid'); if (!verificationData.valid) throw new ApiError('INTERNAL_ERROR', 'signature invalid');
const attestationChallenge = await AttestationChallenges.findOneBy({ const attestationChallenge = await AttestationChallenges.findOneBy({
userId: user.id, userId: user.id,
@ -105,7 +105,7 @@ export default define(meta, paramDef, async (ps, user) => {
}); });
if (!attestationChallenge) { if (!attestationChallenge) {
throw new Error('non-existent challenge'); throw new ApiError('NO_SUCH_OBJECT', 'Attestation challenge not found.');
} }
await AttestationChallenges.delete({ await AttestationChallenges.delete({
@ -118,7 +118,7 @@ export default define(meta, paramDef, async (ps, user) => {
new Date().getTime() - attestationChallenge.createdAt.getTime() >= new Date().getTime() - attestationChallenge.createdAt.getTime() >=
5 * MINUTE 5 * MINUTE
) { ) {
throw new Error('expired challenge'); throw new ApiError('NO_SUCH_OBJECT', 'Attestation challenge expired.');
} }
const credentialIdString = credentialId.toString('hex'); const credentialIdString = credentialId.toString('hex');

View file

@ -1,8 +1,9 @@
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import * as crypto from 'node:crypto'; import * as crypto from 'node:crypto';
import bcrypt from 'bcryptjs';
import { UserProfiles, AttestationChallenges } from '@/models/index.js'; import { UserProfiles, AttestationChallenges } from '@/models/index.js';
import { genId } from '@/misc/gen-id.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 define from '../../../define.js';
import { hash } from '../../../2fa.js'; import { hash } from '../../../2fa.js';
@ -12,6 +13,8 @@ export const meta = {
requireCredential: true, requireCredential: true,
secure: true, secure: true,
errors: ['ACCESS_DENIED', 'INTERNAL_ERROR'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -26,15 +29,12 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
// Compare password if (!(await comparePassword(ps.password, profile.password!))) {
const same = await bcrypt.compare(ps.password, profile.password!); throw new ApiError('ACCESS_DENIED');
if (!same) {
throw new Error('incorrect password');
} }
if (!profile.twoFactorEnabled) { if (!profile.twoFactorEnabled) {
throw new Error('2fa not enabled'); throw new ApiError('INTERNAL_ERROR', '2fa not enabled');
} }
// 32 byte challenge // 32 byte challenge

View file

@ -1,14 +1,17 @@
import bcrypt from 'bcryptjs';
import * as speakeasy from 'speakeasy'; import * as speakeasy from 'speakeasy';
import * as QRCode from 'qrcode'; import * as QRCode from 'qrcode';
import config from '@/config/index.js'; import config from '@/config/index.js';
import { comparePassword } from '@/misc/password.js';
import { UserProfiles } from '@/models/index.js'; import { UserProfiles } from '@/models/index.js';
import { ApiError } from '@/server/api/error.js';
import define from '../../../define.js'; import define from '../../../define.js';
export const meta = { export const meta = {
requireCredential: true, requireCredential: true,
secure: true, secure: true,
errors: ['ACCESS_DENIED'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -23,11 +26,8 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
// Compare password if (!(await comparePassword(ps.password, profile.password!))) {
const same = await bcrypt.compare(ps.password, profile.password!); throw new ApiError('ACCESS_DENIED');
if (!same) {
throw new Error('incorrect password');
} }
// Generate user's secret key // Generate user's secret key

View file

@ -1,12 +1,15 @@
import bcrypt from 'bcryptjs'; import { comparePassword } from '@/misc/password.js';
import { UserProfiles, UserSecurityKeys, Users } from '@/models/index.js'; import { UserProfiles, UserSecurityKeys, Users } from '@/models/index.js';
import { publishMainStream } from '@/services/stream.js'; import { publishMainStream } from '@/services/stream.js';
import { ApiError } from '@/server/api/error.js';
import define from '../../../define.js'; import define from '../../../define.js';
export const meta = { export const meta = {
requireCredential: true, requireCredential: true,
secure: true, secure: true,
errors: ['ACCESS_DENIED'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -22,11 +25,8 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
// Compare password if (!(await comparePassword(ps.password, profile.password!))) {
const same = await bcrypt.compare(ps.password, profile.password!); throw new ApiError('ACCESS_DENIED');
if (!same) {
throw new Error('incorrect password');
} }
// Make sure we only delete the user's own creds // Make sure we only delete the user's own creds

View file

@ -1,11 +1,14 @@
import bcrypt from 'bcryptjs'; import { comparePassword } from '@/misc/password.js';
import { UserProfiles } from '@/models/index.js'; import { UserProfiles } from '@/models/index.js';
import { ApiError } from '@/server/api/error.js';
import define from '../../../define.js'; import define from '../../../define.js';
export const meta = { export const meta = {
requireCredential: true, requireCredential: true,
secure: true, secure: true,
errors: ['ACCESS_DENIED'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -20,11 +23,8 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
// Compare password if (!(await comparePassword(ps.password, profile.password!))) {
const same = await bcrypt.compare(ps.password, profile.password!); throw new ApiError('ACCESS_DENIED');
if (!same) {
throw new Error('incorrect password');
} }
await UserProfiles.update(user.id, { await UserProfiles.update(user.id, {

View file

@ -1,11 +1,14 @@
import bcrypt from 'bcryptjs'; import { comparePassword, hashPassword } from '@/misc/password.js';
import { UserProfiles } from '@/models/index.js'; import { UserProfiles } from '@/models/index.js';
import { ApiError } from '@/server/api/error.js';
import define from '../../define.js'; import define from '../../define.js';
export const meta = { export const meta = {
requireCredential: true, requireCredential: true,
secure: true, secure: true,
errors: ['ACCESS_DENIED'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -21,18 +24,11 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
// Compare password if (!(await comparePassword(ps.currentPassword, profile.password!))) {
const same = await bcrypt.compare(ps.currentPassword, profile.password!); throw new ApiError('ACCESS_DENIED');
if (!same) {
throw new Error('incorrect password');
} }
// Generate hash of password
const salt = await bcrypt.genSalt(8);
const hash = await bcrypt.hash(ps.newPassword, salt);
await UserProfiles.update(user.id, { await UserProfiles.update(user.id, {
password: hash, password: await hashPassword(ps.newPassword),
}); });
}); });

View file

@ -1,12 +1,15 @@
import bcrypt from 'bcryptjs'; import { comparePassword } from '@/misc/password.js';
import { UserProfiles, Users } from '@/models/index.js'; import { UserProfiles, Users } from '@/models/index.js';
import { deleteAccount } from '@/services/delete-account.js'; import { deleteAccount } from '@/services/delete-account.js';
import { ApiError } from '@/server/api/error.js';
import define from '../../define.js'; import define from '../../define.js';
export const meta = { export const meta = {
requireCredential: true, requireCredential: true,
secure: true, secure: true,
errors: ['ACCESS_DENIED'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -19,17 +22,17 @@ export const paramDef = {
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); const [profile, userDetailed] = await Promise.all([
const userDetailed = await Users.findOneByOrFail({ id: user.id }); UserProfiles.findOneByOrFail({ userId: user.id }),
Users.findOneByOrFail({ id: user.id }),
]);
if (userDetailed.isDeleted) { if (userDetailed.isDeleted) {
return; return;
} }
// Compare password if (!(await comparePassword(ps.password, profile.password!))) {
const same = await bcrypt.compare(ps.password, profile.password!); throw new ApiError('ACCESS_DENIED');
if (!same) {
throw new Error('incorrect password');
} }
await deleteAccount(user); await deleteAccount(user);

View file

@ -1,13 +1,16 @@
import bcrypt from 'bcryptjs'; import { comparePassword } from '@/misc/password.js';
import { publishInternalEvent, publishMainStream, publishUserEvent } from '@/services/stream.js'; import { publishInternalEvent, publishMainStream, publishUserEvent } from '@/services/stream.js';
import { Users, UserProfiles } from '@/models/index.js'; import { Users, UserProfiles } from '@/models/index.js';
import generateUserToken from '../../common/generate-native-user-token.js'; import generateUserToken from '../../common/generate-native-user-token.js';
import { ApiError } from '@/server/api/error.js';
import define from '../../define.js'; import define from '../../define.js';
export const meta = { export const meta = {
requireCredential: true, requireCredential: true,
secure: true, secure: true,
errors: ['ACCESS_DENIED'],
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -25,11 +28,8 @@ export default define(meta, paramDef, async (ps, user) => {
const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
// Compare password if (!(await comparePassword(ps.password, profile.password!))) {
const same = await bcrypt.compare(ps.password, profile.password!); throw new ApiError('ACCESS_DENIED');
if (!same) {
throw new Error('incorrect password');
} }
const newToken = generateUserToken(); const newToken = generateUserToken();

View file

@ -1,6 +1,6 @@
import bcrypt from 'bcryptjs';
import { publishMainStream } from '@/services/stream.js'; import { publishMainStream } from '@/services/stream.js';
import config from '@/config/index.js'; import config from '@/config/index.js';
import { comparePassword } from '@/misc/password.js';
import { secureRndstr } from '@/misc/secure-rndstr.js'; import { secureRndstr } from '@/misc/secure-rndstr.js';
import { Users, UserProfiles } from '@/models/index.js'; import { Users, UserProfiles } from '@/models/index.js';
import { sendEmail } from '@/services/send-email.js'; import { sendEmail } from '@/services/send-email.js';
@ -37,10 +37,9 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
// Compare password if (!(await comparePassword(ps.password, profile.password!))) {
const same = await bcrypt.compare(ps.password, profile.password!); throw new ApiError('ACCESS_DENIED');
}
if (!same) throw new ApiError('ACCESS_DENIED');
if (ps.email != null) { if (ps.email != null) {
const available = await validateEmailForAccount(ps.email); const available = await validateEmailForAccount(ps.email);

View file

@ -2,7 +2,7 @@ import RE2 from 're2';
import * as mfm from 'mfm-js'; import * as mfm from 'mfm-js';
import { notificationTypes } from 'foundkey-js'; import { notificationTypes } from 'foundkey-js';
import { publishMainStream, publishUserEvent } from '@/services/stream.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 { publishToFollowers } from '@/services/i/update.js';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js'; import { extractHashtags } from '@/misc/extract-hashtags.js';

View file

@ -233,6 +233,10 @@ export const meta = {
}, },
}, },
}, },
v2: {
method: 'get'
},
} as const; } as const;
export const paramDef = { export const paramDef = {

View file

@ -21,6 +21,11 @@ export const meta = {
ref: 'Note', ref: 'Note',
}, },
}, },
v2: {
method: 'get',
alias: 'notes/:noteId/children',
},
} as const; } as const;
export const paramDef = { export const paramDef = {

View file

@ -19,6 +19,11 @@ export const meta = {
}, },
}, },
v2: {
method: 'get',
alias: 'notes/:noteId/clips',
},
errors: ['NO_SUCH_NOTE'], errors: ['NO_SUCH_NOTE'],
} as const; } as const;

View file

@ -19,6 +19,11 @@ export const meta = {
}, },
}, },
v2: {
method: 'get',
alias: 'notes/:noteId/conversation',
},
errors: ['NO_SUCH_NOTE'], errors: ['NO_SUCH_NOTE'],
} as const; } as const;

View file

@ -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'], errors: ['NO_SUCH_NOTE', 'PURE_RENOTE', 'EXPIRED_POLL', 'NO_SUCH_CHANNEL', 'BLOCKED', 'LESS_RESTRICTIVE_VISIBILITY'],
} as const; } as const;

View file

@ -18,6 +18,11 @@ export const meta = {
minInterval: SECOND, minInterval: SECOND,
}, },
v2: {
method: 'delete',
alias: 'notes/:noteId',
},
errors: ['ACCESS_DENIED', 'NO_SUCH_NOTE'], errors: ['ACCESS_DENIED', 'NO_SUCH_NOTE'],
} as const; } as const;

View file

@ -18,6 +18,10 @@ export const meta = {
ref: 'Note', ref: 'Note',
}, },
}, },
v2: {
method: 'get',
}
} as const; } as const;
export const paramDef = { export const paramDef = {

View file

@ -23,6 +23,11 @@ export const meta = {
}, },
}, },
v2: {
method: 'get',
alias: 'notes/:noteId/reactions/:type?',
},
errors: ['NO_SUCH_NOTE'], errors: ['NO_SUCH_NOTE'],
} as const; } as const;

View file

@ -22,6 +22,11 @@ export const meta = {
}, },
}, },
v2: {
method: 'get',
alias: 'notes/:noteId/renotes',
},
errors: ['NO_SUCH_NOTE'], errors: ['NO_SUCH_NOTE'],
} as const; } as const;

View file

@ -22,6 +22,11 @@ export const meta = {
}, },
}, },
v2: {
method: 'get',
alias: 'notes/:noteId/replies',
},
errors: ['NO_SUCH_NOTE'], errors: ['NO_SUCH_NOTE'],
} as const; } as const;

View file

@ -14,6 +14,11 @@ export const meta = {
ref: 'Note', ref: 'Note',
}, },
v2: {
method: 'get',
alias: 'notes/:noteId',
},
errors: ['NO_SUCH_NOTE'], errors: ['NO_SUCH_NOTE'],
} as const; } as const;

View file

@ -27,6 +27,11 @@ export const meta = {
}, },
}, },
v2: {
method: 'get',
alias: 'notes/:noteId/status',
},
errors: ['NO_SUCH_NOTE'], errors: ['NO_SUCH_NOTE'],
} as const; } as const;

View file

@ -56,6 +56,11 @@ export const meta = {
}, },
}, },
v2: {
method: 'get',
alias: 'notes/:noteId/translate/:targetLang/:sourceLang?',
},
errors: ['NO_SUCH_NOTE'], errors: ['NO_SUCH_NOTE'],
} as const; } as const;

View file

@ -18,6 +18,11 @@ export const meta = {
minInterval: SECOND, minInterval: SECOND,
}, },
v2: {
method: 'delete',
alias: 'notes/:noteId/renotes',
},
errors: ['NO_SUCH_NOTE'], errors: ['NO_SUCH_NOTE'],
} as const; } as const;

View file

@ -1,4 +1,5 @@
import { resetDb } from '@/db/postgre.js'; import { resetDb } from '@/db/postgre.js';
import { ApiError } from '@/server/api/error.js';
import define from '../define.js'; import define from '../define.js';
export const meta = { export const meta = {
@ -17,7 +18,7 @@ export const paramDef = {
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => { 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(); await resetDb();

View file

@ -1,4 +1,4 @@
import bcrypt from 'bcryptjs'; import { hashPassword } from '@/misc/password.js';
import { UserProfiles, PasswordResetRequests } from '@/models/index.js'; import { UserProfiles, PasswordResetRequests } from '@/models/index.js';
import { DAY, MINUTE } from '@/const.js'; import { DAY, MINUTE } from '@/const.js';
import define from '../define.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'); 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, { await UserProfiles.update(req.userId, {
password: hash, password: await hashPassword(ps.password),
}); });
await PasswordResetRequests.delete(req.id); await PasswordResetRequests.delete(req.id);

View file

@ -177,7 +177,7 @@ export default define(meta, paramDef, async (ps, me) => {
driveFilesCount: DriveFiles.createQueryBuilder('file') driveFilesCount: DriveFiles.createQueryBuilder('file')
.where('file.userId = :userId', { userId: user.id }) .where('file.userId = :userId', { userId: user.id })
.getCount(), .getCount(),
driveUsage: DriveFiles.calcDriveUsageOf(user), driveUsage: DriveFiles.calcDriveUsageOf(user.id),
}); });
result.followingCount = result.localFollowingCount + result.remoteFollowingCount; result.followingCount = result.localFollowingCount + result.remoteFollowingCount;

View file

@ -1,3 +1,5 @@
import Koa from 'koa';
export class ApiError extends Error { export class ApiError extends Error {
public message: string; public message: string;
public code: string; public code: string;
@ -20,6 +22,24 @@ export class ApiError extends Error {
this.message = message; this.message = message;
this.httpStatusCode = httpStatusCode; 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<string, { message: string, httpStatusCode: number }> = { export const errors: Record<string, { message: string, httpStatusCode: number }> = {
@ -167,6 +187,14 @@ export const errors: Record<string, { message: string, httpStatusCode: number }>
message: 'Invalid username.', message: 'Invalid username.',
httpStatusCode: 400, 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: { LESS_RESTRICTIVE_VISIBILITY: {
message: 'The visibility cannot be less restrictive than the parent note.', message: 'The visibility cannot be less restrictive than the parent note.',
httpStatusCode: 400, httpStatusCode: 400,

View file

@ -16,6 +16,7 @@ import signup from './private/signup.js';
import signin from './private/signin.js'; import signin from './private/signin.js';
import signupPending from './private/signup-pending.js'; import signupPending from './private/signup-pending.js';
import { oauth } from './common/oauth.js'; import { oauth } from './common/oauth.js';
import { ApiError } from './error.js';
// Init app // Init app
const app = new Koa(); const app = new Koa();
@ -40,6 +41,27 @@ const upload = multer({
files: 1, 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<void>) => Promise<void>;
const wrapped = upload.single('file');
function uploadWrapper(endpoint: string): KoaMiddleware {
return (ctx: Koa.Context, next: () => Promise<void>): Promise<void> => {
// 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 // Init router
const router = new Router(); const router = new Router();
@ -49,7 +71,7 @@ const router = new Router();
*/ */
for (const endpoint of endpoints) { for (const endpoint of endpoints) {
if (endpoint.meta.requireFile) { 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 { } else {
// 後方互換性のため // 後方互換性のため
if (endpoint.name.includes('-')) { if (endpoint.name.includes('-')) {
@ -69,6 +91,11 @@ for (const endpoint of endpoints) {
} else { } else {
router.get(`/${endpoint.name}`, async ctx => { ctx.status = 405; }); 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));
}
} }
} }

View file

@ -207,6 +207,19 @@ export function genOpenapiSpec() {
} }
spec.paths['/' + endpoint.name] = path; 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; return spec;

View file

@ -1,7 +1,6 @@
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import Koa from 'koa'; import Koa from 'koa';
import bcrypt from 'bcryptjs';
import * as speakeasy from 'speakeasy'; import * as speakeasy from 'speakeasy';
import { SECOND, MINUTE, HOUR } from '@/const.js'; import { SECOND, MINUTE, HOUR } from '@/const.js';
import config from '@/config/index.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 { ILocalUser } from '@/models/entities/user.js';
import { genId } from '@/misc/gen-id.js'; import { genId } from '@/misc/gen-id.js';
import { getIpHash } from '@/misc/get-ip-hash.js'; import { getIpHash } from '@/misc/get-ip-hash.js';
import { comparePassword } from '@/misc/password.js';
import signin from '../common/signin.js'; import signin from '../common/signin.js';
import { verifyLogin, hash } from '../2fa.js'; import { verifyLogin, hash } from '../2fa.js';
import { limiter } from '../limiter.js'; import { limiter } from '../limiter.js';
import { ApiError } from '../error.js'; import { ApiError, errors } from '../error.js';
export default async (ctx: Koa.Context) => { export default async (ctx: Koa.Context) => {
ctx.set('Access-Control-Allow-Origin', config.url); 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 body = ctx.request.body as any;
const { username, password, token } = body; const { username, password, token } = body;
// taken from @server/api/api-handler.ts function error(e: keyof errors, info?: Record<string, any>): void {
function error (e: ApiError): void { new ApiError(e, info).apply(ctx, 'signin');
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',
},
};
} }
try { try {
// not more than 1 attempt per second and not more than 10 attempts per hour // 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)); await limiter({ key: 'signin', duration: HOUR, max: 10, minInterval: SECOND }, getIpHash(ctx.ip));
} catch (err) { } catch (err) {
error(new ApiError('RATE_LIMIT_EXCEEDED')); error('RATE_LIMIT_EXCEEDED');
return; return;
} }
if (typeof username !== 'string') { 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; return;
} }
if (typeof password !== 'string') { 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; return;
} }
if (token != null && typeof token !== 'string') { 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; return;
} }
@ -67,19 +55,19 @@ export default async (ctx: Koa.Context) => {
}) as ILocalUser; }) as ILocalUser;
if (user == null) { if (user == null) {
error(new ApiError('NO_SUCH_USER')); error('NO_SUCH_USER');
return; return;
} }
if (user.isSuspended) { if (user.isSuspended) {
error(new ApiError('SUSPENDED')); error('SUSPENDED');
return; return;
} }
const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
// Compare password // Compare password
const same = await bcrypt.compare(password, profile.password!); const same = await comparePassword(password, profile.password!);
async function fail(): void { async function fail(): void {
// Append signin history // Append signin history
@ -92,7 +80,7 @@ export default async (ctx: Koa.Context) => {
success: false, success: false,
}); });
error(new ApiError('ACCESS_DENIED')); error('ACCESS_DENIED');
} }
if (!profile.twoFactorEnabled) { if (!profile.twoFactorEnabled) {

View file

@ -1,7 +1,7 @@
import Koa from 'koa'; import Koa from 'koa';
import bcrypt from 'bcryptjs';
import { fetchMeta } from '@/misc/fetch-meta.js'; import { fetchMeta } from '@/misc/fetch-meta.js';
import { verifyHcaptcha, verifyRecaptcha } from '@/misc/captcha.js'; import { verifyHcaptcha, verifyRecaptcha } from '@/misc/captcha.js';
import { hashPassword } from '@/misc/password.js';
import { Users, RegistrationTickets, UserPendings } from '@/models/index.js'; import { Users, RegistrationTickets, UserPendings } from '@/models/index.js';
import config from '@/config/index.js'; import config from '@/config/index.js';
import { sendEmail } from '@/services/send-email.js'; import { sendEmail } from '@/services/send-email.js';
@ -71,17 +71,13 @@ export default async (ctx: Koa.Context) => {
if (instance.emailRequiredForSignup) { if (instance.emailRequiredForSignup) {
const code = secureRndstr(16); const code = secureRndstr(16);
// Generate hash of password
const salt = await bcrypt.genSalt(8);
const hash = await bcrypt.hash(password, salt);
await UserPendings.insert({ await UserPendings.insert({
id: genId(), id: genId(),
createdAt: new Date(), createdAt: new Date(),
code, code,
email: emailAddress, email: emailAddress,
username, username,
password: hash, password: await hashPassword(password),
}); });
const link = `${config.url}/signup-complete/${code}`; const link = `${config.url}/signup-complete/${code}`;

View file

@ -7,6 +7,7 @@
import * as nestedProperty from 'nested-property'; import * as nestedProperty from 'nested-property';
import { EntitySchema, Repository, LessThan, Between } from 'typeorm'; import { EntitySchema, Repository, LessThan, Between } from 'typeorm';
import { dateUTC, isTimeSame, isTimeBefore, subtractTime, addTime } from '@/prelude/time.js'; import { dateUTC, isTimeSame, isTimeBefore, subtractTime, addTime } from '@/prelude/time.js';
import { unique } from '@/prelude/array.js';
import { getChartInsertLock } from '@/misc/app-lock.js'; import { getChartInsertLock } from '@/misc/app-lock.js';
import { db } from '@/db/postgre.js'; import { db } from '@/db/postgre.js';
import Logger from '../logger.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()); return str.replace(/([A-Z])/g, s => '_' + s.charAt(0).toLowerCase());
}; };
const removeDuplicates = (array: any[]) => Array.from(new Set(array));
type Commit<S extends Schema> = { type Commit<S extends Schema> = {
[K in keyof S]?: S[K]['uniqueIncrement'] extends true ? string[] : number; [K in keyof S]?: S[K]['uniqueIncrement'] extends true ? string[] : number;
}; };
@ -483,7 +482,7 @@ export default abstract class Chart<T extends Schema> {
this.buffer = this.buffer.filter(q => q.group != null && (q.group !== logHour.group)); 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( await Promise.all(
groups.map(group => groups.map(group =>
@ -651,12 +650,7 @@ export default abstract class Chart<T extends Schema> {
const res = {} as ChartResult<T>; const res = {} as ChartResult<T>;
/** // Turn array of objects into object of arrays.
* Turn
* [{ foo: 1, bar: 5 }, { foo: 2, bar: 6 }, { foo: 3, bar: 7 }]
* into
* { foo: [1, 2, 3], bar: [5, 6, 7] }
*/
for (const record of chart) { for (const record of chart) {
for (const [k, v] of Object.entries(record) as ([keyof typeof record, number])[]) { for (const [k, v] of Object.entries(record) as ([keyof typeof record, number])[]) {
if (res[k]) { if (res[k]) {

View file

@ -1,7 +1,7 @@
import bcrypt from 'bcryptjs';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
import { hashPassword } from '@/misc/password.js';
import { User } from '@/models/entities/user.js'; import { User } from '@/models/entities/user.js';
import { UserProfile } from '@/models/entities/user-profile.js'; import { UserProfile } from '@/models/entities/user-profile.js';
import { genId } from '@/misc/gen-id.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'; import generateNativeUserToken from '@/server/api/common/generate-native-user-token.js';
export async function createSystemUser(username: string): Promise<User> { export async function createSystemUser(username: string): Promise<User> {
const password = uuid(); const password = await hashPassword(uuid());
// Generate hash of password
const salt = await bcrypt.genSalt(8);
const hash = await bcrypt.hash(password, salt);
// Generate secret // Generate secret
const secret = generateNativeUserToken(); const secret = generateNativeUserToken();
@ -55,7 +51,7 @@ export async function createSystemUser(username: string): Promise<User> {
await transactionalEntityManager.insert(UserProfile, { await transactionalEntityManager.insert(UserProfile, {
userId: account.id, userId: account.id,
autoAcceptFollowed: false, autoAcceptFollowed: false,
password: hash, password,
}); });
await transactionalEntityManager.insert(UsedUsername, { await transactionalEntityManager.insert(UsedUsername, {

View file

@ -372,7 +372,7 @@ export async function addFile({
//#region Check drive usage //#region Check drive usage
if (user && !isLink) { if (user && !isLink) {
const usage = await DriveFiles.calcDriveUsageOf(user); const usage = await DriveFiles.calcDriveUsageOf(user.id);
const instance = await fetchMeta(); const instance = await fetchMeta();
const driveCapacity = 1024 * 1024 * (Users.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb); const driveCapacity = 1024 * 1024 * (Users.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb);

View file

@ -15,7 +15,7 @@ import { getActiveWebhooks } from '@/misc/webhook-cache.js';
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js'; import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js';
import Logger from '../logger.js'; import Logger from '../logger.js';
import { createNotification } from '../create-notification.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'); const logger = new Logger('following/create');

View file

@ -24,7 +24,7 @@ type Both = Local | Remote;
/** /**
* API following/request/reject * API following/request/reject
*/ */
export async function rejectFollowRequest(user: Local, follower: Both) { export async function rejectFollowRequest(user: Local, follower: Both): Promise<void> {
if (Users.isRemoteUser(follower)) { if (Users.isRemoteUser(follower)) {
deliverReject(user, follower); deliverReject(user, follower);
} }
@ -39,7 +39,7 @@ export async function rejectFollowRequest(user: Local, follower: Both) {
/** /**
* API following/reject * API following/reject
*/ */
export async function rejectFollow(user: Local, follower: Both) { export async function rejectFollow(user: Local, follower: Both): Promise<void> {
if (Users.isRemoteUser(follower)) { if (Users.isRemoteUser(follower)) {
deliverReject(user, follower); deliverReject(user, follower);
} }
@ -54,7 +54,7 @@ export async function rejectFollow(user: Local, follower: Both) {
/** /**
* AP Reject/Follow * AP Reject/Follow
*/ */
export async function remoteReject(actor: Remote, follower: Local) { export async function remoteReject(actor: Remote, follower: Local): Promise<void> {
await removeFollowRequest(actor, follower); await removeFollowRequest(actor, follower);
await removeFollow(actor, follower); await removeFollow(actor, follower);
publishUnfollow(actor, follower); publishUnfollow(actor, follower);
@ -63,7 +63,7 @@ export async function remoteReject(actor: Remote, follower: Local) {
/** /**
* Remove follow request record * Remove follow request record
*/ */
async function removeFollowRequest(followee: Both, follower: Both) { async function removeFollowRequest(followee: Both, follower: Both): Promise<void> {
const request = await FollowRequests.findOneBy({ const request = await FollowRequests.findOneBy({
followeeId: followee.id, followeeId: followee.id,
followerId: follower.id, followerId: follower.id,
@ -77,7 +77,7 @@ async function removeFollowRequest(followee: Both, follower: Both) {
/** /**
* Remove follow record * Remove follow record
*/ */
async function removeFollow(followee: Both, follower: Both) { async function removeFollow(followee: Both, follower: Both): Promise<void> {
const following = await Followings.findOneBy({ const following = await Followings.findOneBy({
followeeId: followee.id, followeeId: followee.id,
followerId: follower.id, followerId: follower.id,
@ -92,7 +92,7 @@ async function removeFollow(followee: Both, follower: Both) {
/** /**
* Deliver Reject to remote * Deliver Reject to remote
*/ */
async function deliverReject(followee: Local, follower: Remote) { async function deliverReject(followee: Local, follower: Remote): Promise<void> {
const request = await FollowRequests.findOneBy({ const request = await FollowRequests.findOneBy({
followeeId: followee.id, followeeId: followee.id,
followerId: follower.id, followerId: follower.id,
@ -105,7 +105,7 @@ async function deliverReject(followee: Local, follower: Remote) {
/** /**
* Publish unfollow to local * Publish unfollow to local
*/ */
async function publishUnfollow(followee: Both, follower: Local) { async function publishUnfollow(followee: Both, follower: Local): Promise<void> {
const packedFollowee = await Users.pack(followee.id, follower, { const packedFollowee = await Users.pack(followee.id, follower, {
detail: true, detail: true,
}); });

View file

@ -1,18 +1,18 @@
import { User } from '@/models/entities/user.js'; import { User } from '@/models/entities/user.js';
import { FollowRequests, Users } from '@/models/index.js'; import { FollowRequests, Users } from '@/models/index.js';
import accept from './accept.js'; import { acceptFollowRequest } from './accept.js';
/** /**
* * Approve all follow requests addressed to the specified user.
* @param 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<void> {
const requests = await FollowRequests.findBy({ const requests = await FollowRequests.findBy({
followeeId: user.id, followeeId: user.id,
}); });
for (const request of requests) { for (const request of requests) {
const follower = await Users.findOneByOrFail({ id: request.followerId }); const follower = await Users.findOneByOrFail({ id: request.followerId });
accept(user, follower); acceptFollowRequest(user, follower);
} }
} }

View file

@ -3,12 +3,17 @@ import renderFollow from '@/remote/activitypub/renderer/follow.js';
import renderAccept from '@/remote/activitypub/renderer/accept.js'; import renderAccept from '@/remote/activitypub/renderer/accept.js';
import { deliver } from '@/queue/index.js'; import { deliver } from '@/queue/index.js';
import { publishMainStream } from '@/services/stream.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 { FollowRequests, Users } from '@/models/index.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { insertFollowingDoc } from '../create.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<void> {
const request = await FollowRequests.findOneBy({ const request = await FollowRequests.findOneBy({
followeeId: followee.id, followeeId: followee.id,
followerId: follower.id, followerId: follower.id,

View file

@ -7,7 +7,12 @@ import { IdentifiableError } from '@/misc/identifiable-error.js';
import { User } from '@/models/entities/user.js'; import { User } from '@/models/entities/user.js';
import { Users, FollowRequests } from '@/models/index.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<void> {
if (Users.isRemoteUser(followee)) { if (Users.isRemoteUser(followee)) {
const content = renderActivity(renderUndo(renderFollow(follower, followee), follower)); const content = renderActivity(renderUndo(renderFollow(follower, followee), follower));

View file

@ -7,7 +7,13 @@ import { Blockings, FollowRequests, Users } from '@/models/index.js';
import { genId } from '@/misc/gen-id.js'; import { genId } from '@/misc/gen-id.js';
import { createNotification } from '@/services/create-notification.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<void> {
if (follower.id === followee.id) return; if (follower.id === followee.id) return;
// check blocking // check blocking

View file

@ -23,7 +23,7 @@
<div style="margin-bottom: 16px;"><b>{{ i18n.ts.permission }}</b></div> <div style="margin-bottom: 16px;"><b>{{ i18n.ts.permission }}</b></div>
<MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton> <MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton>
<MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton> <MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton>
<FormSwitch v-for="kind in (initialPermissions || kinds)" :key="kind" v-model="permissions[kind]">{{ i18n.t(`_permissions.${kind}`) }}</FormSwitch> <FormSwitch v-for="kind in kinds" :key="kind" v-model="permissions[kind]">{{ i18n.t(`_permissions.${kind}`) }}</FormSwitch>
</div> </div>
</XModalWindow> </XModalWindow>
</template> </template>
@ -57,16 +57,15 @@ const emit = defineEmits<{
let dialog: InstanceType<typeof XModalWindow> | null = $ref(null); let dialog: InstanceType<typeof XModalWindow> | null = $ref(null);
let name = $ref(props.initialName); let name = $ref(props.initialName);
let perms: Record<string, boolean> = $ref({}); let perms: Record<string, boolean> = $ref({});
const kinds = $ref(permissions); let kinds = props.initialPermissions.length > 0
? props.initialPermissions
: permissions;
if (props.initialPermissions.length > 0) { // If there is a particular set of permissions given, enable all of them.
for (const kind of props.initialPermissions) { // Otherwise, by default disable all permissions.
perms[kind] = true; const enable = props.initialPermissions.length > 0;
} for (const kind of kinds) {
} else { perms[kind] = enable;
for (const kind of kinds) {
perms[kind] = false;
}
} }
function ok(): void { function ok(): void {

View file

@ -1,31 +1,83 @@
// TODO: useTooltip関数使うようにしたい // TODO: use the useTooltip function
// ただディレクティブ内でonUnmountedなどのcomposition api使えるのか不明
import { defineAsyncComponent, Directive, ref } from 'vue'; import { defineAsyncComponent, Directive, ref } from 'vue';
import { isTouchUsing } from '@/scripts/touch'; import { isTouchUsing } from '@/scripts/touch';
import { popup, alert } from '@/os'; import { popup, alert } from '@/os';
const start = isTouchUsing ? 'touchstart' : 'mouseover';
const end = isTouchUsing ? 'touchend' : 'mouseleave';
const delay = 100; const delay = 100;
class TooltipDirective {
public text: string | null;
private asMfm: boolean;
private _close: null | () => void;
private showTimer: null | ReturnType<typeof window.setTimeout>;
private hideTimer: null | ReturnType<typeof window.setTimeout>;
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 { export default {
created(el: HTMLElement, binding) {
(el as any)._tooltipDirective_ = new TooltipDirective(binding);
},
mounted(el: HTMLElement, binding) { mounted(el: HTMLElement, binding) {
const self = (el as any)._tooltipDirective_ = {} as any; const self = el._tooltipDirective_ as TooltipDirective;
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;
}
};
if (binding.arg === 'dialog') { if (binding.arg === 'dialog') {
el.addEventListener('click', (ev) => { el.addEventListener('click', (ev) => {
@ -39,53 +91,20 @@ export default {
}); });
} }
self.show = () => { // add event listeners
if (!document.body.contains(el)) return; const start = isTouchUsing ? 'touchstart' : 'mouseover';
if (self._close) return; const end = isTouchUsing ? 'touchend' : 'mouseleave';
if (self.text == null) return; el.addEventListener(start, () => self.show(el), { passive: true });
el.addEventListener(end, () => self.close(), { passive: true });
const showing = ref(true); el.addEventListener('click', self.close());
popup(defineAsyncComponent(() => import('@/components/ui/tooltip.vue')), { el.addEventListener('selectstart', ev => ev.preventDefault());
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();
});
}, },
updated(el, binding) { beforeUpdate(el, binding) {
const self = el._tooltipDirective_; (el._tooltipDirective_ as TooltipDirective).text = binding.value as string;
self.text = binding.value as string;
}, },
unmounted(el) { beforeUnmount(el) {
const self = el._tooltipDirective_; (el._tooltipDirective_ as TooltipDirective).close();
window.clearInterval(self.checkTimer);
}, },
} as Directive; } as Directive;

View file

@ -11,19 +11,25 @@
<div v-for="token in items" :key="token.id" class="_panel bfomjevm"> <div v-for="token in items" :key="token.id" class="_panel bfomjevm">
<img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/> <img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/>
<div class="body"> <div class="body">
<div class="name">{{ token.name }}</div> <button class="_button" @click="revoke(token)"><i class="fas fa-trash-alt"></i></button>
<div class="description">{{ token.description }}</div> <table>
<div class="_keyValue"> <tr>
<div>{{ i18n.ts.installedDate }}:</div> <th>{{ i18n.ts.name }}:</th>
<div><MkTime :time="token.createdAt"/></div> <td>{{ token.name }}</td>
</div> </tr>
<div class="_keyValue"> <tr>
<div>{{ i18n.ts.lastUsedDate }}:</div> <th>{{ i18n.ts.description }}:</th>
<div><MkTime :time="token.lastUsedAt"/></div> <td>{{ token.description }}</td>
</div> </tr>
<div class="actions"> <tr>
<button class="_button" @click="revoke(token)"><i class="fas fa-trash-alt"></i></button> <th>{{ i18n.ts.installedDate }}:</th>
</div> <td><MkTime :time="token.createdAt"/></td>
</tr>
<tr>
<th>{{ i18n.ts.lastUsedDate }}:</th>
<td><MkTime :time="token.lastUsedAt"/></td>
</tr>
</table>
<details> <details>
<summary>{{ i18n.ts.details }}</summary> <summary>{{ i18n.ts.details }}</summary>
<ul> <ul>
@ -82,11 +88,19 @@ definePageMetadata({
} }
> .body { > .body {
width: calc(100% - 62px); width: 100%;
position: relative; position: relative;
> .name { button {
font-weight: bold; position: absolute;
top: 0;
right: 0;
}
th {
text-align: right;
}
td {
text-align: left;
} }
} }
} }

View file

@ -390,14 +390,6 @@ hr {
} }
} }
._keyValue {
display: flex;
> * {
flex: 1;
}
}
._link { ._link {
color: var(--link); color: var(--link);
} }