Compare commits

...

46 commits

Author SHA1 Message Date
1b3d89c40d Merge branch 'fix/endpoint-children' into mk.absturztau.be 2022-12-26 09:09:41 +01:00
55d8d5a626 Merge branch 'main' into mk.absturztau.be 2022-12-26 09:09:34 +01:00
e89c0135bd removing joins to avatar and banners in children endpoint 2022-12-26 09:09:11 +01:00
eea2eb4919
use Promise.all instead of separate promises 2022-12-25 19:04:00 +01:00
114d416de0
server: refactor password hashing & comparison to module
For easier replacement should the hash algorithm ever be changed.
2022-12-25 19:03:51 +01:00
c2372315f7
server: improve error messages
Refactor Error's to ApiError's.

Changelog: Changed
2022-12-25 16:07:48 +01:00
09bc3cf95a
activitypub: Do block checks more globally
Changelog: Fixed
Reviewed-on: FoundKeyGang/FoundKey#299
2022-12-24 18:40:44 -05:00
de3cdb5833
activitypub: block check for resolving collections 2022-12-24 18:39:44 -05:00
a732cdc1ad
activitypub: perform block check in performOneActivity 2022-12-24 18:39:44 -05:00
a8f82050c8
activitypub: perform resolver block check on objects as well 2022-12-24 18:39:44 -05:00
8e12b9a33e
server: restore original comment for skippedInstances 2022-12-24 15:01:32 -05:00
6583d0c43d
server: pass in resolved meta table to shouldBlockInstance
This should make it more friendly to use in places where the meta table
has already been resolved for other reasons.
2022-12-24 14:56:48 -05:00
c02a03168d
fixup tooltip: use this instead of self 2022-12-24 13:48:58 +01:00
85419326f8
server: use prelude function instead of separate function 2022-12-23 13:55:15 +01:00
eaa11647f0
server: rewrite drive usage queries in raw SQL 2022-12-23 13:54:12 +01:00
61a2db49df
server: always use user id for calcDriveUsageOf 2022-12-23 13:38:29 +01:00
79ddbafd0d
client: fix tooltips not closing
closes FoundKeyGang/FoundKey#120

Changelog: Fixed
2022-12-23 11:06:07 +01:00
0e1459e5cf Merge pull request 'server: refactor follow request functions to be named exports' (#296) from refactor/follow-requests into main
Reviewed-on: FoundKeyGang/FoundKey#296
2022-12-23 02:06:31 +00:00
e8e82dac82
fixup: confusing commas 2022-12-23 02:30:57 +01:00
9690244848
server: add return type for all follow reject funcs 2022-12-22 17:52:30 -05:00
4db25e4b1f
server: add doc for cancelFollowRequest 2022-12-22 16:55:08 -05:00
549302e9c0
server: add doc for createFollowRequest 2022-12-22 16:55:07 -05:00
a3354904af
server: use named export for createFollowRequest 2022-12-22 16:52:52 -05:00
28f65bebfc
server: use named export for cancelFollowRequest 2022-12-22 16:52:52 -05:00
2204adc657
server: use named export for acceptAllFollowRequests 2022-12-22 16:52:52 -05:00
b11e4053db
server: use named export for acceptFollowRequest 2022-12-22 16:52:52 -05:00
e2ef800708
server: dont use replace for file types
No point in using replace if we already know which character we want to replace.
2022-12-22 14:46:21 +01:00
a7048f17f7
server: simplify duplicated code 2022-12-22 14:45:20 +01:00
ddf3e2c3db
client: refactor tooltip directive
Using the beforeUnmount hook should hopefully improve issues with
tooltips being left behind.
2022-12-22 14:12:25 +01:00
52afff800a
server: start adding /api/v2 routes
empty changelog commit

Changelog: Added
2022-12-22 11:03:38 +01:00
33f0b24c56
server: add v2 routes to notes endpoints 2022-12-22 11:02:04 +01:00
7685b92511
improve fetching of endpoint arguments
including support for route parameters (e.g. '/v2/note/:noteId' giving us a 'noteId' value)

Co-authored-by: Johann150 <johann.galle@protonmail.com>
2022-12-22 11:02:04 +01:00
8276bd3bdc
generate OpenAPI spec for v2 endpoints 2022-12-22 11:02:04 +01:00
aed2752470
server: make v2 meta endpoint support GET 2022-12-22 11:01:56 +01:00
4a3b91d658
server: add additional API v2 options to endpoints
* improve type definitions for v2 method
The method has to be lowercase because it is used as an index to get
the respective method of the router.

Co-authored-by: Johann150 <johann.galle@protonmail.com>
2022-12-22 11:00:46 +01:00
9317d25078
server: expire notifications after 3 months
closes FoundKeyGang/FoundKey#292

Changelog: Added
2022-12-21 21:46:45 +01:00
fc36bb8880
server: reduce code duplication in check-expired queue job 2022-12-21 21:46:27 +01:00
711bb8be7d
fixup: add missing redirect argument 2022-12-21 21:23:23 +01:00
275136cf8b
allow redirects in API ap/* endpoints 2022-12-21 20:45:55 +01:00
aa33708b90
server: handle redirects in signed get
part of FoundKeyGang/FoundKey#288

Changelog: Fixed
2022-12-20 22:07:24 +01:00
d75e295ee8
remove "your" from "read:reactions"
Makes it consistent wiht the rest of the _permissions strings not using
pronouns.
2022-12-20 00:10:17 -05:00
766ab1c4c4
docs: readd missing "read:reactions" string 2022-12-20 00:07:35 -05:00
99c459a21a
server: better upload limit error
Ref: FoundKeyGang/FoundKey#293
2022-12-19 21:29:29 +01:00
bd68096ea9
server: refactor API error 2022-12-19 21:24:39 +01:00
c411669133
client: fix token-generate-window component 2022-12-18 20:42:05 +01:00
639fa74d43
client: restyle app token view 2022-12-18 19:37:52 +01:00
82 changed files with 637 additions and 432 deletions

View file

@ -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"

View file

@ -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,
});

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.
*
* @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));
}

View file

@ -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]);

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);
},
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(

View file

@ -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.');

View file

@ -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';
};

View file

@ -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)) {

View file

@ -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);

View file

@ -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');
}

View file

@ -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');
}

View file

@ -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);
}
});
}

View file

@ -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

View file

@ -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 {

View file

@ -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,

View file

@ -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)) {

View file

@ -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);

View file

@ -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) }, {

View file

@ -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, {

View file

@ -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, {

View file

@ -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);
}

View file

@ -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,
};
});

View file

@ -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) {

View file

@ -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, {

View file

@ -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, {

View file

@ -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, {

View file

@ -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, {

View file

@ -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;
});

View file

@ -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検索

View file

@ -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 {

View file

@ -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 });
}

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 { ApiError } from '../../../error.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 { IdentifiableError } from '@/misc/identifiable-error.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 { 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({

View file

@ -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({

View file

@ -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, {

View file

@ -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');

View file

@ -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

View file

@ -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

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 { 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

View file

@ -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, {

View file

@ -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),
});
});

View file

@ -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);

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 { 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();

View file

@ -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);

View file

@ -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';

View file

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

View file

@ -21,6 +21,11 @@ export const meta = {
ref: 'Note',
},
},
v2: {
method: 'get',
alias: 'notes/:noteId/children',
},
} as const;
export const paramDef = {
@ -51,9 +56,7 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, user) => {
const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere('note.id IN (SELECT id FROM note_replies(:noteId, :depth, :limit))', { noteId: ps.noteId, depth: ps.depth, limit: ps.limit })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner');
.innerJoinAndSelect('note.user', 'user');
generateVisibilityQuery(query, user);
if (user) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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();

View file

@ -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);

View file

@ -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;

View file

@ -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,

View file

@ -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));
}
}
}

View file

@ -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;

View file

@ -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) {

View file

@ -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}`;

View file

@ -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]) {

View file

@ -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, {

View file

@ -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);

View file

@ -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');

View file

@ -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,
});

View file

@ -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);
}
}

View file

@ -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,

View file

@ -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));

View file

@ -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

View file

@ -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 {

View file

@ -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;

View file

@ -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;
}
}
}

View file

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