forked from FoundKeyGang/FoundKey
Merge branch 'main' into mk.absturztau.be
This commit is contained in:
commit
55d8d5a626
82 changed files with 636 additions and 429 deletions
|
@ -1098,6 +1098,7 @@ _permissions:
|
|||
"write:notes": "Create and delete notes"
|
||||
"read:notifications": "Read notifications"
|
||||
"write:notifications": "Mark notifications as read and create custom notifications"
|
||||
"read:reactions": "View reactions"
|
||||
"write:reactions": "Create and delete reactions"
|
||||
"write:votes": "Vote in polls"
|
||||
"read:pages": "List and read pages"
|
||||
|
|
|
@ -35,7 +35,7 @@ export async function getHtml(url: string, accept = 'text/html, */*', timeout =
|
|||
return await res.text();
|
||||
}
|
||||
|
||||
export async function getResponse(args: { url: string, method: string, body?: string, headers: Record<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 controller = new AbortController();
|
||||
|
@ -47,8 +47,9 @@ export async function getResponse(args: { url: string, method: string, body?: st
|
|||
method: args.method,
|
||||
headers: args.headers,
|
||||
body: args.body,
|
||||
redirect: args.redirect,
|
||||
timeout,
|
||||
size: args.size || 10 * 1024 * 1024,
|
||||
size: args.size || 10 * 1024 * 1024, // 10 MiB
|
||||
agent: getAgentByUrl,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
|
10
packages/backend/src/misc/password.ts
Normal file
10
packages/backend/src/misc/password.ts
Normal 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);
|
||||
}
|
|
@ -6,11 +6,10 @@ import { Meta } from '@/models/entities/meta.js';
|
|||
* Returns whether a specific host (punycoded) should be blocked.
|
||||
*
|
||||
* @param host punycoded instance host
|
||||
* @param meta a Promise contatining the information from the meta table (optional)
|
||||
* @param meta a resolved Meta table
|
||||
* @returns whether the given host should be blocked
|
||||
*/
|
||||
|
||||
export async function shouldBlockInstance(host: Instance['host'], meta: Promise<Meta> = fetchMeta()): Promise<boolean> {
|
||||
const { blockedHosts } = await meta;
|
||||
export async function shouldBlockInstance(host: Instance['host'], meta?: Meta): Promise<boolean> {
|
||||
const { blockedHosts } = meta ?? await fetchMeta();
|
||||
return blockedHosts.some(blockedHost => host === blockedHost || host.endsWith('.' + blockedHost));
|
||||
}
|
||||
|
|
|
@ -15,8 +15,8 @@ const deadThreshold = 7 * DAY;
|
|||
* @returns array of punycoded instance hosts that should be skipped (subset of hosts parameter)
|
||||
*/
|
||||
export async function skippedInstances(hosts: Array<Instance['host']>): Promise<Array<Instance['host']>> {
|
||||
// Resolve the boolean promises before filtering
|
||||
const meta = fetchMeta();
|
||||
// first check for blocked instances since that info may already be in memory
|
||||
const meta = await fetchMeta();
|
||||
const shouldSkip = await Promise.all(hosts.map(host => shouldBlockInstance(host, meta)));
|
||||
const skipped = hosts.filter((_, i) => shouldSkip[i]);
|
||||
|
||||
|
|
|
@ -61,50 +61,24 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({
|
|||
return thumbnail ? (file.thumbnailUrl || (isImage ? (file.webpublicUrl || file.url) : null)) : (file.webpublicUrl || file.url);
|
||||
},
|
||||
|
||||
async calcDriveUsageOf(user: User['id'] | { id: User['id'] }): Promise<number> {
|
||||
const id = typeof user === 'object' ? user.id : user;
|
||||
|
||||
const { sum } = await this
|
||||
.createQueryBuilder('file')
|
||||
.where('file.userId = :id', { id })
|
||||
.andWhere('file.isLink = FALSE')
|
||||
.select('SUM(file.size)', 'sum')
|
||||
.getRawOne();
|
||||
|
||||
return parseInt(sum, 10) || 0;
|
||||
calcDriveUsageOf(id: User['id']): Promise<number> {
|
||||
return db.query('SELECT SUM(size) AS sum FROM drive_file WHERE "userId" = $1 AND NOT "isLink"', [id])
|
||||
.then(res => res[0].sum as number ?? 0);
|
||||
},
|
||||
|
||||
async calcDriveUsageOfHost(host: string): Promise<number> {
|
||||
const { sum } = await this
|
||||
.createQueryBuilder('file')
|
||||
.where('file.userHost = :host', { host: toPuny(host) })
|
||||
.andWhere('file.isLink = FALSE')
|
||||
.select('SUM(file.size)', 'sum')
|
||||
.getRawOne();
|
||||
|
||||
return parseInt(sum, 10) || 0;
|
||||
calcDriveUsageOfHost(host: string): Promise<number> {
|
||||
return db.query('SELECT SUM(size) AS sum FROM drive_file WHERE "userHost" = $1 AND NOT "isLink"', [toPuny(host)])
|
||||
.then(res => res[0].sum as number ?? 0);
|
||||
},
|
||||
|
||||
async calcDriveUsageOfLocal(): Promise<number> {
|
||||
const { sum } = await this
|
||||
.createQueryBuilder('file')
|
||||
.where('file.userHost IS NULL')
|
||||
.andWhere('file.isLink = FALSE')
|
||||
.select('SUM(file.size)', 'sum')
|
||||
.getRawOne();
|
||||
|
||||
return parseInt(sum, 10) || 0;
|
||||
calcDriveUsageOfLocal(): Promise<number> {
|
||||
return db.query('SELECT SUM(size) AS sum FROM drive_file WHERE "userHost" IS NULL AND NOT "isLink"')
|
||||
.then(res => res[0].sum as number ?? 0);
|
||||
},
|
||||
|
||||
async calcDriveUsageOfRemote(): Promise<number> {
|
||||
const { sum } = await this
|
||||
.createQueryBuilder('file')
|
||||
.where('file.userHost IS NOT NULL')
|
||||
.andWhere('file.isLink = FALSE')
|
||||
.select('SUM(file.size)', 'sum')
|
||||
.getRawOne();
|
||||
|
||||
return parseInt(sum, 10) || 0;
|
||||
calcDriveUsageOfRemote(): Promise<number> {
|
||||
return db.query('SELECT SUM(size) AS sum FROM drive_file WHERE "userHost" IS NOT NULL AND NOT "isLink"')
|
||||
.then(res => res[0].sum as number ?? 0);
|
||||
},
|
||||
|
||||
async pack(
|
||||
|
@ -152,26 +126,7 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({
|
|||
const file = typeof src === 'object' ? src : await this.findOneBy({ id: src });
|
||||
if (file == null) return null;
|
||||
|
||||
return await awaitAll<Packed<'DriveFile'>>({
|
||||
id: file.id,
|
||||
createdAt: file.createdAt.toISOString(),
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
md5: file.md5,
|
||||
size: file.size,
|
||||
isSensitive: file.isSensitive,
|
||||
blurhash: file.blurhash,
|
||||
properties: opts.self ? file.properties : this.getPublicProperties(file),
|
||||
url: opts.self ? file.url : this.getPublicUrl(file, false),
|
||||
thumbnailUrl: this.getPublicUrl(file, true),
|
||||
comment: file.comment,
|
||||
folderId: file.folderId,
|
||||
folder: opts.detail && file.folderId ? DriveFolders.pack(file.folderId, {
|
||||
detail: true,
|
||||
}) : null,
|
||||
userId: opts.withUser ? file.userId : null,
|
||||
user: (opts.withUser && file.userId) ? Users.pack(file.userId) : null,
|
||||
});
|
||||
return await this.pack(file);
|
||||
},
|
||||
|
||||
async packMany(
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import Bull from 'bull';
|
||||
import { In, LessThan } from 'typeorm';
|
||||
import { AttestationChallenges, AuthSessions, Mutings, PasswordResetRequests, Signins } from '@/models/index.js';
|
||||
import { AttestationChallenges, AuthSessions, Mutings, Notifications, PasswordResetRequests, Signins } from '@/models/index.js';
|
||||
import { publishUserEvent } from '@/services/stream.js';
|
||||
import { MINUTE, DAY } from '@/const.js';
|
||||
import { MINUTE, DAY, MONTH } from '@/const.js';
|
||||
import { queueLogger } from '@/queue/logger.js';
|
||||
|
||||
const logger = queueLogger.createSubLogger('check-expired');
|
||||
|
@ -26,22 +26,30 @@ export async function checkExpired(job: Bull.Job<Record<string, unknown>>, done:
|
|||
}
|
||||
}
|
||||
|
||||
const OlderThan = (millis: number) => {
|
||||
return LessThan(new Date(new Date().getTime() - millis));
|
||||
};
|
||||
|
||||
await Signins.delete({
|
||||
// 60 days, or roughly equal to two months
|
||||
createdAt: LessThan(new Date(new Date().getTime() - 60 * DAY)),
|
||||
createdAt: OlderThan(2 * MONTH),
|
||||
});
|
||||
|
||||
await AttestationChallenges.delete({
|
||||
createdAt: LessThan(new Date(new Date().getTime() - 5 * MINUTE)),
|
||||
createdAt: OlderThan(5 * MINUTE),
|
||||
});
|
||||
|
||||
await PasswordResetRequests.delete({
|
||||
// this timing should be the same as in @/server/api/endpoints/reset-password.ts
|
||||
createdAt: LessThan(new Date(new Date().getTime() - 30 * MINUTE)),
|
||||
createdAt: OlderThan(30 * MINUTE),
|
||||
});
|
||||
|
||||
await AuthSessions.delete({
|
||||
createdAt: LessThan(new Date(new Date().getTime() - 15 * MINUTE)),
|
||||
createdAt: OlderThan(15 * MINUTE),
|
||||
});
|
||||
|
||||
await Notifications.delete({
|
||||
isRead: true,
|
||||
createdAt: OlderThan(3 * MONTH),
|
||||
});
|
||||
|
||||
logger.succ('Deleted expired data.');
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { CacheableRemoteUser } from '@/models/entities/user.js';
|
||||
import accept from '@/services/following/requests/accept.js';
|
||||
import { acceptFollowRequest } from '@/services/following/requests/accept.js';
|
||||
import { relayAccepted } from '@/services/relay.js';
|
||||
import { IFollow } from '@/remote/activitypub/type.js';
|
||||
import { DbResolver } from '@/remote/activitypub/db-resolver.js';
|
||||
|
@ -24,6 +24,6 @@ export default async (actor: CacheableRemoteUser, activity: IFollow): Promise<st
|
|||
return await relayAccepted(match[1]);
|
||||
}
|
||||
|
||||
await accept(actor, follower);
|
||||
await acceptFollowRequest(actor, follower);
|
||||
return 'ok';
|
||||
};
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { CacheableRemoteUser } from '@/models/entities/user.js';
|
||||
import { toArray } from '@/prelude/array.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 { 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 performDeleteActivity from './delete/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 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)) {
|
||||
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) {
|
||||
const act = await resolver.resolve(item);
|
||||
|
@ -38,6 +40,11 @@ export async function performActivity(actor: CacheableRemoteUser, activity: IObj
|
|||
async function performOneActivity(actor: CacheableRemoteUser, activity: IObject, resolver: Resolver): Promise<void> {
|
||||
if (actor.isSuspended) return;
|
||||
|
||||
if (typeof activity.id !== 'undefined') {
|
||||
const host = extractDbHost(getApId(activity));
|
||||
if (await shouldBlockInstance(host)) return;
|
||||
}
|
||||
|
||||
if (isCreate(activity)) {
|
||||
await create(actor, activity, resolver);
|
||||
} else if (isDelete(activity)) {
|
||||
|
@ -55,7 +62,7 @@ async function performOneActivity(actor: CacheableRemoteUser, activity: IObject,
|
|||
} else if (isAdd(activity)) {
|
||||
await add(actor, activity, resolver).catch(err => apLogger.error(err));
|
||||
} else if (isRemove(activity)) {
|
||||
await remove(actor, activity).catch(err => apLogger.error(err));
|
||||
await remove(actor, activity, resolver).catch(err => apLogger.error(err));
|
||||
} else if (isAnnounce(activity)) {
|
||||
await announce(actor, activity, resolver);
|
||||
} else if (isLike(activity)) {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import unfollow from '@/services/following/delete.js';
|
||||
import cancelRequest from '@/services/following/requests/cancel.js';
|
||||
import { cancelFollowRequest } from '@/services/following/requests/cancel.js';
|
||||
import { CacheableRemoteUser } from '@/models/entities/user.js';
|
||||
import { FollowRequests, Followings } from '@/models/index.js';
|
||||
import { IFollow } from '@/remote/activitypub/type.js';
|
||||
|
@ -29,7 +29,7 @@ export default async (actor: CacheableRemoteUser, activity: IFollow): Promise<st
|
|||
]);
|
||||
|
||||
if (req) {
|
||||
await cancelRequest(followee, actor);
|
||||
await cancelFollowRequest(followee, actor);
|
||||
return 'ok: follow request canceled';
|
||||
} else if (following) {
|
||||
await unfollow(actor, followee);
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { URL } from 'node:url';
|
||||
import config from '@/config/index.js';
|
||||
import { getUserKeypair } from '@/misc/keypair-store.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,
|
||||
headers: req.request.headers,
|
||||
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> {
|
||||
const keypair = await getUserKeypair(user.id);
|
||||
|
||||
const req = createSignedGet({
|
||||
key: {
|
||||
privateKeyPem: keypair.privateKey,
|
||||
keyId: `${config.url}/users/${user.id}#main-key`,
|
||||
},
|
||||
url,
|
||||
additionalHeaders: {
|
||||
'User-Agent': config.userAgent,
|
||||
},
|
||||
});
|
||||
for (let redirects = 0; redirects < 3; redirects++) {
|
||||
const req = createSignedGet({
|
||||
key: {
|
||||
privateKeyPem: keypair.privateKey,
|
||||
keyId: `${config.url}/users/${user.id}#main-key`,
|
||||
},
|
||||
url,
|
||||
additionalHeaders: {
|
||||
'User-Agent': config.userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await getResponse({
|
||||
url,
|
||||
method: req.request.method,
|
||||
headers: req.request.headers,
|
||||
});
|
||||
const res = await getResponse({
|
||||
url,
|
||||
method: req.request.method,
|
||||
headers: req.request.headers,
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
return await res.json();
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
// Have been redirected, need to make a new signature.
|
||||
// Use Location header and fetched URL as the base URL.
|
||||
url = new URL(res.headers.get('Location'), url).href;
|
||||
} else {
|
||||
return await res.json();
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('too many redirects');
|
||||
}
|
||||
|
|
|
@ -34,9 +34,7 @@ export class Resolver {
|
|||
}
|
||||
|
||||
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
|
||||
const collection = typeof value === 'string'
|
||||
? await this.resolve(value)
|
||||
: value;
|
||||
const collection = await this.resolve(value);
|
||||
|
||||
if (isCollectionOrOrderedCollection(collection)) {
|
||||
return collection;
|
||||
|
@ -45,12 +43,18 @@ export class Resolver {
|
|||
}
|
||||
}
|
||||
|
||||
public async resolve(value: string | IObject): Promise<IObject> {
|
||||
public async resolve(value?: string | IObject | null, allowRedirect = false): Promise<IObject> {
|
||||
if (value == null) {
|
||||
throw new Error('resolvee is null (or undefined)');
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
if (typeof value.id !== 'undefined') {
|
||||
const host = extractDbHost(getApId(value));
|
||||
if (await shouldBlockInstance(host)) {
|
||||
throw new Error('instance is blocked');
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
|
@ -75,7 +79,7 @@ export class Resolver {
|
|||
}
|
||||
|
||||
if (await shouldBlockInstance(host)) {
|
||||
throw new Error('Instance is blocked');
|
||||
throw new Error('instance is blocked');
|
||||
}
|
||||
|
||||
if (!this.user) {
|
||||
|
@ -94,7 +98,7 @@ export class Resolver {
|
|||
)
|
||||
// Did we actually get the object that corresponds to the canonical URL?
|
||||
// Does the host we requested stuff from actually correspond to the host that owns the activity?
|
||||
|| !(getApId(object) == null || getApId(object) === value)
|
||||
|| !(getApId(object) == null || getApId(object) === value || allowRedirect)
|
||||
) {
|
||||
throw new Error('invalid response');
|
||||
}
|
||||
|
|
|
@ -5,28 +5,24 @@ import authenticate, { AuthenticationError } from './authenticate.js';
|
|||
import call from './call.js';
|
||||
import { ApiError } from './error.js';
|
||||
|
||||
export async function handler(endpoint: IEndpoint, ctx: Koa.Context): Promise<void> {
|
||||
const body = ctx.is('multipart/form-data')
|
||||
? (ctx.request as any).body
|
||||
: ctx.method === 'GET'
|
||||
? ctx.query
|
||||
: ctx.request.body;
|
||||
|
||||
const error = (e: ApiError): void => {
|
||||
ctx.status = e.httpStatusCode;
|
||||
if (e.httpStatusCode === 401) {
|
||||
ctx.response.set('WWW-Authenticate', 'Bearer');
|
||||
}
|
||||
ctx.body = {
|
||||
error: {
|
||||
message: e!.message,
|
||||
code: e!.code,
|
||||
...(e!.info ? { info: e!.info } : {}),
|
||||
endpoint: endpoint.name,
|
||||
},
|
||||
};
|
||||
function getRequestArguments(ctx: Koa.Context): Record<string, any> {
|
||||
const args = {
|
||||
...(ctx.params || {}),
|
||||
...ctx.query,
|
||||
...(ctx.request.body || {}),
|
||||
};
|
||||
|
||||
// For security reasons, we drop the i parameter if it's a GET request
|
||||
if (ctx.method === 'GET') {
|
||||
delete args['i'];
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export async function handler(endpoint: IEndpoint, ctx: Koa.Context): Promise<void> {
|
||||
const body = getRequestArguments(ctx);
|
||||
|
||||
// Authentication
|
||||
// for GET requests, do not even pass on the body parameter as it is considered unsafe
|
||||
await authenticate(ctx.headers.authorization, ctx.method === 'GET' ? null : body['i']).then(async ([user, app]) => {
|
||||
|
@ -43,13 +39,13 @@ export async function handler(endpoint: IEndpoint, ctx: Koa.Context): Promise<vo
|
|||
ctx.body = typeof res === 'string' ? JSON.stringify(res) : res;
|
||||
}
|
||||
}).catch((e: ApiError) => {
|
||||
error(e);
|
||||
e.apply(ctx, endpoint.name);
|
||||
});
|
||||
}).catch(e => {
|
||||
if (e instanceof AuthenticationError) {
|
||||
error(new ApiError('AUTHENTICATION_FAILED', e.message));
|
||||
new ApiError('AUTHENTICATION_FAILED', e.message).apply(ctx, endpoint.name);
|
||||
} else {
|
||||
error(new ApiError());
|
||||
new ApiError().apply(ctx, endpoint.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { generateKeyPair } from 'node:crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { User } from '@/models/entities/user.js';
|
||||
import { Users, UsedUsernames } from '@/models/index.js';
|
||||
import { UserProfile } from '@/models/entities/user-profile.js';
|
||||
import { genId } from '@/misc/gen-id.js';
|
||||
import { toPunyNullable } from '@/misc/convert-host.js';
|
||||
import { hashPassword } from '@/misc/password.js';
|
||||
import { UserKeypair } from '@/models/entities/user-keypair.js';
|
||||
import { usersChart } from '@/services/chart/index.js';
|
||||
import { UsedUsername } from '@/models/entities/used-username.js';
|
||||
|
@ -33,9 +33,7 @@ export async function signup(opts: {
|
|||
throw new ApiError('INVALID_PASSWORD');
|
||||
}
|
||||
|
||||
// Generate hash of password
|
||||
const salt = await bcrypt.genSalt(8);
|
||||
hash = await bcrypt.hash(password, salt);
|
||||
hash = await hashPassword(password);
|
||||
}
|
||||
|
||||
// Generate secret
|
||||
|
|
|
@ -702,6 +702,24 @@ export interface IEndpointMeta {
|
|||
* 正常応答をキャッシュ (Cache-Control: public) する秒数
|
||||
*/
|
||||
readonly cacheSec?: number;
|
||||
|
||||
/**
|
||||
* API v2 options
|
||||
*/
|
||||
readonly v2?: {
|
||||
|
||||
/**
|
||||
* HTTP verb this endpoint supports
|
||||
*/
|
||||
readonly method: 'get' | 'put' | 'post' | 'patch' | 'delete';
|
||||
|
||||
/**
|
||||
* Path alias for v2 endpoint
|
||||
*
|
||||
* @example (v0) /api/notes/create -> /api/v2/notes
|
||||
*/
|
||||
readonly alias?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IEndpoint {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { IsNull } from 'typeorm';
|
||||
import { Users } from '@/models/index.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import define from '../../../define.js';
|
||||
import { signup } from '../../../common/signup.js';
|
||||
|
||||
|
@ -17,6 +18,8 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
},
|
||||
|
||||
errors: ['ACCESS_DENIED'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -31,10 +34,17 @@ export const paramDef = {
|
|||
// eslint-disable-next-line import/no-default-export
|
||||
export default define(meta, paramDef, async (ps, _me) => {
|
||||
const me = _me ? await Users.findOneByOrFail({ id: _me.id }) : null;
|
||||
const noUsers = (await Users.countBy({
|
||||
host: IsNull(),
|
||||
})) === 0;
|
||||
if (!noUsers && !me?.isAdmin) throw new Error('access denied');
|
||||
if (me == null) {
|
||||
// check if this is the initial setup
|
||||
const noUsers = (await Users.countBy({
|
||||
host: IsNull(),
|
||||
})) === 0;
|
||||
if (!noUsers) {
|
||||
throw new ApiError('ACCESS_DENIED');
|
||||
}
|
||||
} else if (!me.isAdmin) {
|
||||
throw new ApiError('ACCESS_DENIED');
|
||||
}
|
||||
|
||||
const { account, secret } = await signup({
|
||||
username: ps.username,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Users } from '@/models/index.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { doPostSuspend } from '@/services/suspend-user.js';
|
||||
import { publishUserEvent } from '@/services/stream.js';
|
||||
import { createDeleteAccountJob } from '@/queue/index.js';
|
||||
|
@ -9,6 +10,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
|
||||
errors: ['NO_SUCH_USER', 'IS_ADMIN', 'IS_MODERATOR'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -24,15 +27,11 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
const user = await Users.findOneBy({ id: ps.userId });
|
||||
|
||||
if (user == null) {
|
||||
throw new Error('user not found');
|
||||
}
|
||||
|
||||
if (user.isAdmin) {
|
||||
throw new Error('cannot suspend admin');
|
||||
}
|
||||
|
||||
if (user.isModerator) {
|
||||
throw new Error('cannot suspend moderator');
|
||||
throw new ApiError('NO_SUCH_USER');
|
||||
} else if (user.isAdmin) {
|
||||
throw new ApiError('IS_ADMIN');
|
||||
} else if(user.isModerator) {
|
||||
throw new ApiError('IS_MODERATOR');
|
||||
}
|
||||
|
||||
if (Users.isLocalUser(user)) {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Instances } from '@/models/index.js';
|
||||
import { toPuny } from '@/misc/convert-host.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js';
|
||||
import define from '../../../define.js';
|
||||
|
||||
|
@ -8,6 +9,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
|
||||
errors: ['NO_SUCH_OBJECT'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -23,7 +26,7 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
const instance = await Instances.findOneBy({ host: toPuny(ps.host) });
|
||||
|
||||
if (instance == null) {
|
||||
throw new Error('instance not found');
|
||||
throw new ApiError('NO_SUCH_OBJECT');
|
||||
}
|
||||
|
||||
fetchInstanceMetadata(instance, true);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Instances } from '@/models/index.js';
|
||||
import { toPuny } from '@/misc/convert-host.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import define from '../../../define.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -7,6 +8,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
|
||||
errors: ['NO_SUCH_OBJECT'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -23,7 +26,7 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
const instance = await Instances.findOneBy({ host: toPuny(ps.host) });
|
||||
|
||||
if (instance == null) {
|
||||
throw new Error('instance not found');
|
||||
throw new ApiError('NO_SUCH_OBJECT');
|
||||
}
|
||||
|
||||
Instances.update({ host: toPuny(ps.host) }, {
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
import { Users } from '@/models/index.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { publishInternalEvent } from '@/services/stream.js';
|
||||
import define from '../../../define.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
||||
description: 'Grants a user moderator privileges. Administrators cannot be granted moderator privileges.',
|
||||
|
||||
requireCredential: true,
|
||||
requireAdmin: true,
|
||||
|
||||
errors: ['NO_SUCH_USER', 'IS_ADMIN'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -22,11 +27,11 @@ export default define(meta, paramDef, async (ps) => {
|
|||
const user = await Users.findOneBy({ id: ps.userId });
|
||||
|
||||
if (user == null) {
|
||||
throw new Error('user not found');
|
||||
throw new ApiError('NO_SUCH_USER');
|
||||
}
|
||||
|
||||
if (user.isAdmin) {
|
||||
throw new Error('cannot mark as moderator if admin user');
|
||||
throw new ApiError('IS_ADMIN');
|
||||
}
|
||||
|
||||
await Users.update(user.id, {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Users } from '@/models/index.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { publishInternalEvent } from '@/services/stream.js';
|
||||
import define from '../../../define.js';
|
||||
|
||||
|
@ -7,6 +8,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
requireAdmin: true,
|
||||
|
||||
errors: ['NO_SUCH_USER'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -22,7 +25,7 @@ export default define(meta, paramDef, async (ps) => {
|
|||
const user = await Users.findOneBy({ id: ps.userId });
|
||||
|
||||
if (user == null) {
|
||||
throw new Error('user not found');
|
||||
throw new ApiError('NO_SUCH_USER');
|
||||
}
|
||||
|
||||
await Users.update(user.id, {
|
||||
|
|
|
@ -51,7 +51,7 @@ export const paramDef = {
|
|||
// eslint-disable-next-line import/no-default-export
|
||||
export default define(meta, paramDef, async (ps, user) => {
|
||||
try {
|
||||
if (new URL(ps.inbox).protocol !== 'https:') throw new Error('https only');
|
||||
if (new URL(ps.inbox).protocol !== 'https:') throw new ApiError('INVALID_URL', 'https only');
|
||||
} catch (e) {
|
||||
throw new ApiError('INVALID_URL', e);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import bcrypt from 'bcryptjs';
|
||||
import { hashPassword } from '@/misc/password.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import { Users, UserProfiles } from '@/models/index.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import define from '../../define.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -16,11 +17,11 @@ export const meta = {
|
|||
password: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
minLength: 8,
|
||||
maxLength: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_USER', 'IS_ADMIN'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -36,25 +37,22 @@ export default define(meta, paramDef, async (ps) => {
|
|||
const user = await Users.findOneBy({ id: ps.userId });
|
||||
|
||||
if (user == null) {
|
||||
throw new Error('user not found');
|
||||
throw new ApiError('NO_SUCH_USER');
|
||||
}
|
||||
|
||||
if (user.isAdmin) {
|
||||
throw new Error('cannot reset password of admin');
|
||||
throw new ApiError('IS_ADMIN');
|
||||
}
|
||||
|
||||
const passwd = secureRndstr(8, true);
|
||||
|
||||
// Generate hash of password
|
||||
const hash = bcrypt.hashSync(passwd);
|
||||
const password = secureRndstr(8, true);
|
||||
|
||||
await UserProfiles.update({
|
||||
userId: user.id,
|
||||
}, {
|
||||
password: hash,
|
||||
password: await hashPassword(password),
|
||||
});
|
||||
|
||||
return {
|
||||
password: passwd,
|
||||
password,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Signins, UserProfiles, Users } from '@/models/index.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import define from '../../define.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -11,6 +12,8 @@ export const meta = {
|
|||
type: 'object',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_USER', 'IS_ADMIN'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -29,12 +32,12 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
]);
|
||||
|
||||
if (user == null || profile == null) {
|
||||
throw new Error('user not found');
|
||||
throw new ApiError('NO_SUCH_USER');
|
||||
}
|
||||
|
||||
const _me = await Users.findOneByOrFail({ id: me.id });
|
||||
if ((_me.isModerator && !_me.isAdmin) && user.isAdmin) {
|
||||
throw new Error('cannot show info of admin');
|
||||
throw new ApiError('IS_ADMIN');
|
||||
}
|
||||
|
||||
if (!_me.isAdmin) {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Users } from '@/models/index.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { insertModerationLog } from '@/services/insert-moderation-log.js';
|
||||
import { publishInternalEvent } from '@/services/stream.js';
|
||||
import define from '../../define.js';
|
||||
|
@ -8,6 +9,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
|
||||
errors: ['NO_SUCH_USER', 'IS_ADMIN'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -23,11 +26,11 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
const user = await Users.findOneBy({ id: ps.userId });
|
||||
|
||||
if (user == null) {
|
||||
throw new Error('user not found');
|
||||
throw new ApiError('NO_SUCH_USER');
|
||||
}
|
||||
|
||||
if (user.isAdmin) {
|
||||
throw new Error('cannot silence admin');
|
||||
throw new ApiError('IS_ADMIN');
|
||||
}
|
||||
|
||||
await Users.update(user.id, {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import deleteFollowing from '@/services/following/delete.js';
|
||||
import { Users, Followings, Notifications } from '@/models/index.js';
|
||||
import { User } from '@/models/entities/user.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { insertModerationLog } from '@/services/insert-moderation-log.js';
|
||||
import { doPostSuspend } from '@/services/suspend-user.js';
|
||||
import { publishUserEvent } from '@/services/stream.js';
|
||||
|
@ -11,6 +12,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
|
||||
errors: ['NO_SUCH_USER', 'IS_ADMIN', 'IS_MODERATOR'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -26,15 +29,11 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
const user = await Users.findOneBy({ id: ps.userId });
|
||||
|
||||
if (user == null) {
|
||||
throw new Error('user not found');
|
||||
}
|
||||
|
||||
if (user.isAdmin) {
|
||||
throw new Error('cannot suspend admin');
|
||||
}
|
||||
|
||||
if (user.isModerator) {
|
||||
throw new Error('cannot suspend moderator');
|
||||
throw new ApiError('NO_SUCH_USER');
|
||||
} else if (user.isAdmin) {
|
||||
throw new ApiError('IS_ADMIN');
|
||||
} else if (user.isModerator) {
|
||||
throw new ApiError('IS_MODERATOR');
|
||||
}
|
||||
|
||||
await Users.update(user.id, {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Users } from '@/models/index.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { insertModerationLog } from '@/services/insert-moderation-log.js';
|
||||
import { publishInternalEvent } from '@/services/stream.js';
|
||||
import define from '../../define.js';
|
||||
|
@ -8,6 +9,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
|
||||
errors: ['NO_SUCH_USER'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -23,7 +26,7 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
const user = await Users.findOneBy({ id: ps.userId });
|
||||
|
||||
if (user == null) {
|
||||
throw new Error('user not found');
|
||||
throw new ApiError('NO_SUCH_USER');
|
||||
}
|
||||
|
||||
await Users.update(user.id, {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Users } from '@/models/index.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { insertModerationLog } from '@/services/insert-moderation-log.js';
|
||||
import { doPostUnsuspend } from '@/services/unsuspend-user.js';
|
||||
import define from '../../define.js';
|
||||
|
@ -8,6 +9,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
|
||||
errors: ['NO_SUCH_USER'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -23,7 +26,7 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
const user = await Users.findOneBy({ id: ps.userId });
|
||||
|
||||
if (user == null) {
|
||||
throw new Error('user not found');
|
||||
throw new ApiError('NO_SUCH_USER');
|
||||
}
|
||||
|
||||
await Users.update(user.id, {
|
||||
|
|
|
@ -29,6 +29,6 @@ export const paramDef = {
|
|||
// eslint-disable-next-line import/no-default-export
|
||||
export default define(meta, paramDef, async (ps) => {
|
||||
const resolver = new Resolver();
|
||||
const object = await resolver.resolve(ps.uri);
|
||||
const object = await resolver.resolve(ps.uri, true);
|
||||
return object;
|
||||
});
|
||||
|
|
|
@ -98,9 +98,10 @@ async function fetchAny(uri: string, me: CacheableLocalUser | null | undefined):
|
|||
]));
|
||||
if (local != null) return local;
|
||||
|
||||
// リモートから一旦オブジェクトフェッチ
|
||||
// fetch object from remote
|
||||
const resolver = new Resolver();
|
||||
const object = await resolver.resolve(uri) as any;
|
||||
// allow redirect
|
||||
const object = await resolver.resolve(uri, true) as any;
|
||||
|
||||
// /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する
|
||||
// これはDBに存在する可能性があるため再度DB検索
|
||||
|
|
|
@ -35,7 +35,6 @@ export const paramDef = {
|
|||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const instance = await fetchMeta(true);
|
||||
|
||||
// Calculate drive usage
|
||||
const usage = await DriveFiles.calcDriveUsageOf(user.id);
|
||||
|
||||
return {
|
||||
|
|
|
@ -45,7 +45,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
|
||||
if (ps.type) {
|
||||
if (ps.type.endsWith('/*')) {
|
||||
query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' });
|
||||
query.andWhere('file.type like :type', { type: ps.type.slice(0, -1) + '%' });
|
||||
} else {
|
||||
query.andWhere('file.type = :type', { type: ps.type });
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import acceptFollowRequest from '@/services/following/requests/accept.js';
|
||||
import { acceptFollowRequest } from '@/services/following/requests/accept.js';
|
||||
import define from '../../../define.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
import { getUser } from '../../../common/getters.js';
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import cancelFollowRequest from '@/services/following/requests/cancel.js';
|
||||
import { cancelFollowRequest } from '@/services/following/requests/cancel.js';
|
||||
import { Users } from '@/models/index.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import define from '../../../define.js';
|
||||
|
|
|
@ -3,6 +3,7 @@ import { genId } from '@/misc/gen-id.js';
|
|||
import { GalleryPost } from '@/models/entities/gallery-post.js';
|
||||
import { DriveFile } from '@/models/entities/drive-file.js';
|
||||
import { HOUR } from '@/const.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import define from '../../../define.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -46,8 +47,14 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
}),
|
||||
))).filter((file): file is DriveFile => file != null);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error();
|
||||
if (files.length !== ps.fileIds.length) {
|
||||
throw new ApiError(
|
||||
'INVALID_PARAM',
|
||||
{
|
||||
param: '#/properties/fileIds/items',
|
||||
reason: 'contains invalid file IDs',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const post = await GalleryPosts.insert(new GalleryPost({
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { DriveFiles, GalleryPosts } from '@/models/index.js';
|
||||
import { DriveFile } from '@/models/entities/drive-file.js';
|
||||
import { HOUR } from '@/const.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import define from '../../../define.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -20,6 +21,8 @@ export const meta = {
|
|||
optional: false, nullable: false,
|
||||
ref: 'GalleryPost',
|
||||
},
|
||||
|
||||
errors: ['INVALID_PARAM'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -45,8 +48,14 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
}),
|
||||
))).filter((file): file is DriveFile => file != null);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error();
|
||||
if (files.length !== ps.fileIds.length) {
|
||||
throw new ApiError(
|
||||
'INVALID_PARAM',
|
||||
{
|
||||
param: '#/properties/fileIds/items',
|
||||
reason: 'contains invalid file IDs',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await GalleryPosts.update({
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
import * as speakeasy from 'speakeasy';
|
||||
import { UserProfiles } from '@/models/index.js';
|
||||
import define from '../../../define.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: ['INTERNAL_ERROR', 'ACCESS_DENIED'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -23,7 +26,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
|
||||
|
||||
if (profile.twoFactorTempSecret == null) {
|
||||
throw new Error('二段階認証の設定が開始されていません');
|
||||
throw new ApiError('INTERNAL_ERROR', 'Two-step verification has not been initiated.');
|
||||
}
|
||||
|
||||
const verified = (speakeasy as any).totp.verify({
|
||||
|
@ -33,7 +36,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
});
|
||||
|
||||
if (!verified) {
|
||||
throw new Error('not verified');
|
||||
throw new ApiError('ACCESS_DENIED', 'TOTP missmatch');
|
||||
}
|
||||
|
||||
await UserProfiles.update(user.id, {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { promisify } from 'node:util';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import * as cbor from 'cbor';
|
||||
import { MINUTE } from '@/const.js';
|
||||
import { comparePassword } from '@/misc/password.js';
|
||||
import {
|
||||
UserProfiles,
|
||||
UserSecurityKeys,
|
||||
|
@ -9,6 +9,7 @@ import {
|
|||
Users,
|
||||
} from '@/models/index.js';
|
||||
import config from '@/config/index.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { publishMainStream } from '@/services/stream.js';
|
||||
import define from '../../../define.js';
|
||||
import { procedures, hash } from '../../../2fa.js';
|
||||
|
@ -20,6 +21,8 @@ export const meta = {
|
|||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: ['ACCESS_DENIED', 'INTERNAL_ERROR', 'NO_SUCH_OBJECT'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -38,24 +41,21 @@ export const paramDef = {
|
|||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
||||
|
||||
if (!same) {
|
||||
throw new Error('incorrect password');
|
||||
if (!(await comparePassword(ps.password, profile.password!))) {
|
||||
throw new ApiError('ACCESS_DENIED');
|
||||
}
|
||||
|
||||
if (!profile.twoFactorEnabled) {
|
||||
throw new Error('2fa not enabled');
|
||||
throw new ApiError('INTERNAL_ERROR', '2fa not enabled');
|
||||
}
|
||||
|
||||
const clientData = JSON.parse(ps.clientDataJSON);
|
||||
|
||||
if (clientData.type !== 'webauthn.create') {
|
||||
throw new Error('not a creation attestation');
|
||||
throw new ApiError('INTERNAL_ERROR', 'not a creation attestation');
|
||||
}
|
||||
if (clientData.origin !== config.scheme + '://' + config.host) {
|
||||
throw new Error('origin mismatch');
|
||||
throw new ApiError('INTERNAL_ERROR', 'origin mismatch');
|
||||
}
|
||||
|
||||
const clientDataJSONHash = hash(Buffer.from(ps.clientDataJSON, 'utf-8'));
|
||||
|
@ -64,14 +64,14 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
|
||||
const rpIdHash = attestation.authData.slice(0, 32);
|
||||
if (!rpIdHashReal.equals(rpIdHash)) {
|
||||
throw new Error('rpIdHash mismatch');
|
||||
throw new ApiError('INTERNAL_ERROR', 'rpIdHash mismatch');
|
||||
}
|
||||
|
||||
const flags = attestation.authData[32];
|
||||
|
||||
// eslint:disable-next-line:no-bitwise
|
||||
if (!(flags & 1)) {
|
||||
throw new Error('user not present');
|
||||
throw new ApiError('INTERNAL_ERROR', 'user not present');
|
||||
}
|
||||
|
||||
const authData = Buffer.from(attestation.authData);
|
||||
|
@ -80,11 +80,11 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
const publicKeyData = authData.slice(55 + credentialIdLength);
|
||||
const publicKey: Map<number, any> = await cborDecodeFirst(publicKeyData);
|
||||
if (publicKey.get(3) !== -7) {
|
||||
throw new Error('alg mismatch');
|
||||
throw new ApiError('INTERNAL_ERROR', 'algorithm mismatch');
|
||||
}
|
||||
|
||||
if (!(procedures as any)[attestation.fmt]) {
|
||||
throw new Error('unsupported fmt');
|
||||
throw new ApiError('INTERNAL_ERROR', 'unsupported fmt');
|
||||
}
|
||||
|
||||
const verificationData = (procedures as any)[attestation.fmt].verify({
|
||||
|
@ -95,7 +95,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
publicKey,
|
||||
rpIdHash,
|
||||
});
|
||||
if (!verificationData.valid) throw new Error('signature invalid');
|
||||
if (!verificationData.valid) throw new ApiError('INTERNAL_ERROR', 'signature invalid');
|
||||
|
||||
const attestationChallenge = await AttestationChallenges.findOneBy({
|
||||
userId: user.id,
|
||||
|
@ -105,7 +105,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
});
|
||||
|
||||
if (!attestationChallenge) {
|
||||
throw new Error('non-existent challenge');
|
||||
throw new ApiError('NO_SUCH_OBJECT', 'Attestation challenge not found.');
|
||||
}
|
||||
|
||||
await AttestationChallenges.delete({
|
||||
|
@ -118,7 +118,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
new Date().getTime() - attestationChallenge.createdAt.getTime() >=
|
||||
5 * MINUTE
|
||||
) {
|
||||
throw new Error('expired challenge');
|
||||
throw new ApiError('NO_SUCH_OBJECT', 'Attestation challenge expired.');
|
||||
}
|
||||
|
||||
const credentialIdString = credentialId.toString('hex');
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { promisify } from 'node:util';
|
||||
import * as crypto from 'node:crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { UserProfiles, AttestationChallenges } from '@/models/index.js';
|
||||
import { genId } from '@/misc/gen-id.js';
|
||||
import { comparePassword } from '@/misc/password.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import define from '../../../define.js';
|
||||
import { hash } from '../../../2fa.js';
|
||||
|
||||
|
@ -12,6 +13,8 @@ export const meta = {
|
|||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: ['ACCESS_DENIED', 'INTERNAL_ERROR'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -26,15 +29,12 @@ export const paramDef = {
|
|||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
||||
|
||||
if (!same) {
|
||||
throw new Error('incorrect password');
|
||||
if (!(await comparePassword(ps.password, profile.password!))) {
|
||||
throw new ApiError('ACCESS_DENIED');
|
||||
}
|
||||
|
||||
if (!profile.twoFactorEnabled) {
|
||||
throw new Error('2fa not enabled');
|
||||
throw new ApiError('INTERNAL_ERROR', '2fa not enabled');
|
||||
}
|
||||
|
||||
// 32 byte challenge
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
import bcrypt from 'bcryptjs';
|
||||
import * as speakeasy from 'speakeasy';
|
||||
import * as QRCode from 'qrcode';
|
||||
import config from '@/config/index.js';
|
||||
import { comparePassword } from '@/misc/password.js';
|
||||
import { UserProfiles } from '@/models/index.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import define from '../../../define.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: ['ACCESS_DENIED'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -23,11 +26,8 @@ export const paramDef = {
|
|||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
||||
|
||||
if (!same) {
|
||||
throw new Error('incorrect password');
|
||||
if (!(await comparePassword(ps.password, profile.password!))) {
|
||||
throw new ApiError('ACCESS_DENIED');
|
||||
}
|
||||
|
||||
// Generate user's secret key
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
import bcrypt from 'bcryptjs';
|
||||
import { comparePassword } from '@/misc/password.js';
|
||||
import { UserProfiles, UserSecurityKeys, Users } from '@/models/index.js';
|
||||
import { publishMainStream } from '@/services/stream.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import define from '../../../define.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: ['ACCESS_DENIED'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -22,11 +25,8 @@ export const paramDef = {
|
|||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
||||
|
||||
if (!same) {
|
||||
throw new Error('incorrect password');
|
||||
if (!(await comparePassword(ps.password, profile.password!))) {
|
||||
throw new ApiError('ACCESS_DENIED');
|
||||
}
|
||||
|
||||
// Make sure we only delete the user's own creds
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
import bcrypt from 'bcryptjs';
|
||||
import { comparePassword } from '@/misc/password.js';
|
||||
import { UserProfiles } from '@/models/index.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import define from '../../../define.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: ['ACCESS_DENIED'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -20,11 +23,8 @@ export const paramDef = {
|
|||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
||||
|
||||
if (!same) {
|
||||
throw new Error('incorrect password');
|
||||
if (!(await comparePassword(ps.password, profile.password!))) {
|
||||
throw new ApiError('ACCESS_DENIED');
|
||||
}
|
||||
|
||||
await UserProfiles.update(user.id, {
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
import bcrypt from 'bcryptjs';
|
||||
import { comparePassword, hashPassword } from '@/misc/password.js';
|
||||
import { UserProfiles } from '@/models/index.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import define from '../../define.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: ['ACCESS_DENIED'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -21,18 +24,11 @@ export const paramDef = {
|
|||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(ps.currentPassword, profile.password!);
|
||||
|
||||
if (!same) {
|
||||
throw new Error('incorrect password');
|
||||
if (!(await comparePassword(ps.currentPassword, profile.password!))) {
|
||||
throw new ApiError('ACCESS_DENIED');
|
||||
}
|
||||
|
||||
// Generate hash of password
|
||||
const salt = await bcrypt.genSalt(8);
|
||||
const hash = await bcrypt.hash(ps.newPassword, salt);
|
||||
|
||||
await UserProfiles.update(user.id, {
|
||||
password: hash,
|
||||
password: await hashPassword(ps.newPassword),
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
import bcrypt from 'bcryptjs';
|
||||
import { comparePassword } from '@/misc/password.js';
|
||||
import { UserProfiles, Users } from '@/models/index.js';
|
||||
import { deleteAccount } from '@/services/delete-account.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import define from '../../define.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: ['ACCESS_DENIED'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -19,17 +22,17 @@ export const paramDef = {
|
|||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
|
||||
const userDetailed = await Users.findOneByOrFail({ id: user.id });
|
||||
const [profile, userDetailed] = await Promise.all([
|
||||
UserProfiles.findOneByOrFail({ userId: user.id }),
|
||||
Users.findOneByOrFail({ id: user.id }),
|
||||
]);
|
||||
|
||||
if (userDetailed.isDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
||||
|
||||
if (!same) {
|
||||
throw new Error('incorrect password');
|
||||
if (!(await comparePassword(ps.password, profile.password!))) {
|
||||
throw new ApiError('ACCESS_DENIED');
|
||||
}
|
||||
|
||||
await deleteAccount(user);
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
import bcrypt from 'bcryptjs';
|
||||
import { comparePassword } from '@/misc/password.js';
|
||||
import { publishInternalEvent, publishMainStream, publishUserEvent } from '@/services/stream.js';
|
||||
import { Users, UserProfiles } from '@/models/index.js';
|
||||
import generateUserToken from '../../common/generate-native-user-token.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import define from '../../define.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: ['ACCESS_DENIED'],
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -25,11 +28,8 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
|
||||
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
||||
|
||||
if (!same) {
|
||||
throw new Error('incorrect password');
|
||||
if (!(await comparePassword(ps.password, profile.password!))) {
|
||||
throw new ApiError('ACCESS_DENIED');
|
||||
}
|
||||
|
||||
const newToken = generateUserToken();
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import bcrypt from 'bcryptjs';
|
||||
import { publishMainStream } from '@/services/stream.js';
|
||||
import config from '@/config/index.js';
|
||||
import { comparePassword } from '@/misc/password.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import { Users, UserProfiles } from '@/models/index.js';
|
||||
import { sendEmail } from '@/services/send-email.js';
|
||||
|
@ -37,10 +37,9 @@ export const paramDef = {
|
|||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
||||
|
||||
if (!same) throw new ApiError('ACCESS_DENIED');
|
||||
if (!(await comparePassword(ps.password, profile.password!))) {
|
||||
throw new ApiError('ACCESS_DENIED');
|
||||
}
|
||||
|
||||
if (ps.email != null) {
|
||||
const available = await validateEmailForAccount(ps.email);
|
||||
|
|
|
@ -2,7 +2,7 @@ import RE2 from 're2';
|
|||
import * as mfm from 'mfm-js';
|
||||
import { notificationTypes } from 'foundkey-js';
|
||||
import { publishMainStream, publishUserEvent } from '@/services/stream.js';
|
||||
import acceptAllFollowRequests from '@/services/following/requests/accept-all.js';
|
||||
import { acceptAllFollowRequests } from '@/services/following/requests/accept-all.js';
|
||||
import { publishToFollowers } from '@/services/i/update.js';
|
||||
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
||||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||
|
|
|
@ -233,6 +233,10 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
},
|
||||
|
||||
v2: {
|
||||
method: 'get'
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
@ -21,6 +21,11 @@ export const meta = {
|
|||
ref: 'Note',
|
||||
},
|
||||
},
|
||||
|
||||
v2: {
|
||||
method: 'get',
|
||||
alias: 'notes/:noteId/children',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
@ -19,6 +19,11 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
|
||||
v2: {
|
||||
method: 'get',
|
||||
alias: 'notes/:noteId/clips',
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE'],
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -19,6 +19,11 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
|
||||
v2: {
|
||||
method: 'get',
|
||||
alias: 'notes/:noteId/conversation',
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE'],
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -37,6 +37,11 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
|
||||
v2: {
|
||||
method: 'post',
|
||||
alias: 'notes',
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE', 'PURE_RENOTE', 'EXPIRED_POLL', 'NO_SUCH_CHANNEL', 'BLOCKED', 'LESS_RESTRICTIVE_VISIBILITY'],
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -18,6 +18,11 @@ export const meta = {
|
|||
minInterval: SECOND,
|
||||
},
|
||||
|
||||
v2: {
|
||||
method: 'delete',
|
||||
alias: 'notes/:noteId',
|
||||
},
|
||||
|
||||
errors: ['ACCESS_DENIED', 'NO_SUCH_NOTE'],
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -18,6 +18,10 @@ export const meta = {
|
|||
ref: 'Note',
|
||||
},
|
||||
},
|
||||
|
||||
v2: {
|
||||
method: 'get',
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
@ -23,6 +23,11 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
|
||||
v2: {
|
||||
method: 'get',
|
||||
alias: 'notes/:noteId/reactions/:type?',
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE'],
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -22,6 +22,11 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
|
||||
v2: {
|
||||
method: 'get',
|
||||
alias: 'notes/:noteId/renotes',
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE'],
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -22,6 +22,11 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
|
||||
v2: {
|
||||
method: 'get',
|
||||
alias: 'notes/:noteId/replies',
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE'],
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -14,6 +14,11 @@ export const meta = {
|
|||
ref: 'Note',
|
||||
},
|
||||
|
||||
v2: {
|
||||
method: 'get',
|
||||
alias: 'notes/:noteId',
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE'],
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -27,6 +27,11 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
|
||||
v2: {
|
||||
method: 'get',
|
||||
alias: 'notes/:noteId/status',
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE'],
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -56,6 +56,11 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
|
||||
v2: {
|
||||
method: 'get',
|
||||
alias: 'notes/:noteId/translate/:targetLang/:sourceLang?',
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE'],
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -18,6 +18,11 @@ export const meta = {
|
|||
minInterval: SECOND,
|
||||
},
|
||||
|
||||
v2: {
|
||||
method: 'delete',
|
||||
alias: 'notes/:noteId/renotes',
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE'],
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { resetDb } from '@/db/postgre.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import define from '../define.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -17,7 +18,7 @@ export const paramDef = {
|
|||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default define(meta, paramDef, async (ps, user) => {
|
||||
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV is not a test');
|
||||
if (process.env.NODE_ENV !== 'test') throw new ApiError('ACCESS_DENIED');
|
||||
|
||||
await resetDb();
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import bcrypt from 'bcryptjs';
|
||||
import { hashPassword } from '@/misc/password.js';
|
||||
import { UserProfiles, PasswordResetRequests } from '@/models/index.js';
|
||||
import { DAY, MINUTE } from '@/const.js';
|
||||
import define from '../define.js';
|
||||
|
@ -43,12 +43,8 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
throw new ApiError('NO_SUCH_RESET_REQUEST');
|
||||
}
|
||||
|
||||
// Generate hash of password
|
||||
const salt = await bcrypt.genSalt(8);
|
||||
const hash = await bcrypt.hash(ps.password, salt);
|
||||
|
||||
await UserProfiles.update(req.userId, {
|
||||
password: hash,
|
||||
password: await hashPassword(ps.password),
|
||||
});
|
||||
|
||||
await PasswordResetRequests.delete(req.id);
|
||||
|
|
|
@ -177,7 +177,7 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
driveFilesCount: DriveFiles.createQueryBuilder('file')
|
||||
.where('file.userId = :userId', { userId: user.id })
|
||||
.getCount(),
|
||||
driveUsage: DriveFiles.calcDriveUsageOf(user),
|
||||
driveUsage: DriveFiles.calcDriveUsageOf(user.id),
|
||||
});
|
||||
|
||||
result.followingCount = result.localFollowingCount + result.remoteFollowingCount;
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import Koa from 'koa';
|
||||
|
||||
export class ApiError extends Error {
|
||||
public message: string;
|
||||
public code: string;
|
||||
|
@ -20,6 +22,24 @@ export class ApiError extends Error {
|
|||
this.message = message;
|
||||
this.httpStatusCode = httpStatusCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the response of ctx the current error, given the respective endpoint name.
|
||||
*/
|
||||
public apply(ctx: Koa.Context, endpoint: string): void {
|
||||
ctx.status = this.httpStatusCode;
|
||||
if (ctx.status === 401) {
|
||||
ctx.response.set('WWW-Authenticate', 'Bearer');
|
||||
}
|
||||
ctx.body = {
|
||||
error: {
|
||||
message: this.message,
|
||||
code: this.code,
|
||||
info: this.info ?? undefined,
|
||||
endpoint,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const errors: Record<string, { message: string, httpStatusCode: number }> = {
|
||||
|
@ -167,6 +187,14 @@ export const errors: Record<string, { message: string, httpStatusCode: number }>
|
|||
message: 'Invalid username.',
|
||||
httpStatusCode: 400,
|
||||
},
|
||||
IS_ADMIN: {
|
||||
message: 'This action cannot be done to an administrator account.',
|
||||
httpStatusCode: 400,
|
||||
},
|
||||
IS_MODERATOR: {
|
||||
message: 'This action cannot be done to a moderator account.',
|
||||
httpStatusCode: 400,
|
||||
},
|
||||
LESS_RESTRICTIVE_VISIBILITY: {
|
||||
message: 'The visibility cannot be less restrictive than the parent note.',
|
||||
httpStatusCode: 400,
|
||||
|
|
|
@ -16,6 +16,7 @@ import signup from './private/signup.js';
|
|||
import signin from './private/signin.js';
|
||||
import signupPending from './private/signup-pending.js';
|
||||
import { oauth } from './common/oauth.js';
|
||||
import { ApiError } from './error.js';
|
||||
|
||||
// Init app
|
||||
const app = new Koa();
|
||||
|
@ -40,6 +41,27 @@ const upload = multer({
|
|||
files: 1,
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Wrap multer to return an appropriate API error when something goes wrong, e.g. the file is too big.
|
||||
*/
|
||||
type KoaMiddleware = (ctx: Koa.Context, next: () => Promise<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
|
||||
const router = new Router();
|
||||
|
@ -49,7 +71,7 @@ const router = new Router();
|
|||
*/
|
||||
for (const endpoint of endpoints) {
|
||||
if (endpoint.meta.requireFile) {
|
||||
router.post(`/${endpoint.name}`, upload.single('file'), handler.bind(null, endpoint));
|
||||
router.post(`/${endpoint.name}`, uploadWrapper(endpoint.name), handler.bind(null, endpoint));
|
||||
} else {
|
||||
// 後方互換性のため
|
||||
if (endpoint.name.includes('-')) {
|
||||
|
@ -69,6 +91,11 @@ for (const endpoint of endpoints) {
|
|||
} else {
|
||||
router.get(`/${endpoint.name}`, async ctx => { ctx.status = 405; });
|
||||
}
|
||||
|
||||
if (endpoint.meta.v2) {
|
||||
const path = endpoint.meta.v2.alias ?? endpoint.name.replace(/-/g, '_');
|
||||
router[endpoint.meta.v2.method](`/v2/${path}`, handler.bind(null, endpoint));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -207,6 +207,19 @@ export function genOpenapiSpec() {
|
|||
}
|
||||
|
||||
spec.paths['/' + endpoint.name] = path;
|
||||
|
||||
if (endpoint.meta.v2) {
|
||||
// we need a clone of the API endpoint info because otherwise we change it by reference
|
||||
const infoClone = structuredClone(info);
|
||||
const route = `/v2/${endpoint.meta.v2.alias ?? endpoint.name.replace(/-/g, '_')}`;
|
||||
|
||||
infoClone['operationId'] = infoClone['summary'] = route;
|
||||
|
||||
spec.paths[route] = {
|
||||
...spec.paths[route],
|
||||
[endpoint.meta.v2.method]: infoClone,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return spec;
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { randomBytes } from 'node:crypto';
|
||||
import { IsNull } from 'typeorm';
|
||||
import Koa from 'koa';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import * as speakeasy from 'speakeasy';
|
||||
import { SECOND, MINUTE, HOUR } from '@/const.js';
|
||||
import config from '@/config/index.js';
|
||||
|
@ -9,10 +8,11 @@ import { Users, Signins, UserProfiles, UserSecurityKeys, AttestationChallenges }
|
|||
import { ILocalUser } from '@/models/entities/user.js';
|
||||
import { genId } from '@/misc/gen-id.js';
|
||||
import { getIpHash } from '@/misc/get-ip-hash.js';
|
||||
import { comparePassword } from '@/misc/password.js';
|
||||
import signin from '../common/signin.js';
|
||||
import { verifyLogin, hash } from '../2fa.js';
|
||||
import { limiter } from '../limiter.js';
|
||||
import { ApiError } from '../error.js';
|
||||
import { ApiError, errors } from '../error.js';
|
||||
|
||||
export default async (ctx: Koa.Context) => {
|
||||
ctx.set('Access-Control-Allow-Origin', config.url);
|
||||
|
@ -21,42 +21,30 @@ export default async (ctx: Koa.Context) => {
|
|||
const body = ctx.request.body as any;
|
||||
const { username, password, token } = body;
|
||||
|
||||
// taken from @server/api/api-handler.ts
|
||||
function error (e: ApiError): void {
|
||||
ctx.status = e.httpStatusCode;
|
||||
if (e.httpStatusCode === 401) {
|
||||
ctx.response.set('WWW-Authenticate', 'Bearer');
|
||||
}
|
||||
ctx.body = {
|
||||
error: {
|
||||
message: e!.message,
|
||||
code: e!.code,
|
||||
...(e!.info ? { info: e!.info } : {}),
|
||||
endpoint: 'signin',
|
||||
},
|
||||
};
|
||||
function error(e: keyof errors, info?: Record<string, any>): void {
|
||||
new ApiError(e, info).apply(ctx, 'signin');
|
||||
}
|
||||
|
||||
try {
|
||||
// not more than 1 attempt per second and not more than 10 attempts per hour
|
||||
await limiter({ key: 'signin', duration: HOUR, max: 10, minInterval: SECOND }, getIpHash(ctx.ip));
|
||||
} catch (err) {
|
||||
error(new ApiError('RATE_LIMIT_EXCEEDED'));
|
||||
error('RATE_LIMIT_EXCEEDED');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof username !== 'string') {
|
||||
error(new ApiError('INVALID_PARAM', { param: 'username', reason: 'not a string' }));
|
||||
error('INVALID_PARAM', { param: 'username', reason: 'not a string' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof password !== 'string') {
|
||||
error(new ApiError('INVALID_PARAM', { param: 'password', reason: 'not a string' }));
|
||||
error('INVALID_PARAM', { param: 'password', reason: 'not a string' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (token != null && typeof token !== 'string') {
|
||||
error(new ApiError('INVALID_PARAM', { param: 'token', reason: 'provided but not a string' }));
|
||||
error('INVALID_PARAM', { param: 'token', reason: 'provided but not a string' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -67,19 +55,19 @@ export default async (ctx: Koa.Context) => {
|
|||
}) as ILocalUser;
|
||||
|
||||
if (user == null) {
|
||||
error(new ApiError('NO_SUCH_USER'));
|
||||
error('NO_SUCH_USER');
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
error(new ApiError('SUSPENDED'));
|
||||
error('SUSPENDED');
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(password, profile.password!);
|
||||
const same = await comparePassword(password, profile.password!);
|
||||
|
||||
async function fail(): void {
|
||||
// Append signin history
|
||||
|
@ -92,7 +80,7 @@ export default async (ctx: Koa.Context) => {
|
|||
success: false,
|
||||
});
|
||||
|
||||
error(new ApiError('ACCESS_DENIED'));
|
||||
error('ACCESS_DENIED');
|
||||
}
|
||||
|
||||
if (!profile.twoFactorEnabled) {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import Koa from 'koa';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { fetchMeta } from '@/misc/fetch-meta.js';
|
||||
import { verifyHcaptcha, verifyRecaptcha } from '@/misc/captcha.js';
|
||||
import { hashPassword } from '@/misc/password.js';
|
||||
import { Users, RegistrationTickets, UserPendings } from '@/models/index.js';
|
||||
import config from '@/config/index.js';
|
||||
import { sendEmail } from '@/services/send-email.js';
|
||||
|
@ -71,17 +71,13 @@ export default async (ctx: Koa.Context) => {
|
|||
if (instance.emailRequiredForSignup) {
|
||||
const code = secureRndstr(16);
|
||||
|
||||
// Generate hash of password
|
||||
const salt = await bcrypt.genSalt(8);
|
||||
const hash = await bcrypt.hash(password, salt);
|
||||
|
||||
await UserPendings.insert({
|
||||
id: genId(),
|
||||
createdAt: new Date(),
|
||||
code,
|
||||
email: emailAddress,
|
||||
username,
|
||||
password: hash,
|
||||
password: await hashPassword(password),
|
||||
});
|
||||
|
||||
const link = `${config.url}/signup-complete/${code}`;
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
import * as nestedProperty from 'nested-property';
|
||||
import { EntitySchema, Repository, LessThan, Between } from 'typeorm';
|
||||
import { dateUTC, isTimeSame, isTimeBefore, subtractTime, addTime } from '@/prelude/time.js';
|
||||
import { unique } from '@/prelude/array.js';
|
||||
import { getChartInsertLock } from '@/misc/app-lock.js';
|
||||
import { db } from '@/db/postgre.js';
|
||||
import Logger from '../logger.js';
|
||||
|
@ -56,8 +57,6 @@ const camelToSnake = (str: string): string => {
|
|||
return str.replace(/([A-Z])/g, s => '_' + s.charAt(0).toLowerCase());
|
||||
};
|
||||
|
||||
const removeDuplicates = (array: any[]) => Array.from(new Set(array));
|
||||
|
||||
type Commit<S extends Schema> = {
|
||||
[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));
|
||||
};
|
||||
|
||||
const groups = removeDuplicates(this.buffer.map(log => log.group));
|
||||
const groups = unique(this.buffer.map(log => log.group));
|
||||
|
||||
await Promise.all(
|
||||
groups.map(group =>
|
||||
|
@ -651,12 +650,7 @@ export default abstract class Chart<T extends Schema> {
|
|||
|
||||
const res = {} as ChartResult<T>;
|
||||
|
||||
/**
|
||||
* Turn
|
||||
* [{ foo: 1, bar: 5 }, { foo: 2, bar: 6 }, { foo: 3, bar: 7 }]
|
||||
* into
|
||||
* { foo: [1, 2, 3], bar: [5, 6, 7] }
|
||||
*/
|
||||
// Turn array of objects into object of arrays.
|
||||
for (const record of chart) {
|
||||
for (const [k, v] of Object.entries(record) as ([keyof typeof record, number])[]) {
|
||||
if (res[k]) {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import bcrypt from 'bcryptjs';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
|
||||
import { hashPassword } from '@/misc/password.js';
|
||||
import { User } from '@/models/entities/user.js';
|
||||
import { UserProfile } from '@/models/entities/user-profile.js';
|
||||
import { genId } from '@/misc/gen-id.js';
|
||||
|
@ -11,11 +11,7 @@ import { db } from '@/db/postgre.js';
|
|||
import generateNativeUserToken from '@/server/api/common/generate-native-user-token.js';
|
||||
|
||||
export async function createSystemUser(username: string): Promise<User> {
|
||||
const password = uuid();
|
||||
|
||||
// Generate hash of password
|
||||
const salt = await bcrypt.genSalt(8);
|
||||
const hash = await bcrypt.hash(password, salt);
|
||||
const password = await hashPassword(uuid());
|
||||
|
||||
// Generate secret
|
||||
const secret = generateNativeUserToken();
|
||||
|
@ -55,7 +51,7 @@ export async function createSystemUser(username: string): Promise<User> {
|
|||
await transactionalEntityManager.insert(UserProfile, {
|
||||
userId: account.id,
|
||||
autoAcceptFollowed: false,
|
||||
password: hash,
|
||||
password,
|
||||
});
|
||||
|
||||
await transactionalEntityManager.insert(UsedUsername, {
|
||||
|
|
|
@ -372,7 +372,7 @@ export async function addFile({
|
|||
|
||||
//#region Check drive usage
|
||||
if (user && !isLink) {
|
||||
const usage = await DriveFiles.calcDriveUsageOf(user);
|
||||
const usage = await DriveFiles.calcDriveUsageOf(user.id);
|
||||
|
||||
const instance = await fetchMeta();
|
||||
const driveCapacity = 1024 * 1024 * (Users.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb);
|
||||
|
|
|
@ -15,7 +15,7 @@ import { getActiveWebhooks } from '@/misc/webhook-cache.js';
|
|||
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js';
|
||||
import Logger from '../logger.js';
|
||||
import { createNotification } from '../create-notification.js';
|
||||
import createFollowRequest from './requests/create.js';
|
||||
import { createFollowRequest } from './requests/create.js';
|
||||
|
||||
const logger = new Logger('following/create');
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ type Both = Local | Remote;
|
|||
/**
|
||||
* API following/request/reject
|
||||
*/
|
||||
export async function rejectFollowRequest(user: Local, follower: Both) {
|
||||
export async function rejectFollowRequest(user: Local, follower: Both): Promise<void> {
|
||||
if (Users.isRemoteUser(follower)) {
|
||||
deliverReject(user, follower);
|
||||
}
|
||||
|
@ -39,7 +39,7 @@ export async function rejectFollowRequest(user: Local, follower: Both) {
|
|||
/**
|
||||
* API following/reject
|
||||
*/
|
||||
export async function rejectFollow(user: Local, follower: Both) {
|
||||
export async function rejectFollow(user: Local, follower: Both): Promise<void> {
|
||||
if (Users.isRemoteUser(follower)) {
|
||||
deliverReject(user, follower);
|
||||
}
|
||||
|
@ -54,7 +54,7 @@ export async function rejectFollow(user: Local, follower: Both) {
|
|||
/**
|
||||
* AP Reject/Follow
|
||||
*/
|
||||
export async function remoteReject(actor: Remote, follower: Local) {
|
||||
export async function remoteReject(actor: Remote, follower: Local): Promise<void> {
|
||||
await removeFollowRequest(actor, follower);
|
||||
await removeFollow(actor, follower);
|
||||
publishUnfollow(actor, follower);
|
||||
|
@ -63,7 +63,7 @@ export async function remoteReject(actor: Remote, follower: Local) {
|
|||
/**
|
||||
* Remove follow request record
|
||||
*/
|
||||
async function removeFollowRequest(followee: Both, follower: Both) {
|
||||
async function removeFollowRequest(followee: Both, follower: Both): Promise<void> {
|
||||
const request = await FollowRequests.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
|
@ -77,7 +77,7 @@ async function removeFollowRequest(followee: Both, follower: Both) {
|
|||
/**
|
||||
* Remove follow record
|
||||
*/
|
||||
async function removeFollow(followee: Both, follower: Both) {
|
||||
async function removeFollow(followee: Both, follower: Both): Promise<void> {
|
||||
const following = await Followings.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
|
@ -92,7 +92,7 @@ async function removeFollow(followee: Both, follower: Both) {
|
|||
/**
|
||||
* Deliver Reject to remote
|
||||
*/
|
||||
async function deliverReject(followee: Local, follower: Remote) {
|
||||
async function deliverReject(followee: Local, follower: Remote): Promise<void> {
|
||||
const request = await FollowRequests.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
|
@ -105,7 +105,7 @@ async function deliverReject(followee: Local, follower: Remote) {
|
|||
/**
|
||||
* Publish unfollow to local
|
||||
*/
|
||||
async function publishUnfollow(followee: Both, follower: Local) {
|
||||
async function publishUnfollow(followee: Both, follower: Local): Promise<void> {
|
||||
const packedFollowee = await Users.pack(followee.id, follower, {
|
||||
detail: true,
|
||||
});
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import { User } from '@/models/entities/user.js';
|
||||
import { FollowRequests, Users } from '@/models/index.js';
|
||||
import accept from './accept.js';
|
||||
import { acceptFollowRequest } from './accept.js';
|
||||
|
||||
/**
|
||||
* 指定したユーザー宛てのフォローリクエストをすべて承認
|
||||
* @param user ユーザー
|
||||
* Approve all follow requests addressed to the specified user.
|
||||
* @param user The user whom to accept all follow requests to
|
||||
*/
|
||||
export default async function(user: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }) {
|
||||
export async function acceptAllFollowRequests(user: User): Promise<void> {
|
||||
const requests = await FollowRequests.findBy({
|
||||
followeeId: user.id,
|
||||
});
|
||||
|
||||
for (const request of requests) {
|
||||
const follower = await Users.findOneByOrFail({ id: request.followerId });
|
||||
accept(user, follower);
|
||||
acceptFollowRequest(user, follower);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,12 +3,17 @@ import renderFollow from '@/remote/activitypub/renderer/follow.js';
|
|||
import renderAccept from '@/remote/activitypub/renderer/accept.js';
|
||||
import { deliver } from '@/queue/index.js';
|
||||
import { publishMainStream } from '@/services/stream.js';
|
||||
import { User, CacheableUser } from '@/models/entities/user.js';
|
||||
import { User } from '@/models/entities/user.js';
|
||||
import { FollowRequests, Users } from '@/models/index.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { insertFollowingDoc } from '../create.js';
|
||||
|
||||
export default async function(followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, follower: CacheableUser) {
|
||||
/**
|
||||
* Accept a follow request from user `followee` to follow `follower`.
|
||||
* @param followee User who is being followed
|
||||
* @param follower User making the follow request
|
||||
*/
|
||||
export async function acceptFollowRequest(followee: User, follower: User): Promise<void> {
|
||||
const request = await FollowRequests.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
|
|
|
@ -7,7 +7,12 @@ import { IdentifiableError } from '@/misc/identifiable-error.js';
|
|||
import { User } from '@/models/entities/user.js';
|
||||
import { Users, FollowRequests } from '@/models/index.js';
|
||||
|
||||
export default async function(followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox'] }, follower: { id: User['id']; host: User['host']; uri: User['host'] }) {
|
||||
/**
|
||||
* Cancel a follow request from `follower` to `followee`.
|
||||
* @param followee User that was going to be followed
|
||||
* @param follower User who is making the follow request
|
||||
*/
|
||||
export async function cancelFollowRequest(followee: User, follower: User): Promise<void> {
|
||||
if (Users.isRemoteUser(followee)) {
|
||||
const content = renderActivity(renderUndo(renderFollow(follower, followee), follower));
|
||||
|
||||
|
|
|
@ -7,7 +7,13 @@ import { Blockings, FollowRequests, Users } from '@/models/index.js';
|
|||
import { genId } from '@/misc/gen-id.js';
|
||||
import { createNotification } from '@/services/create-notification.js';
|
||||
|
||||
export default async function(follower: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, requestId?: string) {
|
||||
/**
|
||||
* Make a follow request from `follower` to `followee`.
|
||||
* @param follower User making the follow request
|
||||
* @param followee User to make the follow request to
|
||||
* @param requestId Follow request ID
|
||||
*/
|
||||
export async function createFollowRequest(follower: User, followee: User, requestId?: string): Promise<void> {
|
||||
if (follower.id === followee.id) return;
|
||||
|
||||
// check blocking
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
<div style="margin-bottom: 16px;"><b>{{ i18n.ts.permission }}</b></div>
|
||||
<MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</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>
|
||||
</XModalWindow>
|
||||
</template>
|
||||
|
@ -57,16 +57,15 @@ const emit = defineEmits<{
|
|||
let dialog: InstanceType<typeof XModalWindow> | null = $ref(null);
|
||||
let name = $ref(props.initialName);
|
||||
let perms: Record<string, boolean> = $ref({});
|
||||
const kinds = $ref(permissions);
|
||||
let kinds = props.initialPermissions.length > 0
|
||||
? props.initialPermissions
|
||||
: permissions;
|
||||
|
||||
if (props.initialPermissions.length > 0) {
|
||||
for (const kind of props.initialPermissions) {
|
||||
perms[kind] = true;
|
||||
}
|
||||
} else {
|
||||
for (const kind of kinds) {
|
||||
perms[kind] = false;
|
||||
}
|
||||
// If there is a particular set of permissions given, enable all of them.
|
||||
// Otherwise, by default disable all permissions.
|
||||
const enable = props.initialPermissions.length > 0;
|
||||
for (const kind of kinds) {
|
||||
perms[kind] = enable;
|
||||
}
|
||||
|
||||
function ok(): void {
|
||||
|
|
|
@ -1,31 +1,83 @@
|
|||
// TODO: useTooltip関数使うようにしたい
|
||||
// ただディレクティブ内でonUnmountedなどのcomposition api使えるのか不明
|
||||
// TODO: use the useTooltip function
|
||||
|
||||
import { defineAsyncComponent, Directive, ref } from 'vue';
|
||||
import { isTouchUsing } from '@/scripts/touch';
|
||||
import { popup, alert } from '@/os';
|
||||
|
||||
const start = isTouchUsing ? 'touchstart' : 'mouseover';
|
||||
const end = isTouchUsing ? 'touchend' : 'mouseleave';
|
||||
const delay = 100;
|
||||
|
||||
class TooltipDirective {
|
||||
public text: string | null;
|
||||
private asMfm: boolean;
|
||||
|
||||
private _close: null | () => void;
|
||||
private showTimer: null | ReturnType<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 {
|
||||
created(el: HTMLElement, binding) {
|
||||
(el as any)._tooltipDirective_ = new TooltipDirective(binding);
|
||||
},
|
||||
|
||||
mounted(el: HTMLElement, binding) {
|
||||
const self = (el as any)._tooltipDirective_ = {} as any;
|
||||
|
||||
self.text = binding.value as string;
|
||||
self._close = null;
|
||||
self.showTimer = null;
|
||||
self.hideTimer = null;
|
||||
self.checkTimer = null;
|
||||
|
||||
self.close = () => {
|
||||
if (self._close) {
|
||||
window.clearInterval(self.checkTimer);
|
||||
self._close();
|
||||
self._close = null;
|
||||
}
|
||||
};
|
||||
const self = el._tooltipDirective_ as TooltipDirective;
|
||||
|
||||
if (binding.arg === 'dialog') {
|
||||
el.addEventListener('click', (ev) => {
|
||||
|
@ -39,53 +91,20 @@ export default {
|
|||
});
|
||||
}
|
||||
|
||||
self.show = () => {
|
||||
if (!document.body.contains(el)) return;
|
||||
if (self._close) return;
|
||||
if (self.text == null) return;
|
||||
|
||||
const showing = ref(true);
|
||||
popup(defineAsyncComponent(() => import('@/components/ui/tooltip.vue')), {
|
||||
showing,
|
||||
text: self.text,
|
||||
asMfm: binding.modifiers.mfm,
|
||||
targetElement: el,
|
||||
}, {}, 'closed');
|
||||
|
||||
self._close = () => {
|
||||
showing.value = false;
|
||||
};
|
||||
};
|
||||
|
||||
el.addEventListener('selectstart', ev => {
|
||||
ev.preventDefault();
|
||||
});
|
||||
|
||||
el.addEventListener(start, () => {
|
||||
window.clearTimeout(self.showTimer);
|
||||
window.clearTimeout(self.hideTimer);
|
||||
self.showTimer = window.setTimeout(self.show, delay);
|
||||
}, { passive: true });
|
||||
|
||||
el.addEventListener(end, () => {
|
||||
window.clearTimeout(self.showTimer);
|
||||
window.clearTimeout(self.hideTimer);
|
||||
self.hideTimer = window.setTimeout(self.close, delay);
|
||||
}, { passive: true });
|
||||
|
||||
el.addEventListener('click', () => {
|
||||
window.clearTimeout(self.showTimer);
|
||||
self.close();
|
||||
});
|
||||
// add event listeners
|
||||
const start = isTouchUsing ? 'touchstart' : 'mouseover';
|
||||
const end = isTouchUsing ? 'touchend' : 'mouseleave';
|
||||
el.addEventListener(start, () => self.show(el), { passive: true });
|
||||
el.addEventListener(end, () => self.close(), { passive: true });
|
||||
el.addEventListener('click', self.close());
|
||||
el.addEventListener('selectstart', ev => ev.preventDefault());
|
||||
},
|
||||
|
||||
updated(el, binding) {
|
||||
const self = el._tooltipDirective_;
|
||||
self.text = binding.value as string;
|
||||
beforeUpdate(el, binding) {
|
||||
(el._tooltipDirective_ as TooltipDirective).text = binding.value as string;
|
||||
},
|
||||
|
||||
unmounted(el) {
|
||||
const self = el._tooltipDirective_;
|
||||
window.clearInterval(self.checkTimer);
|
||||
beforeUnmount(el) {
|
||||
(el._tooltipDirective_ as TooltipDirective).close();
|
||||
},
|
||||
} as Directive;
|
||||
|
|
|
@ -11,19 +11,25 @@
|
|||
<div v-for="token in items" :key="token.id" class="_panel bfomjevm">
|
||||
<img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/>
|
||||
<div class="body">
|
||||
<div class="name">{{ token.name }}</div>
|
||||
<div class="description">{{ token.description }}</div>
|
||||
<div class="_keyValue">
|
||||
<div>{{ i18n.ts.installedDate }}:</div>
|
||||
<div><MkTime :time="token.createdAt"/></div>
|
||||
</div>
|
||||
<div class="_keyValue">
|
||||
<div>{{ i18n.ts.lastUsedDate }}:</div>
|
||||
<div><MkTime :time="token.lastUsedAt"/></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="_button" @click="revoke(token)"><i class="fas fa-trash-alt"></i></button>
|
||||
</div>
|
||||
<button class="_button" @click="revoke(token)"><i class="fas fa-trash-alt"></i></button>
|
||||
<table>
|
||||
<tr>
|
||||
<th>{{ i18n.ts.name }}:</th>
|
||||
<td>{{ token.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ i18n.ts.description }}:</th>
|
||||
<td>{{ token.description }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ i18n.ts.installedDate }}:</th>
|
||||
<td><MkTime :time="token.createdAt"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ i18n.ts.lastUsedDate }}:</th>
|
||||
<td><MkTime :time="token.lastUsedAt"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
<details>
|
||||
<summary>{{ i18n.ts.details }}</summary>
|
||||
<ul>
|
||||
|
@ -82,11 +88,19 @@ definePageMetadata({
|
|||
}
|
||||
|
||||
> .body {
|
||||
width: calc(100% - 62px);
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
> .name {
|
||||
font-weight: bold;
|
||||
button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
th {
|
||||
text-align: right;
|
||||
}
|
||||
td {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -390,14 +390,6 @@ hr {
|
|||
}
|
||||
}
|
||||
|
||||
._keyValue {
|
||||
display: flex;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
._link {
|
||||
color: var(--link);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue