diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
| commit | 0e4a111f81cceed275d9bec2695f6e401fb654d8 (patch) | |
| tree | 40874799472fa07416f17b50a398ac33b7771905 /packages/backend/src/server/api | |
| parent | update deps (diff) | |
| download | sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.gz sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.bz2 sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.zip | |
refactoring
Resolve #7779
Diffstat (limited to 'packages/backend/src/server/api')
364 files changed, 26025 insertions, 0 deletions
diff --git a/packages/backend/src/server/api/2fa.ts b/packages/backend/src/server/api/2fa.ts new file mode 100644 index 0000000000..117446383d --- /dev/null +++ b/packages/backend/src/server/api/2fa.ts @@ -0,0 +1,422 @@ +import * as crypto from 'crypto'; +import config from '@/config/index'; +import * as jsrsasign from 'jsrsasign'; + +const ECC_PRELUDE = Buffer.from([0x04]); +const NULL_BYTE = Buffer.from([0]); +const PEM_PRELUDE = Buffer.from( + '3059301306072a8648ce3d020106082a8648ce3d030107034200', + 'hex' +); + +// Android Safetynet attestations are signed with this cert: +const GSR2 = `-----BEGIN CERTIFICATE----- +MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G +A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp +Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1 +MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG +A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL +v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8 +eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq +tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd +C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa +zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB +mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH +V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n +bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG +3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs +J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO +291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS +ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd +AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7 +TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg== +-----END CERTIFICATE-----\n`; + +function base64URLDecode(source: string) { + return Buffer.from(source.replace(/\-/g, '+').replace(/_/g, '/'), 'base64'); +} + +function getCertSubject(certificate: string) { + const subjectCert = new jsrsasign.X509(); + subjectCert.readCertPEM(certificate); + + const subjectString = subjectCert.getSubjectString(); + const subjectFields = subjectString.slice(1).split('/'); + + const fields = {} as Record<string, string>; + for (const field of subjectFields) { + const eqIndex = field.indexOf('='); + fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1); + } + + return fields; +} + +function verifyCertificateChain(certificates: string[]) { + let valid = true; + + for (let i = 0; i < certificates.length; i++) { + const Cert = certificates[i]; + const certificate = new jsrsasign.X509(); + certificate.readCertPEM(Cert); + + const CACert = i + 1 >= certificates.length ? Cert : certificates[i + 1]; + + const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex!, 0, [0]); + const algorithm = certificate.getSignatureAlgorithmField(); + const signatureHex = certificate.getSignatureValueHex(); + + // Verify against CA + const Signature = new jsrsasign.KJUR.crypto.Signature({alg: algorithm}); + Signature.init(CACert); + Signature.updateHex(certStruct); + valid = valid && !!Signature.verify(signatureHex); // true if CA signed the certificate + } + + return valid; +} + +function PEMString(pemBuffer: Buffer, type = 'CERTIFICATE') { + if (pemBuffer.length === 65 && pemBuffer[0] === 0x04) { + pemBuffer = Buffer.concat([PEM_PRELUDE, pemBuffer], 91); + type = 'PUBLIC KEY'; + } + const cert = pemBuffer.toString('base64'); + + const keyParts = []; + const max = Math.ceil(cert.length / 64); + let start = 0; + for (let i = 0; i < max; i++) { + keyParts.push(cert.substring(start, start + 64)); + start += 64; + } + + return ( + `-----BEGIN ${type}-----\n` + + keyParts.join('\n') + + `\n-----END ${type}-----\n` + ); +} + +export function hash(data: Buffer) { + return crypto + .createHash('sha256') + .update(data) + .digest(); +} + +export function verifyLogin({ + publicKey, + authenticatorData, + clientDataJSON, + clientData, + signature, + challenge +}: { + publicKey: Buffer, + authenticatorData: Buffer, + clientDataJSON: Buffer, + clientData: any, + signature: Buffer, + challenge: string +}) { + if (clientData.type != 'webauthn.get') { + throw new Error('type is not webauthn.get'); + } + + if (hash(clientData.challenge).toString('hex') != challenge) { + throw new Error('challenge mismatch'); + } + if (clientData.origin != config.scheme + '://' + config.host) { + throw new Error('origin mismatch'); + } + + const verificationData = Buffer.concat( + [authenticatorData, hash(clientDataJSON)], + 32 + authenticatorData.length + ); + + return crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(publicKey), signature); +} + +export const procedures = { + none: { + verify({publicKey}: {publicKey: Map<number, Buffer>}) { + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length != 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length != 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyU2F = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32 + ); + + return { + publicKey: publicKeyU2F, + valid: true + }; + } + }, + 'android-key': { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map<number, any>; + rpIdHash: Buffer, + credentialId: Buffer, + }) { + if (attStmt.alg != -7) { + throw new Error('alg mismatch'); + } + + const verificationData = Buffer.concat([ + authenticatorData, + clientDataHash + ]); + + const attCert: Buffer = attStmt.x5c[0]; + + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length != 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length != 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyData = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32 + ); + + if (!attCert.equals(publicKeyData)) { + throw new Error('public key mismatch'); + } + + const isValid = crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(attCert), attStmt.sig); + + // TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON) + + return { + valid: isValid, + publicKey: publicKeyData + }; + } + }, + // what a stupid attestation + 'android-safetynet': { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map<number, any>; + rpIdHash: Buffer, + credentialId: Buffer, + }) { + const verificationData = hash( + Buffer.concat([authenticatorData, clientDataHash]) + ); + + const jwsParts = attStmt.response.toString('utf-8').split('.'); + + const header = JSON.parse(base64URLDecode(jwsParts[0]).toString('utf-8')); + const response = JSON.parse( + base64URLDecode(jwsParts[1]).toString('utf-8') + ); + const signature = jwsParts[2]; + + if (!verificationData.equals(Buffer.from(response.nonce, 'base64'))) { + throw new Error('invalid nonce'); + } + + const certificateChain = header.x5c + .map((key: any) => PEMString(key)) + .concat([GSR2]); + + if (getCertSubject(certificateChain[0]).CN != 'attest.android.com') { + throw new Error('invalid common name'); + } + + if (!verifyCertificateChain(certificateChain)) { + throw new Error('Invalid certificate chain!'); + } + + const signatureBase = Buffer.from( + jwsParts[0] + '.' + jwsParts[1], + 'utf-8' + ); + + const valid = crypto + .createVerify('sha256') + .update(signatureBase) + .verify(certificateChain[0], base64URLDecode(signature)); + + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length != 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length != 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyData = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32 + ); + return { + valid, + publicKey: publicKeyData + }; + } + }, + packed: { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map<number, any>; + rpIdHash: Buffer, + credentialId: Buffer, + }) { + const verificationData = Buffer.concat([ + authenticatorData, + clientDataHash + ]); + + if (attStmt.x5c) { + const attCert = attStmt.x5c[0]; + + const validSignature = crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(attCert), attStmt.sig); + + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length != 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length != 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyData = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32 + ); + + return { + valid: validSignature, + publicKey: publicKeyData + }; + } else if (attStmt.ecdaaKeyId) { + // https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-ecdaa-algorithm-v2.0-id-20180227.html#ecdaa-verify-operation + throw new Error('ECDAA-Verify is not supported'); + } else { + if (attStmt.alg != -7) throw new Error('alg mismatch'); + + throw new Error('self attestation is not supported'); + } + } + }, + + 'fido-u2f': { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map<number, any>, + rpIdHash: Buffer, + credentialId: Buffer + }) { + const x5c: Buffer[] = attStmt.x5c; + if (x5c.length != 1) { + throw new Error('x5c length does not match expectation'); + } + + const attCert = x5c[0]; + + // TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve + + const negTwo: Buffer = publicKey.get(-2); + + if (!negTwo || negTwo.length != 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree: Buffer = publicKey.get(-3); + if (!negThree || negThree.length != 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyU2F = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32 + ); + + const verificationData = Buffer.concat([ + NULL_BYTE, + rpIdHash, + clientDataHash, + credentialId, + publicKeyU2F + ]); + + const validSignature = crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(attCert), attStmt.sig); + + return { + valid: validSignature, + publicKey: publicKeyU2F + }; + } + } +}; diff --git a/packages/backend/src/server/api/api-handler.ts b/packages/backend/src/server/api/api-handler.ts new file mode 100644 index 0000000000..cbace8917e --- /dev/null +++ b/packages/backend/src/server/api/api-handler.ts @@ -0,0 +1,51 @@ +import * as Koa from 'koa'; + +import { IEndpoint } from './endpoints'; +import authenticate, { AuthenticationError } from './authenticate'; +import call from './call'; +import { ApiError } from './error'; + +export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise((res) => { + const body = ctx.request.body; + + const reply = (x?: any, y?: ApiError) => { + if (x == null) { + ctx.status = 204; + } else if (typeof x === 'number' && y) { + ctx.status = x; + ctx.body = { + error: { + message: y!.message, + code: y!.code, + id: y!.id, + kind: y!.kind, + ...(y!.info ? { info: y!.info } : {}) + } + }; + } else { + // 文字列を返す場合は、JSON.stringify通さないとJSONと認識されない + ctx.body = typeof x === 'string' ? JSON.stringify(x) : x; + } + res(); + }; + + // Authentication + authenticate(body['i']).then(([user, app]) => { + // API invoking + call(endpoint.name, user, app, body, (ctx as any).file).then((res: any) => { + reply(res); + }).catch((e: ApiError) => { + reply(e.httpStatusCode ? e.httpStatusCode : e.kind === 'client' ? 400 : 500, e); + }); + }).catch(e => { + if (e instanceof AuthenticationError) { + reply(403, new ApiError({ + message: 'Authentication failed. Please ensure your token is correct.', + code: 'AUTHENTICATION_FAILED', + id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14' + })); + } else { + reply(500, new ApiError()); + } + }); +}); diff --git a/packages/backend/src/server/api/authenticate.ts b/packages/backend/src/server/api/authenticate.ts new file mode 100644 index 0000000000..b8e216edc4 --- /dev/null +++ b/packages/backend/src/server/api/authenticate.ts @@ -0,0 +1,62 @@ +import isNativeToken from './common/is-native-token'; +import { User } from '@/models/entities/user'; +import { Users, AccessTokens, Apps } from '@/models/index'; +import { AccessToken } from '@/models/entities/access-token'; + +export class AuthenticationError extends Error { + constructor(message: string) { + super(message); + this.name = 'AuthenticationError'; + } +} + +export default async (token: string): Promise<[User | null | undefined, App | null | undefined]> => { + if (token == null) { + return [null, null]; + } + + if (isNativeToken(token)) { + // Fetch user + const user = await Users + .findOne({ token }); + + if (user == null) { + throw new AuthenticationError('user not found'); + } + + return [user, null]; + } else { + const accessToken = await AccessTokens.findOne({ + where: [{ + hash: token.toLowerCase() // app + }, { + token: token // miauth + }], + }); + + if (accessToken == null) { + throw new AuthenticationError('invalid signature'); + } + + AccessTokens.update(accessToken.id, { + lastUsedAt: new Date(), + }); + + const user = await Users + .findOne({ + id: accessToken.userId // findOne(accessToken.userId) のように書かないのは後方互換性のため + }); + + if (accessToken.appId) { + const app = await Apps + .findOneOrFail(accessToken.appId); + + return [user, { + id: accessToken.id, + permission: app.permission + } as AccessToken]; + } else { + return [user, accessToken]; + } + } +}; diff --git a/packages/backend/src/server/api/call.ts b/packages/backend/src/server/api/call.ts new file mode 100644 index 0000000000..bd86ffdc35 --- /dev/null +++ b/packages/backend/src/server/api/call.ts @@ -0,0 +1,109 @@ +import { performance } from 'perf_hooks'; +import limiter from './limiter'; +import { User } from '@/models/entities/user'; +import endpoints from './endpoints'; +import { ApiError } from './error'; +import { apiLogger } from './logger'; +import { AccessToken } from '@/models/entities/access-token'; + +const accessDenied = { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '56f35758-7dd5-468b-8439-5d6fb8ec9b8e' +}; + +export default async (endpoint: string, user: User | null | undefined, token: AccessToken | null | undefined, data: any, file?: any) => { + const isSecure = user != null && token == null; + + const ep = endpoints.find(e => e.name === endpoint); + + if (ep == null) { + throw new ApiError({ + message: 'No such endpoint.', + code: 'NO_SUCH_ENDPOINT', + id: 'f8080b67-5f9c-4eb7-8c18-7f1eeae8f709', + httpStatusCode: 404 + }); + } + + if (ep.meta.secure && !isSecure) { + throw new ApiError(accessDenied); + } + + if (ep.meta.requireCredential && user == null) { + throw new ApiError({ + message: 'Credential required.', + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + httpStatusCode: 401 + }); + } + + if (ep.meta.requireCredential && user!.isSuspended) { + throw new ApiError({ + message: 'Your account has been suspended.', + code: 'YOUR_ACCOUNT_SUSPENDED', + id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370', + httpStatusCode: 403 + }); + } + + if (ep.meta.requireAdmin && !user!.isAdmin) { + throw new ApiError(accessDenied, { reason: 'You are not the admin.' }); + } + + if (ep.meta.requireModerator && !user!.isAdmin && !user!.isModerator) { + throw new ApiError(accessDenied, { reason: 'You are not a moderator.' }); + } + + if (token && ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) { + throw new ApiError({ + message: 'Your app does not have the necessary permissions to use this endpoint.', + code: 'PERMISSION_DENIED', + id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', + }); + } + + if (ep.meta.requireCredential && ep.meta.limit && !user!.isAdmin && !user!.isModerator) { + // Rate limit + await limiter(ep, user!).catch(e => { + throw new ApiError({ + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', + httpStatusCode: 429 + }); + }); + } + + // API invoking + const before = performance.now(); + return await ep.exec(data, user, token, file).catch((e: Error) => { + if (e instanceof ApiError) { + throw e; + } else { + apiLogger.error(`Internal error occurred in ${ep.name}: ${e?.message}`, { + ep: ep.name, + ps: data, + e: { + message: e?.message, + code: e?.name, + stack: e?.stack + } + }); + throw new ApiError(null, { + e: { + message: e?.message, + code: e?.name, + stack: e?.stack + } + }); + } + }).finally(() => { + const after = performance.now(); + const time = after - before; + if (time > 1000) { + apiLogger.warn(`SLOW API CALL DETECTED: ${ep.name} (${time}ms)`); + } + }); +}; diff --git a/packages/backend/src/server/api/common/generate-block-query.ts b/packages/backend/src/server/api/common/generate-block-query.ts new file mode 100644 index 0000000000..4fd6184738 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-block-query.ts @@ -0,0 +1,42 @@ +import { User } from '@/models/entities/user'; +import { Blockings } from '@/models/index'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; + +// ここでいうBlockedは被Blockedの意 +export function generateBlockedUserQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }) { + const blockingQuery = Blockings.createQueryBuilder('blocking') + .select('blocking.blockerId') + .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); + + // 投稿の作者にブロックされていない かつ + // 投稿の返信先の作者にブロックされていない かつ + // 投稿の引用元の作者にブロックされていない + q + .andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`) + .andWhere(new Brackets(qb => { qb + .where(`note.replyUserId IS NULL`) + .orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`); + })) + .andWhere(new Brackets(qb => { qb + .where(`note.renoteUserId IS NULL`) + .orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`); + })); + + q.setParameters(blockingQuery.getParameters()); +} + +export function generateBlockQueryForUsers(q: SelectQueryBuilder<any>, me: { id: User['id'] }) { + const blockingQuery = Blockings.createQueryBuilder('blocking') + .select('blocking.blockeeId') + .where('blocking.blockerId = :blockerId', { blockerId: me.id }); + + const blockedQuery = Blockings.createQueryBuilder('blocking') + .select('blocking.blockerId') + .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); + + q.andWhere(`user.id NOT IN (${ blockingQuery.getQuery() })`); + q.setParameters(blockingQuery.getParameters()); + + q.andWhere(`user.id NOT IN (${ blockedQuery.getQuery() })`); + q.setParameters(blockedQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/common/generate-channel-query.ts b/packages/backend/src/server/api/common/generate-channel-query.ts new file mode 100644 index 0000000000..80a0acf7f9 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-channel-query.ts @@ -0,0 +1,24 @@ +import { User } from '@/models/entities/user'; +import { ChannelFollowings } from '@/models/index'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; + +export function generateChannelQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null) { + if (me == null) { + q.andWhere('note.channelId IS NULL'); + } else { + q.leftJoinAndSelect('note.channel', 'channel'); + + const channelFollowingQuery = ChannelFollowings.createQueryBuilder('channelFollowing') + .select('channelFollowing.followeeId') + .where('channelFollowing.followerId = :followerId', { followerId: me.id }); + + q.andWhere(new Brackets(qb => { qb + // チャンネルのノートではない + .where('note.channelId IS NULL') + // または自分がフォローしているチャンネルのノート + .orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`); + })); + + q.setParameters(channelFollowingQuery.getParameters()); + } +} diff --git a/packages/backend/src/server/api/common/generate-muted-note-query.ts b/packages/backend/src/server/api/common/generate-muted-note-query.ts new file mode 100644 index 0000000000..0737842613 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-muted-note-query.ts @@ -0,0 +1,13 @@ +import { User } from '@/models/entities/user'; +import { MutedNotes } from '@/models/index'; +import { SelectQueryBuilder } from 'typeorm'; + +export function generateMutedNoteQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }) { + const mutedQuery = MutedNotes.createQueryBuilder('muted') + .select('muted.noteId') + .where('muted.userId = :userId', { userId: me.id }); + + q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); + + q.setParameters(mutedQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/common/generate-muted-note-thread-query.ts b/packages/backend/src/server/api/common/generate-muted-note-thread-query.ts new file mode 100644 index 0000000000..7e2cbd498b --- /dev/null +++ b/packages/backend/src/server/api/common/generate-muted-note-thread-query.ts @@ -0,0 +1,17 @@ +import { User } from '@/models/entities/user'; +import { NoteThreadMutings } from '@/models/index'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; + +export function generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }) { + const mutedQuery = NoteThreadMutings.createQueryBuilder('threadMuted') + .select('threadMuted.threadId') + .where('threadMuted.userId = :userId', { userId: me.id }); + + q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); + q.andWhere(new Brackets(qb => { qb + .where(`note.threadId IS NULL`) + .orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`); + })); + + q.setParameters(mutedQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/common/generate-muted-user-query.ts b/packages/backend/src/server/api/common/generate-muted-user-query.ts new file mode 100644 index 0000000000..7e200b87ef --- /dev/null +++ b/packages/backend/src/server/api/common/generate-muted-user-query.ts @@ -0,0 +1,40 @@ +import { User } from '@/models/entities/user'; +import { Mutings } from '@/models/index'; +import { SelectQueryBuilder, Brackets } from 'typeorm'; + +export function generateMutedUserQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }, exclude?: User) { + const mutingQuery = Mutings.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: me.id }); + + if (exclude) { + mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id }); + } + + // 投稿の作者をミュートしていない かつ + // 投稿の返信先の作者をミュートしていない かつ + // 投稿の引用元の作者をミュートしていない + q + .andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`) + .andWhere(new Brackets(qb => { qb + .where(`note.replyUserId IS NULL`) + .orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`); + })) + .andWhere(new Brackets(qb => { qb + .where(`note.renoteUserId IS NULL`) + .orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`); + })); + + q.setParameters(mutingQuery.getParameters()); +} + +export function generateMutedUserQueryForUsers(q: SelectQueryBuilder<any>, me: { id: User['id'] }) { + const mutingQuery = Mutings.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: me.id }); + + q + .andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`); + + q.setParameters(mutingQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/common/generate-native-user-token.ts b/packages/backend/src/server/api/common/generate-native-user-token.ts new file mode 100644 index 0000000000..1f791c57ce --- /dev/null +++ b/packages/backend/src/server/api/common/generate-native-user-token.ts @@ -0,0 +1,3 @@ +import { secureRndstr } from '@/misc/secure-rndstr'; + +export default () => secureRndstr(16, true); diff --git a/packages/backend/src/server/api/common/generate-replies-query.ts b/packages/backend/src/server/api/common/generate-replies-query.ts new file mode 100644 index 0000000000..fbc41b2c25 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-replies-query.ts @@ -0,0 +1,27 @@ +import { User } from '@/models/entities/user'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; + +export function generateRepliesQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null) { + if (me == null) { + q.andWhere(new Brackets(qb => { qb + .where(`note.replyId IS NULL`) // 返信ではない + .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信 + .where(`note.replyId IS NOT NULL`) + .andWhere('note.replyUserId = note.userId'); + })); + })); + } else { + q.andWhere(new Brackets(qb => { qb + .where(`note.replyId IS NULL`) // 返信ではない + .orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信 + .orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信 + .where(`note.replyId IS NOT NULL`) + .andWhere('note.userId = :meId', { meId: me.id }); + })) + .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信 + .where(`note.replyId IS NOT NULL`) + .andWhere('note.replyUserId = note.userId'); + })); + })); + } +} diff --git a/packages/backend/src/server/api/common/generate-visibility-query.ts b/packages/backend/src/server/api/common/generate-visibility-query.ts new file mode 100644 index 0000000000..813e8b6c09 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-visibility-query.ts @@ -0,0 +1,40 @@ +import { User } from '@/models/entities/user'; +import { Followings } from '@/models/index'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; + +export function generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null) { + if (me == null) { + q.andWhere(new Brackets(qb => { qb + .where(`note.visibility = 'public'`) + .orWhere(`note.visibility = 'home'`); + })); + } else { + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); + + q.andWhere(new Brackets(qb => { qb + // 公開投稿である + .where(new Brackets(qb => { qb + .where(`note.visibility = 'public'`) + .orWhere(`note.visibility = 'home'`); + })) + // または 自分自身 + .orWhere('note.userId = :userId1', { userId1: me.id }) + // または 自分宛て + .orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`) + .orWhere(new Brackets(qb => { qb + // または フォロワー宛ての投稿であり、 + .where('note.visibility = \'followers\'') + .andWhere(new Brackets(qb => { qb + // 自分がフォロワーである + .where(`note.userId IN (${ followingQuery.getQuery() })`) + // または 自分の投稿へのリプライ + .orWhere('note.replyUserId = :userId3', { userId3: me.id }); + })); + })); + })); + + q.setParameters(followingQuery.getParameters()); + } +} diff --git a/packages/backend/src/server/api/common/getters.ts b/packages/backend/src/server/api/common/getters.ts new file mode 100644 index 0000000000..4b2ee8f1da --- /dev/null +++ b/packages/backend/src/server/api/common/getters.ts @@ -0,0 +1,56 @@ +import { IdentifiableError } from '@/misc/identifiable-error'; +import { User } from '@/models/entities/user'; +import { Note } from '@/models/entities/note'; +import { Notes, Users } from '@/models/index'; + +/** + * Get note for API processing + */ +export async function getNote(noteId: Note['id']) { + const note = await Notes.findOne(noteId); + + if (note == null) { + throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.'); + } + + return note; +} + +/** + * Get user for API processing + */ +export async function getUser(userId: User['id']) { + const user = await Users.findOne(userId); + + if (user == null) { + throw new IdentifiableError('15348ddd-432d-49c2-8a5a-8069753becff', 'No such user.'); + } + + return user; +} + +/** + * Get remote user for API processing + */ +export async function getRemoteUser(userId: User['id']) { + const user = await getUser(userId); + + if (!Users.isRemoteUser(user)) { + throw new Error('user is not a remote user'); + } + + return user; +} + +/** + * Get local user for API processing + */ +export async function getLocalUser(userId: User['id']) { + const user = await getUser(userId); + + if (!Users.isLocalUser(user)) { + throw new Error('user is not a local user'); + } + + return user; +} diff --git a/packages/backend/src/server/api/common/inject-featured.ts b/packages/backend/src/server/api/common/inject-featured.ts new file mode 100644 index 0000000000..1dc13c83ef --- /dev/null +++ b/packages/backend/src/server/api/common/inject-featured.ts @@ -0,0 +1,56 @@ +import rndstr from 'rndstr'; +import { Note } from '@/models/entities/note'; +import { User } from '@/models/entities/user'; +import { Notes, UserProfiles, NoteReactions } from '@/models/index'; +import { generateMutedUserQuery } from './generate-muted-user-query'; +import { generateBlockedUserQuery } from './generate-block-query'; + +// TODO: リアクション、Renote、返信などをしたノートは除外する + +export async function injectFeatured(timeline: Note[], user?: User | null) { + if (timeline.length < 5) return; + + if (user) { + const profile = await UserProfiles.findOneOrFail(user.id); + if (!profile.injectFeaturedNote) return; + } + + const max = 30; + const day = 1000 * 60 * 60 * 24 * 3; // 3日前まで + + const query = Notes.createQueryBuilder('note') + .addSelect('note.score') + .where('note.userHost IS NULL') + .andWhere(`note.score > 0`) + .andWhere(`note.createdAt > :date`, { date: new Date(Date.now() - day) }) + .andWhere(`note.visibility = 'public'`) + .innerJoinAndSelect('note.user', 'user'); + + if (user) { + query.andWhere('note.userId != :userId', { userId: user.id }); + + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + + const reactionQuery = NoteReactions.createQueryBuilder('reaction') + .select('reaction.noteId') + .where('reaction.userId = :userId', { userId: user.id }); + + query.andWhere(`note.id NOT IN (${ reactionQuery.getQuery() })`); + } + + const notes = await query + .orderBy('note.score', 'DESC') + .take(max) + .getMany(); + + if (notes.length === 0) return; + + // Pick random one + const featured = notes[Math.floor(Math.random() * notes.length)]; + + (featured as any)._featuredId_ = rndstr('a-z0-9', 8); + + // Inject featured + timeline.splice(3, 0, featured); +} diff --git a/packages/backend/src/server/api/common/inject-promo.ts b/packages/backend/src/server/api/common/inject-promo.ts new file mode 100644 index 0000000000..87767a65bf --- /dev/null +++ b/packages/backend/src/server/api/common/inject-promo.ts @@ -0,0 +1,34 @@ +import rndstr from 'rndstr'; +import { Note } from '@/models/entities/note'; +import { User } from '@/models/entities/user'; +import { PromoReads, PromoNotes, Notes, Users } from '@/models/index'; + +export async function injectPromo(timeline: Note[], user?: User | null) { + if (timeline.length < 5) return; + + // TODO: readやexpireフィルタはクエリ側でやる + + const reads = user ? await PromoReads.find({ + userId: user.id + }) : []; + + let promos = await PromoNotes.find(); + + promos = promos.filter(n => n.expiresAt.getTime() > Date.now()); + promos = promos.filter(n => !reads.map(r => r.noteId).includes(n.noteId)); + + if (promos.length === 0) return; + + // Pick random promo + const promo = promos[Math.floor(Math.random() * promos.length)]; + + const note = await Notes.findOneOrFail(promo.noteId); + + // Join + note.user = await Users.findOneOrFail(note.userId); + + (note as any)._prId_ = rndstr('a-z0-9', 8); + + // Inject promo + timeline.splice(3, 0, note); +} diff --git a/packages/backend/src/server/api/common/is-native-token.ts b/packages/backend/src/server/api/common/is-native-token.ts new file mode 100644 index 0000000000..2833c570c8 --- /dev/null +++ b/packages/backend/src/server/api/common/is-native-token.ts @@ -0,0 +1 @@ +export default (token: string) => token.length === 16; diff --git a/packages/backend/src/server/api/common/make-pagination-query.ts b/packages/backend/src/server/api/common/make-pagination-query.ts new file mode 100644 index 0000000000..51c11e5dff --- /dev/null +++ b/packages/backend/src/server/api/common/make-pagination-query.ts @@ -0,0 +1,28 @@ +import { SelectQueryBuilder } from 'typeorm'; + +export function makePaginationQuery<T>(q: SelectQueryBuilder<T>, sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number) { + if (sinceId && untilId) { + q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); + q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); + q.orderBy(`${q.alias}.id`, 'DESC'); + } else if (sinceId) { + q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); + q.orderBy(`${q.alias}.id`, 'ASC'); + } else if (untilId) { + q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); + q.orderBy(`${q.alias}.id`, 'DESC'); + } else if (sinceDate && untilDate) { + q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) }); + q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) }); + q.orderBy(`${q.alias}.createdAt`, 'DESC'); + } else if (sinceDate) { + q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) }); + q.orderBy(`${q.alias}.createdAt`, 'ASC'); + } else if (untilDate) { + q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) }); + q.orderBy(`${q.alias}.createdAt`, 'DESC'); + } else { + q.orderBy(`${q.alias}.id`, 'DESC'); + } + return q; +} diff --git a/packages/backend/src/server/api/common/read-messaging-message.ts b/packages/backend/src/server/api/common/read-messaging-message.ts new file mode 100644 index 0000000000..33f41b2770 --- /dev/null +++ b/packages/backend/src/server/api/common/read-messaging-message.ts @@ -0,0 +1,122 @@ +import { publishMainStream, publishGroupMessagingStream } from '@/services/stream'; +import { publishMessagingStream } from '@/services/stream'; +import { publishMessagingIndexStream } from '@/services/stream'; +import { User, IRemoteUser } from '@/models/entities/user'; +import { MessagingMessage } from '@/models/entities/messaging-message'; +import { MessagingMessages, UserGroupJoinings, Users } from '@/models/index'; +import { In } from 'typeorm'; +import { IdentifiableError } from '@/misc/identifiable-error'; +import { UserGroup } from '@/models/entities/user-group'; +import { toArray } from '@/prelude/array'; +import { renderReadActivity } from '@/remote/activitypub/renderer/read'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import { deliver } from '@/queue/index'; +import orderedCollection from '@/remote/activitypub/renderer/ordered-collection'; + +/** + * Mark messages as read + */ +export async function readUserMessagingMessage( + userId: User['id'], + otherpartyId: User['id'], + messageIds: MessagingMessage['id'][] +) { + if (messageIds.length === 0) return; + + const messages = await MessagingMessages.find({ + id: In(messageIds) + }); + + for (const message of messages) { + if (message.recipientId !== userId) { + throw new IdentifiableError('e140a4bf-49ce-4fb6-b67c-b78dadf6b52f', 'Access denied (user).'); + } + } + + // Update documents + await MessagingMessages.update({ + id: In(messageIds), + userId: otherpartyId, + recipientId: userId, + isRead: false + }, { + isRead: true + }); + + // Publish event + publishMessagingStream(otherpartyId, userId, 'read', messageIds); + publishMessagingIndexStream(userId, 'read', messageIds); + + if (!await Users.getHasUnreadMessagingMessage(userId)) { + // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 + publishMainStream(userId, 'readAllMessagingMessages'); + } +} + +/** + * Mark messages as read + */ +export async function readGroupMessagingMessage( + userId: User['id'], + groupId: UserGroup['id'], + messageIds: MessagingMessage['id'][] +) { + if (messageIds.length === 0) return; + + // check joined + const joining = await UserGroupJoinings.findOne({ + userId: userId, + userGroupId: groupId + }); + + if (joining == null) { + throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (group).'); + } + + const messages = await MessagingMessages.find({ + id: In(messageIds) + }); + + const reads: MessagingMessage['id'][] = []; + + for (const message of messages) { + if (message.userId === userId) continue; + if (message.reads.includes(userId)) continue; + + // Update document + await MessagingMessages.createQueryBuilder().update() + .set({ + reads: (() => `array_append("reads", '${joining.userId}')`) as any + }) + .where('id = :id', { id: message.id }) + .execute(); + + reads.push(message.id); + } + + // Publish event + publishGroupMessagingStream(groupId, 'read', { + ids: reads, + userId: userId + }); + publishMessagingIndexStream(userId, 'read', reads); + + if (!await Users.getHasUnreadMessagingMessage(userId)) { + // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 + publishMainStream(userId, 'readAllMessagingMessages'); + } +} + +export async function deliverReadActivity(user: { id: User['id']; host: null; }, recipient: IRemoteUser, messages: MessagingMessage | MessagingMessage[]) { + messages = toArray(messages).filter(x => x.uri); + const contents = messages.map(x => renderReadActivity(user, x)); + + if (contents.length > 1) { + const collection = orderedCollection(null, contents.length, undefined, undefined, contents); + deliver(user, renderActivity(collection), recipient.inbox); + } else { + for (const content of contents) { + deliver(user, renderActivity(content), recipient.inbox); + } + } +} diff --git a/packages/backend/src/server/api/common/read-notification.ts b/packages/backend/src/server/api/common/read-notification.ts new file mode 100644 index 0000000000..a4406c9eeb --- /dev/null +++ b/packages/backend/src/server/api/common/read-notification.ts @@ -0,0 +1,43 @@ +import { publishMainStream } from '@/services/stream'; +import { User } from '@/models/entities/user'; +import { Notification } from '@/models/entities/notification'; +import { Notifications, Users } from '@/models/index'; +import { In } from 'typeorm'; + +export async function readNotification( + userId: User['id'], + notificationIds: Notification['id'][] +) { + // Update documents + await Notifications.update({ + id: In(notificationIds), + isRead: false + }, { + isRead: true + }); + + post(userId); +} + +export async function readNotificationByQuery( + userId: User['id'], + query: Record<string, any> +) { + // Update documents + await Notifications.update({ + ...query, + notifieeId: userId, + isRead: false + }, { + isRead: true + }); + + post(userId); +} + +async function post(userId: User['id']) { + if (!await Users.getHasUnreadNotification(userId)) { + // 全ての(いままで未読だった)通知を(これで)読みましたよというイベントを発行 + publishMainStream(userId, 'readAllNotifications'); + } +} diff --git a/packages/backend/src/server/api/common/signin.ts b/packages/backend/src/server/api/common/signin.ts new file mode 100644 index 0000000000..4c7aacf1cd --- /dev/null +++ b/packages/backend/src/server/api/common/signin.ts @@ -0,0 +1,44 @@ +import * as Koa from 'koa'; + +import config from '@/config/index'; +import { ILocalUser } from '@/models/entities/user'; +import { Signins } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { publishMainStream } from '@/services/stream'; + +export default function(ctx: Koa.Context, user: ILocalUser, redirect = false) { + if (redirect) { + //#region Cookie + ctx.cookies.set('igi', user.token, { + path: '/', + // SEE: https://github.com/koajs/koa/issues/974 + // When using a SSL proxy it should be configured to add the "X-Forwarded-Proto: https" header + secure: config.url.startsWith('https'), + httpOnly: false + }); + //#endregion + + ctx.redirect(config.url); + } else { + ctx.body = { + id: user.id, + i: user.token + }; + ctx.status = 200; + } + + (async () => { + // Append signin history + const record = await Signins.save({ + id: genId(), + createdAt: new Date(), + userId: user.id, + ip: ctx.ip, + headers: ctx.headers, + success: true + }); + + // Publish signin event + publishMainStream(user.id, 'signin', await Signins.pack(record)); + })(); +} diff --git a/packages/backend/src/server/api/common/signup.ts b/packages/backend/src/server/api/common/signup.ts new file mode 100644 index 0000000000..2ba0d8e479 --- /dev/null +++ b/packages/backend/src/server/api/common/signup.ts @@ -0,0 +1,113 @@ +import * as bcrypt from 'bcryptjs'; +import { generateKeyPair } from 'crypto'; +import generateUserToken from './generate-native-user-token'; +import { User } from '@/models/entities/user'; +import { Users, UsedUsernames } from '@/models/index'; +import { UserProfile } from '@/models/entities/user-profile'; +import { getConnection } from 'typeorm'; +import { genId } from '@/misc/gen-id'; +import { toPunyNullable } from '@/misc/convert-host'; +import { UserKeypair } from '@/models/entities/user-keypair'; +import { usersChart } from '@/services/chart/index'; +import { UsedUsername } from '@/models/entities/used-username'; + +export async function signup(opts: { + username: User['username']; + password?: string | null; + passwordHash?: UserProfile['password'] | null; + host?: string | null; +}) { + const { username, password, passwordHash, host } = opts; + let hash = passwordHash; + + // Validate username + if (!Users.validateLocalUsername.ok(username)) { + throw new Error('INVALID_USERNAME'); + } + + if (password != null && passwordHash == null) { + // Validate password + if (!Users.validatePassword.ok(password)) { + throw new Error('INVALID_PASSWORD'); + } + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + hash = await bcrypt.hash(password, salt); + } + + // Generate secret + const secret = generateUserToken(); + + // Check username duplication + if (await Users.findOne({ usernameLower: username.toLowerCase(), host: null })) { + throw new Error('DUPLICATED_USERNAME'); + } + + // Check deleted username duplication + if (await UsedUsernames.findOne({ username: username.toLowerCase() })) { + throw new Error('USED_USERNAME'); + } + + const keyPair = await new Promise<string[]>((res, rej) => + generateKeyPair('rsa', { + modulusLength: 4096, + publicKeyEncoding: { + type: 'spki', + format: 'pem' + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: undefined, + passphrase: undefined + } + } as any, (err, publicKey, privateKey) => + err ? rej(err) : res([publicKey, privateKey]) + )); + + let account!: User; + + // Start transaction + await getConnection().transaction(async transactionalEntityManager => { + const exist = await transactionalEntityManager.findOne(User, { + usernameLower: username.toLowerCase(), + host: null + }); + + if (exist) throw new Error(' the username is already used'); + + account = await transactionalEntityManager.save(new User({ + id: genId(), + createdAt: new Date(), + username: username, + usernameLower: username.toLowerCase(), + host: toPunyNullable(host), + token: secret, + isAdmin: (await Users.count({ + host: null, + })) === 0, + })); + + await transactionalEntityManager.save(new UserKeypair({ + publicKey: keyPair[0], + privateKey: keyPair[1], + userId: account.id + })); + + await transactionalEntityManager.save(new UserProfile({ + userId: account.id, + autoAcceptFollowed: true, + password: hash, + })); + + await transactionalEntityManager.save(new UsedUsername({ + createdAt: new Date(), + username: username.toLowerCase(), + })); + }); + + usersChart.update(account, true); + + return { account, secret }; +} diff --git a/packages/backend/src/server/api/define.ts b/packages/backend/src/server/api/define.ts new file mode 100644 index 0000000000..4bd8f95e31 --- /dev/null +++ b/packages/backend/src/server/api/define.ts @@ -0,0 +1,87 @@ +import * as fs from 'fs'; +import { ILocalUser } from '@/models/entities/user'; +import { IEndpointMeta } from './endpoints'; +import { ApiError } from './error'; +import { SchemaType } from '@/misc/schema'; +import { AccessToken } from '@/models/entities/access-token'; + +type NonOptional<T> = T extends undefined ? never : T; + +type SimpleUserInfo = { + id: ILocalUser['id']; + host: ILocalUser['host']; + username: ILocalUser['username']; + uri: ILocalUser['uri']; + inbox: ILocalUser['inbox']; + sharedInbox: ILocalUser['sharedInbox']; + isAdmin: ILocalUser['isAdmin']; + isModerator: ILocalUser['isModerator']; + isSilenced: ILocalUser['isSilenced']; +}; + +type Params<T extends IEndpointMeta> = { + [P in keyof T['params']]: NonNullable<T['params']>[P]['transform'] extends Function + ? ReturnType<NonNullable<T['params']>[P]['transform']> + : NonNullable<T['params']>[P]['default'] extends null | number | string + ? NonOptional<ReturnType<NonNullable<T['params']>[P]['validator']['get']>[0]> + : ReturnType<NonNullable<T['params']>[P]['validator']['get']>[0]; +}; + +export type Response = Record<string, any> | void; + +type executor<T extends IEndpointMeta> = + (params: Params<T>, user: T['requireCredential'] extends true ? SimpleUserInfo : SimpleUserInfo | null, token: AccessToken | null, file?: any, cleanup?: Function) => + Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>; + +export default function <T extends IEndpointMeta>(meta: T, cb: executor<T>) + : (params: any, user: T['requireCredential'] extends true ? SimpleUserInfo : SimpleUserInfo | null, token: AccessToken | null, file?: any) => Promise<any> { + return (params: any, user: T['requireCredential'] extends true ? SimpleUserInfo : SimpleUserInfo | null, token: AccessToken | null, file?: any) => { + function cleanup() { + fs.unlink(file.path, () => {}); + } + + if (meta.requireFile && file == null) return Promise.reject(new ApiError({ + message: 'File required.', + code: 'FILE_REQUIRED', + id: '4267801e-70d1-416a-b011-4ee502885d8b', + })); + + const [ps, pserr] = getParams(meta, params); + if (pserr) { + if (file) cleanup(); + return Promise.reject(pserr); + } + + return cb(ps, user, token, file, cleanup); + }; +} + +function getParams<T extends IEndpointMeta>(defs: T, params: any): [Params<T>, ApiError | null] { + if (defs.params == null) return [params, null]; + + const x: any = {}; + let err: ApiError | null = null; + Object.entries(defs.params).some(([k, def]) => { + const [v, e] = def.validator.get(params[k]); + if (e) { + err = new ApiError({ + message: 'Invalid param.', + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + }, { + param: k, + reason: e.message + }); + return true; + } else { + if (v === undefined && def.hasOwnProperty('default')) { + x[k] = def.default; + } else { + x[k] = v; + } + if (def.transform) x[k] = def.transform(x[k]); + return false; + } + }); + return [x, err]; +} diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts new file mode 100644 index 0000000000..6d9d2b0782 --- /dev/null +++ b/packages/backend/src/server/api/endpoints.ts @@ -0,0 +1,124 @@ +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import { Context } from 'cafy'; +import * as path from 'path'; +import * as glob from 'glob'; +import { SimpleSchema } from '@/misc/simple-schema'; + +//const _filename = fileURLToPath(import.meta.url); +const _filename = __filename; +const _dirname = dirname(_filename); + +export type Param = { + validator: Context<any>; + transform?: any; + default?: any; + deprecated?: boolean; + ref?: string; +}; + +export interface IEndpointMeta { + stability?: string; //'deprecated' | 'experimental' | 'stable'; + + tags?: string[]; + + params?: { + [key: string]: Param; + }; + + errors?: { + [key: string]: { + message: string; + code: string; + id: string; + }; + }; + + res?: SimpleSchema; + + /** + * このエンドポイントにリクエストするのにユーザー情報が必須か否か + * 省略した場合は false として解釈されます。 + */ + requireCredential?: boolean; + + /** + * 管理者のみ使えるエンドポイントか否か + */ + requireAdmin?: boolean; + + /** + * 管理者またはモデレーターのみ使えるエンドポイントか否か + */ + requireModerator?: boolean; + + /** + * エンドポイントのリミテーションに関するやつ + * 省略した場合はリミテーションは無いものとして解釈されます。 + * また、withCredential が false の場合はリミテーションを行うことはできません。 + */ + limit?: { + + /** + * 複数のエンドポイントでリミットを共有したい場合に指定するキー + */ + key?: string; + + /** + * リミットを適用する期間(ms) + * このプロパティを設定する場合、max プロパティも設定する必要があります。 + */ + duration?: number; + + /** + * durationで指定した期間内にいくつまでリクエストできるのか + * このプロパティを設定する場合、duration プロパティも設定する必要があります。 + */ + max?: number; + + /** + * 最低でもどれくらいの間隔を開けてリクエストしなければならないか(ms) + */ + minInterval?: number; + }; + + /** + * ファイルの添付を必要とするか否か + * 省略した場合は false として解釈されます。 + */ + requireFile?: boolean; + + /** + * サードパーティアプリからはリクエストすることができないか否か + * 省略した場合は false として解釈されます。 + */ + secure?: boolean; + + /** + * エンドポイントの種類 + * パーミッションの実現に利用されます。 + */ + kind?: string; +} + +export interface IEndpoint { + name: string; + exec: any; + meta: IEndpointMeta; +} + +const files = glob.sync('**/*.js', { + cwd: path.resolve(_dirname + '/endpoints/') +}); + +const endpoints: IEndpoint[] = files.map(f => { + const ep = require(`./endpoints/${f}`); + + return { + name: f.replace('.js', ''), + exec: ep.default, + meta: ep.meta || {} + }; +}); + +export default endpoints; diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts new file mode 100644 index 0000000000..403eb24191 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts @@ -0,0 +1,134 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { AbuseUserReports } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + state: { + validator: $.optional.nullable.str, + default: null, + }, + + reporterOrigin: { + validator: $.optional.str.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'combined' + }, + + targetUserOrigin: { + validator: $.optional.str.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'combined' + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'date-time', + }, + comment: { + type: 'string' as const, + nullable: false as const, optional: false as const, + }, + resolved: { + type: 'boolean' as const, + nullable: false as const, optional: false as const, + example: false + }, + reporterId: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'id', + }, + targetUserId: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'id', + }, + assigneeId: { + type: 'string' as const, + nullable: true as const, optional: false as const, + format: 'id', + }, + reporter: { + type: 'object' as const, + nullable: false as const, optional: false as const, + ref: 'User' + }, + targetUser: { + type: 'object' as const, + nullable: false as const, optional: false as const, + ref: 'User' + }, + assignee: { + type: 'object' as const, + nullable: true as const, optional: true as const, + ref: 'User' + } + } + } + } +}; + +export default define(meta, async (ps) => { + const query = makePaginationQuery(AbuseUserReports.createQueryBuilder('report'), ps.sinceId, ps.untilId); + + switch (ps.state) { + case 'resolved': query.andWhere('report.resolved = TRUE'); break; + case 'unresolved': query.andWhere('report.resolved = FALSE'); break; + } + + switch (ps.reporterOrigin) { + case 'local': query.andWhere('report.reporterHost IS NULL'); break; + case 'remote': query.andWhere('report.reporterHost IS NOT NULL'); break; + } + + switch (ps.targetUserOrigin) { + case 'local': query.andWhere('report.targetUserHost IS NULL'); break; + case 'remote': query.andWhere('report.targetUserHost IS NOT NULL'); break; + } + + const reports = await query.take(ps.limit!).getMany(); + + return await AbuseUserReports.packMany(reports); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts new file mode 100644 index 0000000000..fa15e84f77 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -0,0 +1,51 @@ +import define from '../../../define'; +import { Users } from '@/models/index'; +import { signup } from '../../../common/signup'; + +export const meta = { + tags: ['admin'], + + params: { + username: { + validator: Users.validateLocalUsername, + }, + + password: { + validator: Users.validatePassword, + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + properties: { + token: { + type: 'string' as const, + optional: false as const, nullable: false as const, + } + } + } +}; + +export default define(meta, async (ps, _me) => { + const me = _me ? await Users.findOneOrFail(_me.id) : null; + const noUsers = (await Users.count({ + host: null, + })) === 0; + if (!noUsers && !me?.isAdmin) throw new Error('access denied'); + + const { account, secret } = await signup({ + username: ps.username, + password: ps.password, + }); + + const res = await Users.pack(account, account, { + detail: true, + includeSecrets: true + }); + + (res as any).token = secret; + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts new file mode 100644 index 0000000000..4e8a559805 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts @@ -0,0 +1,58 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Users } from '@/models/index'; +import { doPostSuspend } from '@/services/suspend-user'; +import { publishUserEvent } from '@/services/stream'; +import { createDeleteAccountJob } from '@/queue'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(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'); + } + + if (Users.isLocalUser(user)) { + // 物理削除する前にDelete activityを送信する + await doPostSuspend(user).catch(e => {}); + + createDeleteAccountJob(user, { + soft: false + }); + } else { + createDeleteAccountJob(user, { + soft: true // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する + }); + } + + await Users.update(user.id, { + isDeleted: true, + }); + + if (Users.isLocalUser(user)) { + // Terminate streaming + publishUserEvent(user.id, 'terminate', {}); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/ad/create.ts b/packages/backend/src/server/api/endpoints/admin/ad/create.ts new file mode 100644 index 0000000000..27c7b5d318 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/ad/create.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Ads } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + url: { + validator: $.str.min(1) + }, + memo: { + validator: $.str + }, + place: { + validator: $.str + }, + priority: { + validator: $.str + }, + ratio: { + validator: $.num.int().min(0) + }, + expiresAt: { + validator: $.num.int() + }, + imageUrl: { + validator: $.str.min(1) + } + }, +}; + +export default define(meta, async (ps) => { + await Ads.insert({ + id: genId(), + createdAt: new Date(), + expiresAt: new Date(ps.expiresAt), + url: ps.url, + imageUrl: ps.imageUrl, + priority: ps.priority, + ratio: ps.ratio, + place: ps.place, + memo: ps.memo, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/ad/delete.ts b/packages/backend/src/server/api/endpoints/admin/ad/delete.ts new file mode 100644 index 0000000000..91934e1aab --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/ad/delete.ts @@ -0,0 +1,34 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { Ads } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + id: { + validator: $.type(ID) + } + }, + + errors: { + noSuchAd: { + message: 'No such ad.', + code: 'NO_SUCH_AD', + id: 'ccac9863-3a03-416e-b899-8a64041118b1' + } + } +}; + +export default define(meta, async (ps, me) => { + const ad = await Ads.findOne(ps.id); + + if (ad == null) throw new ApiError(meta.errors.noSuchAd); + + await Ads.delete(ad.id); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/ad/list.ts b/packages/backend/src/server/api/endpoints/admin/ad/list.ts new file mode 100644 index 0000000000..000aaaba9d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/ad/list.ts @@ -0,0 +1,36 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { Ads } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, +}; + +export default define(meta, async (ps) => { + const query = makePaginationQuery(Ads.createQueryBuilder('ad'), ps.sinceId, ps.untilId) + .andWhere('ad.expiresAt > :now', { now: new Date() }); + + const ads = await query.take(ps.limit!).getMany(); + + return ads; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/ad/update.ts b/packages/backend/src/server/api/endpoints/admin/ad/update.ts new file mode 100644 index 0000000000..36c87895c2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/ad/update.ts @@ -0,0 +1,63 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { Ads } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + id: { + validator: $.type(ID) + }, + memo: { + validator: $.str + }, + url: { + validator: $.str.min(1) + }, + imageUrl: { + validator: $.str.min(1) + }, + place: { + validator: $.str + }, + priority: { + validator: $.str + }, + ratio: { + validator: $.num.int().min(0) + }, + expiresAt: { + validator: $.num.int() + }, + }, + + errors: { + noSuchAd: { + message: 'No such ad.', + code: 'NO_SUCH_AD', + id: 'b7aa1727-1354-47bc-a182-3a9c3973d300' + } + } +}; + +export default define(meta, async (ps, me) => { + const ad = await Ads.findOne(ps.id); + + if (ad == null) throw new ApiError(meta.errors.noSuchAd); + + await Ads.update(ad.id, { + url: ps.url, + place: ps.place, + priority: ps.priority, + ratio: ps.ratio, + memo: ps.memo, + imageUrl: ps.imageUrl, + expiresAt: new Date(ps.expiresAt), + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts new file mode 100644 index 0000000000..f1c07745f9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -0,0 +1,71 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Announcements } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + title: { + validator: $.str.min(1) + }, + text: { + validator: $.str.min(1) + }, + imageUrl: { + validator: $.nullable.str.min(1) + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + updatedAt: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'date-time', + }, + title: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + text: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + imageUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const, + } + } + } +}; + +export default define(meta, async (ps) => { + const announcement = await Announcements.save({ + id: genId(), + createdAt: new Date(), + updatedAt: null, + title: ps.title, + text: ps.text, + imageUrl: ps.imageUrl, + }); + + return announcement; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts b/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts new file mode 100644 index 0000000000..7dbc05b4c9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts @@ -0,0 +1,34 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { Announcements } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + id: { + validator: $.type(ID) + } + }, + + errors: { + noSuchAnnouncement: { + message: 'No such announcement.', + code: 'NO_SUCH_ANNOUNCEMENT', + id: 'ecad8040-a276-4e85-bda9-015a708d291e' + } + } +}; + +export default define(meta, async (ps, me) => { + const announcement = await Announcements.findOne(ps.id); + + if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); + + await Announcements.delete(announcement.id); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts new file mode 100644 index 0000000000..4039bcd88f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -0,0 +1,84 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { Announcements, AnnouncementReads } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + updatedAt: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'date-time', + }, + text: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + title: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + imageUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + reads: { + type: 'number' as const, + optional: false as const, nullable: false as const, + } + } + } + } +}; + +export default define(meta, async (ps) => { + const query = makePaginationQuery(Announcements.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); + + const announcements = await query.take(ps.limit!).getMany(); + + for (const announcement of announcements) { + (announcement as any).reads = await AnnouncementReads.count({ + announcementId: announcement.id + }); + } + + return announcements; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts new file mode 100644 index 0000000000..343f37d626 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { Announcements } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + id: { + validator: $.type(ID) + }, + title: { + validator: $.str.min(1) + }, + text: { + validator: $.str.min(1) + }, + imageUrl: { + validator: $.nullable.str.min(1) + } + }, + + errors: { + noSuchAnnouncement: { + message: 'No such announcement.', + code: 'NO_SUCH_ANNOUNCEMENT', + id: 'd3aae5a7-6372-4cb4-b61c-f511ffc2d7cc' + } + } +}; + +export default define(meta, async (ps, me) => { + const announcement = await Announcements.findOne(ps.id); + + if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); + + await Announcements.update(announcement.id, { + updatedAt: new Date(), + title: ps.title, + text: ps.text, + imageUrl: ps.imageUrl, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts new file mode 100644 index 0000000000..988ab29558 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts @@ -0,0 +1,28 @@ +import $ from 'cafy'; +import define from '../../define'; +import { deleteFile } from '@/services/drive/delete-file'; +import { DriveFiles } from '@/models/index'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const files = await DriveFiles.find({ + userId: ps.userId + }); + + for (const file of files) { + deleteFile(file); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/delete-logs.ts b/packages/backend/src/server/api/endpoints/admin/delete-logs.ts new file mode 100644 index 0000000000..9d37ceb434 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/delete-logs.ts @@ -0,0 +1,13 @@ +import define from '../../define'; +import { Logs } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, +}; + +export default define(meta, async (ps) => { + await Logs.clear(); // TRUNCATE +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts new file mode 100644 index 0000000000..76a6acff59 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts @@ -0,0 +1,13 @@ +import define from '../../../define'; +import { createCleanRemoteFilesJob } from '@/queue/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, +}; + +export default define(meta, async (ps, me) => { + createCleanRemoteFilesJob(); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts b/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts new file mode 100644 index 0000000000..8497478da9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts @@ -0,0 +1,21 @@ +import { IsNull } from 'typeorm'; +import define from '../../../define'; +import { deleteFile } from '@/services/drive/delete-file'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, +}; + +export default define(meta, async (ps, me) => { + const files = await DriveFiles.find({ + userId: IsNull() + }); + + for (const file of files) { + deleteFile(file); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/files.ts b/packages/backend/src/server/api/endpoints/admin/drive/files.ts new file mode 100644 index 0000000000..fe1c799805 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive/files.ts @@ -0,0 +1,81 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { DriveFiles } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: false as const, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + type: { + validator: $.optional.nullable.str.match(/^[a-zA-Z0-9\/\-*]+$/) + }, + + origin: { + validator: $.optional.str.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'local' + }, + + hostname: { + validator: $.optional.nullable.str, + default: null + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile' + } + } +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId); + + if (ps.origin === 'local') { + query.andWhere('file.userHost IS NULL'); + } else if (ps.origin === 'remote') { + query.andWhere('file.userHost IS NOT NULL'); + } + + if (ps.hostname) { + query.andWhere('file.userHost = :hostname', { hostname: ps.hostname }); + } + + if (ps.type) { + if (ps.type.endsWith('/*')) { + query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); + } else { + query.andWhere('file.type = :type', { type: ps.type }); + } + } + + const files = await query.take(ps.limit!).getMany(); + + return await DriveFiles.packMany(files, { detail: true, withUser: true, self: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts new file mode 100644 index 0000000000..270b89c4fa --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts @@ -0,0 +1,180 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + fileId: { + validator: $.optional.type(ID), + }, + + url: { + validator: $.optional.str, + }, + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'caf3ca38-c6e5-472e-a30c-b05377dcc240' + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + userId: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + userHost: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + md5: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'md5', + example: '15eca7fba0480996e2245f5185bf39f2' + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: 'lenna.jpg' + }, + type: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: 'image/jpeg' + }, + size: { + type: 'number' as const, + optional: false as const, nullable: false as const, + example: 51469 + }, + comment: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + blurhash: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + properties: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + width: { + type: 'number' as const, + optional: false as const, nullable: false as const, + example: 1280 + }, + height: { + type: 'number' as const, + optional: false as const, nullable: false as const, + example: 720 + }, + avgColor: { + type: 'string' as const, + optional: true as const, nullable: false as const, + example: 'rgb(40,65,87)' + } + } + }, + storedInternal: { + type: 'boolean' as const, + optional: false as const, nullable: true as const, + example: true + }, + url: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'url', + }, + thumbnailUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'url', + }, + webpublicUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'url', + }, + accessKey: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + thumbnailAccessKey: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + webpublicAccessKey: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + uri: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + src: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + folderId: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + isSensitive: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + isLink: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + } + } + } +}; + +export default define(meta, async (ps, me) => { + const file = ps.fileId ? await DriveFiles.findOne(ps.fileId) : await DriveFiles.findOne({ + where: [{ + url: ps.url + }, { + thumbnailUrl: ps.url + }, { + webpublicUrl: ps.url + }] + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + + return file; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts new file mode 100644 index 0000000000..1af81fe46d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -0,0 +1,64 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Emojis, DriveFiles } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { getConnection } from 'typeorm'; +import { insertModerationLog } from '@/services/insert-moderation-log'; +import { ApiError } from '../../../error'; +import { ID } from '@/misc/cafy-id'; +import rndstr from 'rndstr'; +import { publishBroadcastStream } from '@/services/stream'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + fileId: { + validator: $.type(ID) + }, + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'MO_SUCH_FILE', + id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf' + } + } +}; + +export default define(meta, async (ps, me) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + + const name = file.name.split('.')[0].match(/^[a-z0-9_]+$/) ? file.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`; + + const emoji = await Emojis.save({ + id: genId(), + updatedAt: new Date(), + name: name, + category: null, + host: null, + aliases: [], + url: file.url, + type: file.type, + }); + + await getConnection().queryResultCache!.remove(['meta_emojis']); + + publishBroadcastStream('emojiAdded', { + emoji: await Emojis.pack(emoji.id) + }); + + insertModerationLog(me, 'addEmoji', { + emojiId: emoji.id + }); + + return { + id: emoji.id + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts new file mode 100644 index 0000000000..4c8ab99f7c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -0,0 +1,81 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Emojis } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { getConnection } from 'typeorm'; +import { ApiError } from '../../../error'; +import { DriveFile } from '@/models/entities/drive-file'; +import { ID } from '@/misc/cafy-id'; +import uploadFromUrl from '@/services/drive/upload-from-url'; +import { publishBroadcastStream } from '@/services/stream'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + emojiId: { + validator: $.type(ID) + }, + }, + + errors: { + noSuchEmoji: { + message: 'No such emoji.', + code: 'NO_SUCH_EMOJI', + id: 'e2785b66-dca3-4087-9cac-b93c541cc425' + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + } + } + } +}; + +export default define(meta, async (ps, me) => { + const emoji = await Emojis.findOne(ps.emojiId); + + if (emoji == null) { + throw new ApiError(meta.errors.noSuchEmoji); + } + + let driveFile: DriveFile; + + try { + // Create file + driveFile = await uploadFromUrl(emoji.url, null, null, null, false, true); + } catch (e) { + throw new ApiError(); + } + + const copied = await Emojis.insert({ + id: genId(), + updatedAt: new Date(), + name: emoji.name, + host: null, + aliases: [], + url: driveFile.url, + type: driveFile.type, + fileId: driveFile.id, + }).then(x => Emojis.findOneOrFail(x.identifiers[0])); + + await getConnection().queryResultCache!.remove(['meta_emojis']); + + publishBroadcastStream('emojiAdded', { + emoji: await Emojis.pack(copied.id) + }); + + return { + id: copied.id + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts new file mode 100644 index 0000000000..3c8ca22170 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts @@ -0,0 +1,99 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Emojis } from '@/models/index'; +import { toPuny } from '@/misc/convert-host'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + query: { + validator: $.optional.nullable.str, + default: null + }, + + host: { + validator: $.optional.nullable.str, + default: null + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + }, + aliases: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + category: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + host: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + url: { + type: 'string' as const, + optional: false as const, nullable: false as const, + } + } + } + } +}; + +export default define(meta, async (ps) => { + const q = makePaginationQuery(Emojis.createQueryBuilder('emoji'), ps.sinceId, ps.untilId); + + if (ps.host == null) { + q.andWhere(`emoji.host IS NOT NULL`); + } else { + q.andWhere(`emoji.host = :host`, { host: toPuny(ps.host) }); + } + + if (ps.query) { + q.andWhere('emoji.name like :query', { query: '%' + ps.query + '%' }); + } + + const emojis = await q + .orderBy('emoji.id', 'DESC') + .take(ps.limit!) + .getMany(); + + return Emojis.packMany(emojis); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts new file mode 100644 index 0000000000..cb1e79e0fe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -0,0 +1,98 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Emojis } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; +import { ID } from '@/misc/cafy-id'; +import { Emoji } from '@/models/entities/emoji'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + query: { + validator: $.optional.nullable.str, + default: null + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + }, + aliases: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + category: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + host: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + url: { + type: 'string' as const, + optional: false as const, nullable: false as const, + } + } + } + } +}; + +export default define(meta, async (ps) => { + const q = makePaginationQuery(Emojis.createQueryBuilder('emoji'), ps.sinceId, ps.untilId) + .andWhere(`emoji.host IS NULL`); + + let emojis: Emoji[]; + + if (ps.query) { + //q.andWhere('emoji.name ILIKE :q', { q: `%${ps.query}%` }); + //const emojis = await q.take(ps.limit!).getMany(); + + emojis = await q.getMany(); + + emojis = emojis.filter(emoji => + emoji.name.includes(ps.query!) || + emoji.aliases.some(a => a.includes(ps.query!)) || + emoji.category?.includes(ps.query!)); + + emojis.splice(ps.limit! + 1); + } else { + emojis = await q.take(ps.limit!).getMany(); + } + + return Emojis.packMany(emojis); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/remove.ts b/packages/backend/src/server/api/endpoints/admin/emoji/remove.ts new file mode 100644 index 0000000000..259950e362 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/remove.ts @@ -0,0 +1,42 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { Emojis } from '@/models/index'; +import { getConnection } from 'typeorm'; +import { insertModerationLog } from '@/services/insert-moderation-log'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + id: { + validator: $.type(ID) + } + }, + + errors: { + noSuchEmoji: { + message: 'No such emoji.', + code: 'NO_SUCH_EMOJI', + id: 'be83669b-773a-44b7-b1f8-e5e5170ac3c2' + } + } +}; + +export default define(meta, async (ps, me) => { + const emoji = await Emojis.findOne(ps.id); + + if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); + + await Emojis.delete(emoji.id); + + await getConnection().queryResultCache!.remove(['meta_emojis']); + + insertModerationLog(me, 'removeEmoji', { + emoji: emoji + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts new file mode 100644 index 0000000000..3fd547d7e5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { Emojis } from '@/models/index'; +import { getConnection } from 'typeorm'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + id: { + validator: $.type(ID) + }, + + name: { + validator: $.str + }, + + category: { + validator: $.optional.nullable.str + }, + + aliases: { + validator: $.arr($.str) + } + }, + + errors: { + noSuchEmoji: { + message: 'No such emoji.', + code: 'NO_SUCH_EMOJI', + id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8' + } + } +}; + +export default define(meta, async (ps) => { + const emoji = await Emojis.findOne(ps.id); + + if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); + + await Emojis.update(emoji.id, { + updatedAt: new Date(), + name: ps.name, + category: ps.category, + aliases: ps.aliases, + }); + + await getConnection().queryResultCache!.remove(['meta_emojis']); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts b/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts new file mode 100644 index 0000000000..82540c5447 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts @@ -0,0 +1,27 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { deleteFile } from '@/services/drive/delete-file'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + host: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, me) => { + const files = await DriveFiles.find({ + userHost: ps.host + }); + + for (const file of files) { + deleteFile(file); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts b/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts new file mode 100644 index 0000000000..65a6947ba0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts @@ -0,0 +1,28 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Instances } from '@/models/index'; +import { toPuny } from '@/misc/convert-host'; +import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + host: { + validator: $.str + }, + } +}; + +export default define(meta, async (ps, me) => { + const instance = await Instances.findOne({ host: toPuny(ps.host) }); + + if (instance == null) { + throw new Error('instance not found'); + } + + fetchInstanceMetadata(instance, true); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts new file mode 100644 index 0000000000..7935eaa631 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts @@ -0,0 +1,32 @@ +import $ from 'cafy'; +import define from '../../../define'; +import deleteFollowing from '@/services/following/delete'; +import { Followings, Users } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + host: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, me) => { + const followings = await Followings.find({ + followerHost: ps.host + }); + + const pairs = await Promise.all(followings.map(f => Promise.all([ + Users.findOneOrFail(f.followerId), + Users.findOneOrFail(f.followeeId) + ]))); + + for (const pair of pairs) { + deleteFollowing(pair[0], pair[1]); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts new file mode 100644 index 0000000000..34eab27c78 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts @@ -0,0 +1,33 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Instances } from '@/models/index'; +import { toPuny } from '@/misc/convert-host'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + host: { + validator: $.str + }, + + isSuspended: { + validator: $.bool + }, + } +}; + +export default define(meta, async (ps, me) => { + const instance = await Instances.findOne({ host: toPuny(ps.host) }); + + if (instance == null) { + throw new Error('instance not found'); + } + + Instances.update({ host: toPuny(ps.host) }, { + isSuspended: ps.isSuspended + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts b/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts new file mode 100644 index 0000000000..f2b06d0ef2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts @@ -0,0 +1,26 @@ +import define from '../../define'; +import { getConnection } from 'typeorm'; + +export const meta = { + requireCredential: true as const, + requireModerator: true, + + tags: ['admin'], + + params: { + }, +}; + +export default define(meta, async () => { + const stats = await + getConnection().query(`SELECT * FROM pg_indexes;`) + .then(recs => { + const res = [] as { tablename: string; indexname: string; }[]; + for (const rec of recs) { + res.push(rec); + } + return res; + }); + + return stats; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts b/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts new file mode 100644 index 0000000000..bce813232b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts @@ -0,0 +1,45 @@ +import define from '../../define'; +import { getConnection } from 'typeorm'; + +export const meta = { + requireCredential: true as const, + requireModerator: true, + + tags: ['admin'], + + params: { + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + example: { + migrations: { + count: 66, + size: 32768 + }, + } + } +}; + +export default define(meta, async () => { + const sizes = await + getConnection().query(` + SELECT relname AS "table", reltuples as "count", pg_total_relation_size(C.oid) AS "size" + FROM pg_class C LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) + WHERE nspname NOT IN ('pg_catalog', 'information_schema') + AND C.relkind <> 'i' + AND nspname !~ '^pg_toast';`) + .then(recs => { + const res = {} as Record<string, { count: number; size: number; }>; + for (const rec of recs) { + res[rec.table] = { + count: parseInt(rec.count, 10), + size: parseInt(rec.size, 10), + }; + } + return res; + }); + + return sizes; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/invite.ts b/packages/backend/src/server/api/endpoints/admin/invite.ts new file mode 100644 index 0000000000..2c69eec535 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/invite.ts @@ -0,0 +1,44 @@ +import rndstr from 'rndstr'; +import define from '../../define'; +import { RegistrationTickets } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: {}, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + code: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: '2ERUA5VR', + maxLength: 8, + minLength: 8 + } + } + } +}; + +export default define(meta, async () => { + const code = rndstr({ + length: 8, + chars: '2-9A-HJ-NP-Z', // [0-9A-Z] w/o [01IO] (32 patterns) + }); + + await RegistrationTickets.insert({ + id: genId(), + createdAt: new Date(), + code, + }); + + return { + code, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/moderators/add.ts b/packages/backend/src/server/api/endpoints/admin/moderators/add.ts new file mode 100644 index 0000000000..2b87fc217f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/moderators/add.ts @@ -0,0 +1,33 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireAdmin: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + if (user.isAdmin) { + throw new Error('cannot mark as moderator if admin user'); + } + + await Users.update(user.id, { + isModerator: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts b/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts new file mode 100644 index 0000000000..cbb0625224 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts @@ -0,0 +1,29 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireAdmin: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + await Users.update(user.id, { + isModerator: false + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/promo/create.ts b/packages/backend/src/server/api/endpoints/admin/promo/create.ts new file mode 100644 index 0000000000..3bdaaad4d9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/promo/create.ts @@ -0,0 +1,57 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getNote } from '../../../common/getters'; +import { PromoNotes } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + noteId: { + validator: $.type(ID), + }, + + expiresAt: { + validator: $.num.int() + }, + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'ee449fbe-af2a-453b-9cae-cf2fe7c895fc' + }, + + alreadyPromoted: { + message: 'The note has already promoted.', + code: 'ALREADY_PROMOTED', + id: 'ae427aa2-7a41-484f-a18c-2c1104051604' + }, + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const exist = await PromoNotes.findOne(note.id); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyPromoted); + } + + await PromoNotes.insert({ + noteId: note.id, + createdAt: new Date(), + expiresAt: new Date(ps.expiresAt), + userId: note.userId, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts new file mode 100644 index 0000000000..fedb7065ab --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts @@ -0,0 +1,18 @@ +import define from '../../../define'; +import { destroy } from '@/queue/index'; +import { insertModerationLog } from '@/services/insert-moderation-log'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: {} +}; + +export default define(meta, async (ps, me) => { + destroy(); + + insertModerationLog(me, 'clearQueue'); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts new file mode 100644 index 0000000000..cd7b640983 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts @@ -0,0 +1,55 @@ +import { deliverQueue } from '@/queue/queues'; +import { URL } from 'url'; +import define from '../../../define'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + anyOf: [ + { + type: 'string' as const, + }, + { + type: 'number' as const, + } + ] + } + }, + example: [[ + 'example.com', + 12 + ]] + } +}; + +export default define(meta, async (ps) => { + const jobs = await deliverQueue.getJobs(['delayed']); + + const res = [] as [string, number][]; + + for (const job of jobs) { + const host = new URL(job.data.to).host; + if (res.find(x => x[0] === host)) { + res.find(x => x[0] === host)![1]++; + } else { + res.push([host, 1]); + } + } + + res.sort((a, b) => b[1] - a[1]); + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts new file mode 100644 index 0000000000..1925906c28 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts @@ -0,0 +1,55 @@ +import { URL } from 'url'; +import define from '../../../define'; +import { inboxQueue } from '@/queue/queues'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + anyOf: [ + { + type: 'string' as const, + }, + { + type: 'number' as const, + } + ] + } + }, + example: [[ + 'example.com', + 12 + ]] + } +}; + +export default define(meta, async (ps) => { + const jobs = await inboxQueue.getJobs(['delayed']); + + const res = [] as [string, number][]; + + for (const job of jobs) { + const host = new URL(job.data.signature.keyId).host; + if (res.find(x => x[0] === host)) { + res.find(x => x[0] === host)![1]++; + } else { + res.push([host, 1]); + } + } + + res.sort((a, b) => b[1] - a[1]); + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts new file mode 100644 index 0000000000..c426e5f39b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts @@ -0,0 +1,81 @@ +import { deliverQueue, inboxQueue, dbQueue, objectStorageQueue } from '@/queue/queues'; +import $ from 'cafy'; +import define from '../../../define'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + domain: { + validator: $.str.or(['deliver', 'inbox', 'db', 'objectStorage']), + }, + + state: { + validator: $.str.or(['active', 'waiting', 'delayed']), + }, + + limit: { + validator: $.optional.num, + default: 50 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + data: { + type: 'object' as const, + optional: false as const, nullable: false as const + }, + attempts: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + maxAttempts: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + timestamp: { + type: 'number' as const, + optional: false as const, nullable: false as const + } + } + } + } +}; + +export default define(meta, async (ps) => { + const queue = + ps.domain === 'deliver' ? deliverQueue : + ps.domain === 'inbox' ? inboxQueue : + ps.domain === 'db' ? dbQueue : + ps.domain === 'objectStorage' ? objectStorageQueue : + null as never; + + const jobs = await queue.getJobs([ps.state], 0, ps.limit!); + + return jobs.map(job => { + const data = job.data; + delete data.content; + delete data.user; + return { + id: job.id, + data, + attempts: job.attemptsMade, + maxAttempts: job.opts ? job.opts.attempts : 0, + timestamp: job.timestamp, + }; + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts new file mode 100644 index 0000000000..38f18459dd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts @@ -0,0 +1,44 @@ +import { deliverQueue, inboxQueue, dbQueue, objectStorageQueue } from '@/queue/queues'; +import define from '../../../define'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: {}, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + deliver: { + ref: 'QueueCount' + }, + inbox: { + ref: 'QueueCount' + }, + db: { + ref: 'QueueCount' + }, + objectStorage: { + ref: 'QueueCount' + } + } + } +}; + +export default define(meta, async (ps) => { + const deliverJobCounts = await deliverQueue.getJobCounts(); + const inboxJobCounts = await inboxQueue.getJobCounts(); + const dbJobCounts = await dbQueue.getJobCounts(); + const objectStorageJobCounts = await objectStorageQueue.getJobCounts(); + + return { + deliver: deliverJobCounts, + inbox: inboxJobCounts, + db: dbJobCounts, + objectStorage: objectStorageJobCounts, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/relays/add.ts b/packages/backend/src/server/api/endpoints/admin/relays/add.ts new file mode 100644 index 0000000000..567035fd3a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/relays/add.ts @@ -0,0 +1,63 @@ +import { URL } from 'url'; +import $ from 'cafy'; +import define from '../../../define'; +import { addRelay } from '@/services/relay'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true as const, + + params: { + inbox: { + validator: $.str + }, + }, + + errors: { + invalidUrl: { + message: 'Invalid URL', + code: 'INVALID_URL', + id: 'fb8c92d3-d4e5-44e7-b3d4-800d5cef8b2c' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + inbox: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'url' + }, + status: { + type: 'string' as const, + optional: false as const, nullable: false as const, + default: 'requesting', + enum: [ + 'requesting', + 'accepted', + 'rejected' + ] + } + } + } +}; + +export default define(meta, async (ps, user) => { + try { + if (new URL(ps.inbox).protocol !== 'https:') throw 'https only'; + } catch { + throw new ApiError(meta.errors.invalidUrl); + } + + return await addRelay(ps.inbox); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/relays/list.ts b/packages/backend/src/server/api/endpoints/admin/relays/list.ts new file mode 100644 index 0000000000..031ebe85d0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/relays/list.ts @@ -0,0 +1,47 @@ +import define from '../../../define'; +import { listRelay } from '@/services/relay'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true as const, + + params: { + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + inbox: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'url' + }, + status: { + type: 'string' as const, + optional: false as const, nullable: false as const, + default: 'requesting', + enum: [ + 'requesting', + 'accepted', + 'rejected' + ] + } + } + } + } +}; + +export default define(meta, async (ps, user) => { + return await listRelay(); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/relays/remove.ts b/packages/backend/src/server/api/endpoints/admin/relays/remove.ts new file mode 100644 index 0000000000..c1c50f5dc0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/relays/remove.ts @@ -0,0 +1,20 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { removeRelay } from '@/services/relay'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true as const, + + params: { + inbox: { + validator: $.str + }, + }, +}; + +export default define(meta, async (ps, user) => { + return await removeRelay(ps.inbox); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/reset-password.ts new file mode 100644 index 0000000000..0fc2c6a868 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts @@ -0,0 +1,59 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import * as bcrypt from 'bcryptjs'; +import rndstr from 'rndstr'; +import { Users, UserProfiles } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + password: { + type: 'string' as const, + optional: false as const, nullable: false as const, + minLength: 8, + maxLength: 8 + } + } + } +}; + +export default define(meta, async (ps) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + if (user.isAdmin) { + throw new Error('cannot reset password of admin'); + } + + const passwd = rndstr('a-zA-Z0-9', 8); + + // Generate hash of password + const hash = bcrypt.hashSync(passwd); + + await UserProfiles.update({ + userId: user.id + }, { + password: hash + }); + + return { + password: passwd + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts new file mode 100644 index 0000000000..7b71f8e000 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts @@ -0,0 +1,30 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { AbuseUserReports } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + reportId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const report = await AbuseUserReports.findOne(ps.reportId); + + if (report == null) { + throw new Error('report not found'); + } + + await AbuseUserReports.update(report.id, { + resolved: true, + assigneeId: me.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/resync-chart.ts b/packages/backend/src/server/api/endpoints/admin/resync-chart.ts new file mode 100644 index 0000000000..e01dfce1b6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/resync-chart.ts @@ -0,0 +1,21 @@ +import define from '../../define'; +import { driveChart, notesChart, usersChart } from '@/services/chart/index'; +import { insertModerationLog } from '@/services/insert-moderation-log'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, +}; + +export default define(meta, async (ps, me) => { + insertModerationLog(me, 'chartResync'); + + driveChart.resync(); + notesChart.resync(); + usersChart.resync(); + + // TODO: ユーザーごとのチャートもキューに入れて更新する + // TODO: インスタンスごとのチャートもキューに入れて更新する +}); diff --git a/packages/backend/src/server/api/endpoints/admin/send-email.ts b/packages/backend/src/server/api/endpoints/admin/send-email.ts new file mode 100644 index 0000000000..6f67b78542 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/send-email.ts @@ -0,0 +1,26 @@ +import $ from 'cafy'; +import define from '../../define'; +import { sendEmail } from '@/services/send-email'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + to: { + validator: $.str, + }, + subject: { + validator: $.str, + }, + text: { + validator: $.str, + }, + } +}; + +export default define(meta, async (ps) => { + await sendEmail(ps.to, ps.subject, ps.text, ps.text); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/server-info.ts b/packages/backend/src/server/api/endpoints/admin/server-info.ts new file mode 100644 index 0000000000..bb2d35e397 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/server-info.ts @@ -0,0 +1,119 @@ +import * as os from 'os'; +import * as si from 'systeminformation'; +import { getConnection } from 'typeorm'; +import define from '../../define'; +import { redisClient } from '../../../../db/redis'; + +export const meta = { + requireCredential: true as const, + requireModerator: true, + + tags: ['admin', 'meta'], + + params: { + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + machine: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + os: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: 'linux' + }, + node: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + psql: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + cpu: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + model: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + cores: { + type: 'number' as const, + optional: false as const, nullable: false as const, + } + } + }, + mem: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + total: { + type: 'number' as const, + optional: false as const, nullable: false as const, + format: 'bytes', + } + } + }, + fs: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + total: { + type: 'number' as const, + optional: false as const, nullable: false as const, + format: 'bytes', + }, + used: { + type: 'number' as const, + optional: false as const, nullable: false as const, + format: 'bytes', + } + } + }, + net: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + interface: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: 'eth0' + } + } + } + } + } +}; + +export default define(meta, async () => { + const memStats = await si.mem(); + const fsStats = await si.fsSize(); + const netInterface = await si.networkInterfaceDefault(); + + return { + machine: os.hostname(), + os: os.platform(), + node: process.version, + psql: await getConnection().query('SHOW server_version').then(x => x[0].server_version), + redis: redisClient.server_info.redis_version, + cpu: { + model: os.cpus()[0].model, + cores: os.cpus().length + }, + mem: { + total: memStats.total + }, + fs: { + total: fsStats[0].size, + used: fsStats[0].used, + }, + net: { + interface: netInterface + } + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts b/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts new file mode 100644 index 0000000000..e9509568d0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts @@ -0,0 +1,74 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ModerationLogs } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time' + }, + type: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + info: { + type: 'object' as const, + optional: false as const, nullable: false as const + }, + userId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + user: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } + } + } + } +}; + +export default define(meta, async (ps) => { + const query = makePaginationQuery(ModerationLogs.createQueryBuilder('report'), ps.sinceId, ps.untilId); + + const reports = await query.take(ps.limit!).getMany(); + + return await ModerationLogs.packMany(reports); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts new file mode 100644 index 0000000000..963c123255 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -0,0 +1,177 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'object' as const, + nullable: false as const, optional: false as const, + properties: { + id: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'id' + }, + createdAt: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'date-time' + }, + updatedAt: { + type: 'string' as const, + nullable: true as const, optional: false as const, + format: 'date-time' + }, + lastFetchedAt: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + username: { + type: 'string' as const, + nullable: false as const, optional: false as const + }, + name: { + type: 'string' as const, + nullable: false as const, optional: false as const + }, + folowersCount: { + type: 'number' as const, + nullable: false as const, optional: false as const + }, + followingCount: { + type: 'number' as const, + nullable: false as const, optional: false as const + }, + notesCount: { + type: 'number' as const, + nullable: false as const, optional: false as const + }, + avatarId: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + bannerId: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + tags: { + type: 'array' as const, + nullable: false as const, optional: false as const, + items: { + type: 'string' as const, + nullable: false as const, optional: false as const + } + }, + avatarUrl: { + type: 'string' as const, + nullable: true as const, optional: false as const, + format: 'url' + }, + bannerUrl: { + type: 'string' as const, + nullable: true as const, optional: false as const, + format: 'url' + }, + avatarBlurhash: { + type: 'any' as const, + nullable: true as const, optional: false as const, + default: null + }, + bannerBlurhash: { + type: 'any' as const, + nullable: true as const, optional: false as const, + default: null + }, + isSuspended: { + type: 'boolean' as const, + nullable: false as const, optional: false as const + }, + isSilenced: { + type: 'boolean' as const, + nullable: false as const, optional: false as const + }, + isLocked: { + type: 'boolean' as const, + nullable: false as const, optional: false as const, + }, + isBot: { + type: 'boolean' as const, + nullable: false as const, optional: false as const + }, + isCat: { + type: 'boolean' as const, + nullable: false as const, optional: false as const + }, + isAdmin: { + type: 'boolean' as const, + nullable: false as const, optional: false as const + }, + isModerator: { + type: 'boolean' as const, + nullable: false as const, optional: false as const + }, + emojis: { + type: 'array' as const, + nullable: false as const, optional: false as const, + items: { + type: 'string' as const, + nullable: false as const, optional: false as const + } + }, + host: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + inbox: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + sharedInbox: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + featured: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + uri: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + token: { + type: 'string' as const, + nullable: false as const, optional: false as const, + default: '<MASKED>' + } + } + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + if ((me.isModerator && !me.isAdmin) && user.isAdmin) { + throw new Error('cannot show info of admin'); + } + + return { + ...user, + token: user.token != null ? '<MASKED>' : user.token, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts new file mode 100644 index 0000000000..20b63e7be6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts @@ -0,0 +1,119 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + offset: { + validator: $.optional.num.min(0), + default: 0 + }, + + sort: { + validator: $.optional.str.or([ + '+follower', + '-follower', + '+createdAt', + '-createdAt', + '+updatedAt', + '-updatedAt', + ]), + }, + + state: { + validator: $.optional.str.or([ + 'all', + 'available', + 'admin', + 'moderator', + 'adminOrModerator', + 'silenced', + 'suspended', + ]), + default: 'all' + }, + + origin: { + validator: $.optional.str.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'local' + }, + + username: { + validator: $.optional.str, + default: null + }, + + hostname: { + validator: $.optional.str, + default: null + } + }, + + res: { + type: 'array' as const, + nullable: false as const, optional: false as const, + items: { + type: 'object' as const, + nullable: false as const, optional: false as const, + ref: 'User' + } + } +}; + +export default define(meta, async (ps, me) => { + const query = Users.createQueryBuilder('user'); + + switch (ps.state) { + case 'available': query.where('user.isSuspended = FALSE'); break; + case 'admin': query.where('user.isAdmin = TRUE'); break; + case 'moderator': query.where('user.isModerator = TRUE'); break; + case 'adminOrModerator': query.where('user.isAdmin = TRUE OR user.isModerator = TRUE'); break; + case 'alive': query.where('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break; + case 'silenced': query.where('user.isSilenced = TRUE'); break; + case 'suspended': query.where('user.isSuspended = TRUE'); break; + } + + switch (ps.origin) { + case 'local': query.andWhere('user.host IS NULL'); break; + case 'remote': query.andWhere('user.host IS NOT NULL'); break; + } + + if (ps.username) { + query.andWhere('user.usernameLower like :username', { username: ps.username.toLowerCase() + '%' }); + } + + if (ps.hostname) { + query.andWhere('user.host like :hostname', { hostname: '%' + ps.hostname.toLowerCase() + '%' }); + } + + switch (ps.sort) { + case '+follower': query.orderBy('user.followersCount', 'DESC'); break; + case '-follower': query.orderBy('user.followersCount', 'ASC'); break; + case '+createdAt': query.orderBy('user.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('user.createdAt', 'ASC'); break; + case '+updatedAt': query.orderBy('user.updatedAt', 'DESC', 'NULLS LAST'); break; + case '-updatedAt': query.orderBy('user.updatedAt', 'ASC', 'NULLS FIRST'); break; + default: query.orderBy('user.id', 'ASC'); break; + } + + query.take(ps.limit!); + query.skip(ps.offset); + + const users = await query.getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/silence-user.ts b/packages/backend/src/server/api/endpoints/admin/silence-user.ts new file mode 100644 index 0000000000..9bfed2310a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/silence-user.ts @@ -0,0 +1,38 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Users } from '@/models/index'; +import { insertModerationLog } from '@/services/insert-moderation-log'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + if (user.isAdmin) { + throw new Error('cannot silence admin'); + } + + await Users.update(user.id, { + isSilenced: true + }); + + insertModerationLog(me, 'silence', { + targetId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts new file mode 100644 index 0000000000..364f258ce8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts @@ -0,0 +1,84 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import deleteFollowing from '@/services/following/delete'; +import { Users, Followings, Notifications } from '@/models/index'; +import { User } from '@/models/entities/user'; +import { insertModerationLog } from '@/services/insert-moderation-log'; +import { doPostSuspend } from '@/services/suspend-user'; +import { publishUserEvent } from '@/services/stream'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId as string); + + 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'); + } + + await Users.update(user.id, { + isSuspended: true + }); + + insertModerationLog(me, 'suspend', { + targetId: user.id, + }); + + // Terminate streaming + if (Users.isLocalUser(user)) { + publishUserEvent(user.id, 'terminate', {}); + } + + (async () => { + await doPostSuspend(user).catch(e => {}); + await unFollowAll(user).catch(e => {}); + await readAllNotify(user).catch(e => {}); + })(); +}); + +async function unFollowAll(follower: User) { + const followings = await Followings.find({ + followerId: follower.id + }); + + for (const following of followings) { + const followee = await Users.findOne({ + id: following.followeeId + }); + + if (followee == null) { + throw `Cant find followee ${following.followeeId}`; + } + + await deleteFollowing(follower, followee, true); + } +} + +async function readAllNotify(notifier: User) { + await Notifications.update({ + notifierId: notifier.id, + isRead: false, + }, { + isRead: true + }); +} diff --git a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts new file mode 100644 index 0000000000..9994fbf462 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts @@ -0,0 +1,34 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Users } from '@/models/index'; +import { insertModerationLog } from '@/services/insert-moderation-log'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + await Users.update(user.id, { + isSilenced: false + }); + + insertModerationLog(me, 'unsilence', { + targetId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts b/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts new file mode 100644 index 0000000000..ab4c2d3dfe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts @@ -0,0 +1,37 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Users } from '@/models/index'; +import { insertModerationLog } from '@/services/insert-moderation-log'; +import { doPostUnsuspend } from '@/services/unsuspend-user'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + await Users.update(user.id, { + isSuspended: false + }); + + insertModerationLog(me, 'unsuspend', { + targetId: user.id, + }); + + doPostUnsuspend(user); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts new file mode 100644 index 0000000000..55447098dc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -0,0 +1,608 @@ +import $ from 'cafy'; +import define from '../../define'; +import { getConnection } from 'typeorm'; +import { Meta } from '@/models/entities/meta'; +import { insertModerationLog } from '@/services/insert-moderation-log'; +import { DB_MAX_NOTE_TEXT_LENGTH } from '@/misc/hard-limits'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireAdmin: true, + + params: { + disableRegistration: { + validator: $.optional.nullable.bool, + }, + + disableLocalTimeline: { + validator: $.optional.nullable.bool, + }, + + disableGlobalTimeline: { + validator: $.optional.nullable.bool, + }, + + useStarForReactionFallback: { + validator: $.optional.nullable.bool, + }, + + pinnedUsers: { + validator: $.optional.nullable.arr($.str), + }, + + hiddenTags: { + validator: $.optional.nullable.arr($.str), + }, + + blockedHosts: { + validator: $.optional.nullable.arr($.str), + }, + + mascotImageUrl: { + validator: $.optional.nullable.str, + }, + + bannerUrl: { + validator: $.optional.nullable.str, + }, + + errorImageUrl: { + validator: $.optional.nullable.str, + }, + + iconUrl: { + validator: $.optional.nullable.str, + }, + + backgroundImageUrl: { + validator: $.optional.nullable.str, + }, + + logoImageUrl: { + validator: $.optional.nullable.str, + }, + + name: { + validator: $.optional.nullable.str, + }, + + description: { + validator: $.optional.nullable.str, + }, + + maxNoteTextLength: { + validator: $.optional.num.min(0).max(DB_MAX_NOTE_TEXT_LENGTH), + }, + + localDriveCapacityMb: { + validator: $.optional.num.min(0), + }, + + remoteDriveCapacityMb: { + validator: $.optional.num.min(0), + }, + + cacheRemoteFiles: { + validator: $.optional.bool, + }, + + proxyRemoteFiles: { + validator: $.optional.bool, + }, + + emailRequiredForSignup: { + validator: $.optional.bool, + }, + + enableHcaptcha: { + validator: $.optional.bool, + }, + + hcaptchaSiteKey: { + validator: $.optional.nullable.str, + }, + + hcaptchaSecretKey: { + validator: $.optional.nullable.str, + }, + + enableRecaptcha: { + validator: $.optional.bool, + }, + + recaptchaSiteKey: { + validator: $.optional.nullable.str, + }, + + recaptchaSecretKey: { + validator: $.optional.nullable.str, + }, + + proxyAccountId: { + validator: $.optional.nullable.type(ID), + }, + + maintainerName: { + validator: $.optional.nullable.str, + }, + + maintainerEmail: { + validator: $.optional.nullable.str, + }, + + pinnedPages: { + validator: $.optional.arr($.str), + }, + + pinnedClipId: { + validator: $.optional.nullable.type(ID), + }, + + langs: { + validator: $.optional.arr($.str), + }, + + summalyProxy: { + validator: $.optional.nullable.str, + }, + + deeplAuthKey: { + validator: $.optional.nullable.str, + }, + + deeplIsPro: { + validator: $.optional.bool, + }, + + enableTwitterIntegration: { + validator: $.optional.bool, + }, + + twitterConsumerKey: { + validator: $.optional.nullable.str, + }, + + twitterConsumerSecret: { + validator: $.optional.nullable.str, + }, + + enableGithubIntegration: { + validator: $.optional.bool, + }, + + githubClientId: { + validator: $.optional.nullable.str, + }, + + githubClientSecret: { + validator: $.optional.nullable.str, + }, + + enableDiscordIntegration: { + validator: $.optional.bool, + }, + + discordClientId: { + validator: $.optional.nullable.str, + }, + + discordClientSecret: { + validator: $.optional.nullable.str, + }, + + enableEmail: { + validator: $.optional.bool, + }, + + email: { + validator: $.optional.nullable.str, + }, + + smtpSecure: { + validator: $.optional.bool, + }, + + smtpHost: { + validator: $.optional.nullable.str, + }, + + smtpPort: { + validator: $.optional.nullable.num, + }, + + smtpUser: { + validator: $.optional.nullable.str, + }, + + smtpPass: { + validator: $.optional.nullable.str, + }, + + enableServiceWorker: { + validator: $.optional.bool, + }, + + swPublicKey: { + validator: $.optional.nullable.str, + }, + + swPrivateKey: { + validator: $.optional.nullable.str, + }, + + tosUrl: { + validator: $.optional.nullable.str, + }, + + repositoryUrl: { + validator: $.optional.str, + }, + + feedbackUrl: { + validator: $.optional.str, + }, + + useObjectStorage: { + validator: $.optional.bool + }, + + objectStorageBaseUrl: { + validator: $.optional.nullable.str + }, + + objectStorageBucket: { + validator: $.optional.nullable.str + }, + + objectStoragePrefix: { + validator: $.optional.nullable.str + }, + + objectStorageEndpoint: { + validator: $.optional.nullable.str + }, + + objectStorageRegion: { + validator: $.optional.nullable.str + }, + + objectStoragePort: { + validator: $.optional.nullable.num + }, + + objectStorageAccessKey: { + validator: $.optional.nullable.str + }, + + objectStorageSecretKey: { + validator: $.optional.nullable.str + }, + + objectStorageUseSSL: { + validator: $.optional.bool + }, + + objectStorageUseProxy: { + validator: $.optional.bool + }, + + objectStorageSetPublicRead: { + validator: $.optional.bool + }, + + objectStorageS3ForcePathStyle: { + validator: $.optional.bool + }, + } +}; + +export default define(meta, async (ps, me) => { + const set = {} as Partial<Meta>; + + if (typeof ps.disableRegistration === 'boolean') { + set.disableRegistration = ps.disableRegistration; + } + + if (typeof ps.disableLocalTimeline === 'boolean') { + set.disableLocalTimeline = ps.disableLocalTimeline; + } + + if (typeof ps.disableGlobalTimeline === 'boolean') { + set.disableGlobalTimeline = ps.disableGlobalTimeline; + } + + if (typeof ps.useStarForReactionFallback === 'boolean') { + set.useStarForReactionFallback = ps.useStarForReactionFallback; + } + + if (Array.isArray(ps.pinnedUsers)) { + set.pinnedUsers = ps.pinnedUsers.filter(Boolean); + } + + if (Array.isArray(ps.hiddenTags)) { + set.hiddenTags = ps.hiddenTags.filter(Boolean); + } + + if (Array.isArray(ps.blockedHosts)) { + set.blockedHosts = ps.blockedHosts.filter(Boolean); + } + + if (ps.mascotImageUrl !== undefined) { + set.mascotImageUrl = ps.mascotImageUrl; + } + + if (ps.bannerUrl !== undefined) { + set.bannerUrl = ps.bannerUrl; + } + + if (ps.iconUrl !== undefined) { + set.iconUrl = ps.iconUrl; + } + + if (ps.backgroundImageUrl !== undefined) { + set.backgroundImageUrl = ps.backgroundImageUrl; + } + + if (ps.logoImageUrl !== undefined) { + set.logoImageUrl = ps.logoImageUrl; + } + + if (ps.name !== undefined) { + set.name = ps.name; + } + + if (ps.description !== undefined) { + set.description = ps.description; + } + + if (ps.maxNoteTextLength) { + set.maxNoteTextLength = ps.maxNoteTextLength; + } + + if (ps.localDriveCapacityMb !== undefined) { + set.localDriveCapacityMb = ps.localDriveCapacityMb; + } + + if (ps.remoteDriveCapacityMb !== undefined) { + set.remoteDriveCapacityMb = ps.remoteDriveCapacityMb; + } + + if (ps.cacheRemoteFiles !== undefined) { + set.cacheRemoteFiles = ps.cacheRemoteFiles; + } + + if (ps.proxyRemoteFiles !== undefined) { + set.proxyRemoteFiles = ps.proxyRemoteFiles; + } + + if (ps.emailRequiredForSignup !== undefined) { + set.emailRequiredForSignup = ps.emailRequiredForSignup; + } + + if (ps.enableHcaptcha !== undefined) { + set.enableHcaptcha = ps.enableHcaptcha; + } + + if (ps.hcaptchaSiteKey !== undefined) { + set.hcaptchaSiteKey = ps.hcaptchaSiteKey; + } + + if (ps.hcaptchaSecretKey !== undefined) { + set.hcaptchaSecretKey = ps.hcaptchaSecretKey; + } + + if (ps.enableRecaptcha !== undefined) { + set.enableRecaptcha = ps.enableRecaptcha; + } + + if (ps.recaptchaSiteKey !== undefined) { + set.recaptchaSiteKey = ps.recaptchaSiteKey; + } + + if (ps.recaptchaSecretKey !== undefined) { + set.recaptchaSecretKey = ps.recaptchaSecretKey; + } + + if (ps.proxyAccountId !== undefined) { + set.proxyAccountId = ps.proxyAccountId; + } + + if (ps.maintainerName !== undefined) { + set.maintainerName = ps.maintainerName; + } + + if (ps.maintainerEmail !== undefined) { + set.maintainerEmail = ps.maintainerEmail; + } + + if (Array.isArray(ps.langs)) { + set.langs = ps.langs.filter(Boolean); + } + + if (Array.isArray(ps.pinnedPages)) { + set.pinnedPages = ps.pinnedPages.filter(Boolean); + } + + if (ps.pinnedClipId !== undefined) { + set.pinnedClipId = ps.pinnedClipId; + } + + if (ps.summalyProxy !== undefined) { + set.summalyProxy = ps.summalyProxy; + } + + if (ps.enableTwitterIntegration !== undefined) { + set.enableTwitterIntegration = ps.enableTwitterIntegration; + } + + if (ps.twitterConsumerKey !== undefined) { + set.twitterConsumerKey = ps.twitterConsumerKey; + } + + if (ps.twitterConsumerSecret !== undefined) { + set.twitterConsumerSecret = ps.twitterConsumerSecret; + } + + if (ps.enableGithubIntegration !== undefined) { + set.enableGithubIntegration = ps.enableGithubIntegration; + } + + if (ps.githubClientId !== undefined) { + set.githubClientId = ps.githubClientId; + } + + if (ps.githubClientSecret !== undefined) { + set.githubClientSecret = ps.githubClientSecret; + } + + if (ps.enableDiscordIntegration !== undefined) { + set.enableDiscordIntegration = ps.enableDiscordIntegration; + } + + if (ps.discordClientId !== undefined) { + set.discordClientId = ps.discordClientId; + } + + if (ps.discordClientSecret !== undefined) { + set.discordClientSecret = ps.discordClientSecret; + } + + if (ps.enableEmail !== undefined) { + set.enableEmail = ps.enableEmail; + } + + if (ps.email !== undefined) { + set.email = ps.email; + } + + if (ps.smtpSecure !== undefined) { + set.smtpSecure = ps.smtpSecure; + } + + if (ps.smtpHost !== undefined) { + set.smtpHost = ps.smtpHost; + } + + if (ps.smtpPort !== undefined) { + set.smtpPort = ps.smtpPort; + } + + if (ps.smtpUser !== undefined) { + set.smtpUser = ps.smtpUser; + } + + if (ps.smtpPass !== undefined) { + set.smtpPass = ps.smtpPass; + } + + if (ps.errorImageUrl !== undefined) { + set.errorImageUrl = ps.errorImageUrl; + } + + if (ps.enableServiceWorker !== undefined) { + set.enableServiceWorker = ps.enableServiceWorker; + } + + if (ps.swPublicKey !== undefined) { + set.swPublicKey = ps.swPublicKey; + } + + if (ps.swPrivateKey !== undefined) { + set.swPrivateKey = ps.swPrivateKey; + } + + if (ps.tosUrl !== undefined) { + set.ToSUrl = ps.tosUrl; + } + + if (ps.repositoryUrl !== undefined) { + set.repositoryUrl = ps.repositoryUrl; + } + + if (ps.feedbackUrl !== undefined) { + set.feedbackUrl = ps.feedbackUrl; + } + + if (ps.useObjectStorage !== undefined) { + set.useObjectStorage = ps.useObjectStorage; + } + + if (ps.objectStorageBaseUrl !== undefined) { + set.objectStorageBaseUrl = ps.objectStorageBaseUrl; + } + + if (ps.objectStorageBucket !== undefined) { + set.objectStorageBucket = ps.objectStorageBucket; + } + + if (ps.objectStoragePrefix !== undefined) { + set.objectStoragePrefix = ps.objectStoragePrefix; + } + + if (ps.objectStorageEndpoint !== undefined) { + set.objectStorageEndpoint = ps.objectStorageEndpoint; + } + + if (ps.objectStorageRegion !== undefined) { + set.objectStorageRegion = ps.objectStorageRegion; + } + + if (ps.objectStoragePort !== undefined) { + set.objectStoragePort = ps.objectStoragePort; + } + + if (ps.objectStorageAccessKey !== undefined) { + set.objectStorageAccessKey = ps.objectStorageAccessKey; + } + + if (ps.objectStorageSecretKey !== undefined) { + set.objectStorageSecretKey = ps.objectStorageSecretKey; + } + + if (ps.objectStorageUseSSL !== undefined) { + set.objectStorageUseSSL = ps.objectStorageUseSSL; + } + + if (ps.objectStorageUseProxy !== undefined) { + set.objectStorageUseProxy = ps.objectStorageUseProxy; + } + + if (ps.objectStorageSetPublicRead !== undefined) { + set.objectStorageSetPublicRead = ps.objectStorageSetPublicRead; + } + + if (ps.objectStorageS3ForcePathStyle !== undefined) { + set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle; + } + + if (ps.deeplAuthKey !== undefined) { + if (ps.deeplAuthKey === '') { + set.deeplAuthKey = null; + } else { + set.deeplAuthKey = ps.deeplAuthKey; + } + } + + if (ps.deeplIsPro !== undefined) { + set.deeplIsPro = ps.deeplIsPro; + } + + await getConnection().transaction(async transactionalEntityManager => { + const meta = await transactionalEntityManager.findOne(Meta, { + order: { + id: 'DESC' + } + }); + + if (meta) { + await transactionalEntityManager.update(Meta, meta.id, set); + } else { + await transactionalEntityManager.save(Meta, set); + } + }); + + insertModerationLog(me, 'updateMeta'); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/vacuum.ts b/packages/backend/src/server/api/endpoints/admin/vacuum.ts new file mode 100644 index 0000000000..9a80d88c44 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/vacuum.ts @@ -0,0 +1,36 @@ +import $ from 'cafy'; +import define from '../../define'; +import { getConnection } from 'typeorm'; +import { insertModerationLog } from '@/services/insert-moderation-log'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + full: { + validator: $.bool, + }, + analyze: { + validator: $.bool, + }, + } +}; + +export default define(meta, async (ps, me) => { + const params: string[] = []; + + if (ps.full) { + params.push('FULL'); + } + + if (ps.analyze) { + params.push('ANALYZE'); + } + + getConnection().query('VACUUM ' + params.join(' ')); + + insertModerationLog(me, 'vacuum', ps); +}); diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts new file mode 100644 index 0000000000..a67737b2ff --- /dev/null +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -0,0 +1,92 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../define'; +import { Announcements, AnnouncementReads } from '@/models/index'; +import { makePaginationQuery } from '../common/make-pagination-query'; + +export const meta = { + tags: ['meta'], + + requireCredential: false as const, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + withUnreads: { + validator: $.optional.boolean, + default: false + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + updatedAt: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'date-time', + }, + text: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + title: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + imageUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + isRead: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + } + } + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(Announcements.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); + + const announcements = await query.take(ps.limit!).getMany(); + + if (user) { + const reads = (await AnnouncementReads.find({ + userId: user.id + })).map(x => x.announcementId); + + for (const announcement of announcements) { + (announcement as any).isRead = reads.includes(announcement.id); + } + } + + return ps.withUnreads ? announcements.filter((a: any) => !a.isRead) : announcements; +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts new file mode 100644 index 0000000000..4bdae8cc33 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -0,0 +1,127 @@ +import $ from 'cafy'; +import define from '../../define'; +import { genId } from '@/misc/gen-id'; +import { Antennas, UserLists, UserGroupJoinings } from '@/models/index'; +import { ID } from '@/misc/cafy-id'; +import { ApiError } from '../../error'; +import { publishInternalEvent } from '@/services/stream'; + +export const meta = { + tags: ['antennas'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + name: { + validator: $.str.range(1, 100) + }, + + src: { + validator: $.str.or(['home', 'all', 'users', 'list', 'group']) + }, + + userListId: { + validator: $.nullable.optional.type(ID), + }, + + userGroupId: { + validator: $.nullable.optional.type(ID), + }, + + keywords: { + validator: $.arr($.arr($.str)) + }, + + excludeKeywords: { + validator: $.arr($.arr($.str)) + }, + + users: { + validator: $.arr($.str) + }, + + caseSensitive: { + validator: $.bool + }, + + withReplies: { + validator: $.bool + }, + + withFile: { + validator: $.bool + }, + + notify: { + validator: $.bool + } + }, + + errors: { + noSuchUserList: { + message: 'No such user list.', + code: 'NO_SUCH_USER_LIST', + id: '95063e93-a283-4b8b-9aa5-bcdb8df69a7f' + }, + + noSuchUserGroup: { + message: 'No such user group.', + code: 'NO_SUCH_USER_GROUP', + id: 'aa3c0b9a-8cae-47c0-92ac-202ce5906682' + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Antenna' + } +}; + +export default define(meta, async (ps, user) => { + let userList; + let userGroupJoining; + + if (ps.src === 'list' && ps.userListId) { + userList = await UserLists.findOne({ + id: ps.userListId, + userId: user.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchUserList); + } + } else if (ps.src === 'group' && ps.userGroupId) { + userGroupJoining = await UserGroupJoinings.findOne({ + userGroupId: ps.userGroupId, + userId: user.id, + }); + + if (userGroupJoining == null) { + throw new ApiError(meta.errors.noSuchUserGroup); + } + } + + const antenna = await Antennas.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + src: ps.src, + userListId: userList ? userList.id : null, + userGroupJoiningId: userGroupJoining ? userGroupJoining.id : null, + keywords: ps.keywords, + excludeKeywords: ps.excludeKeywords, + users: ps.users, + caseSensitive: ps.caseSensitive, + withReplies: ps.withReplies, + withFile: ps.withFile, + notify: ps.notify, + }).then(x => Antennas.findOneOrFail(x.identifiers[0])); + + publishInternalEvent('antennaCreated', antenna); + + return await Antennas.pack(antenna); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/delete.ts b/packages/backend/src/server/api/endpoints/antennas/delete.ts new file mode 100644 index 0000000000..1cd136183e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/delete.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Antennas } from '@/models/index'; +import { publishInternalEvent } from '@/services/stream'; + +export const meta = { + tags: ['antennas'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + antennaId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchAntenna: { + message: 'No such antenna.', + code: 'NO_SUCH_ANTENNA', + id: 'b34dcf9d-348f-44bb-99d0-6c9314cfe2df' + } + } +}; + +export default define(meta, async (ps, user) => { + const antenna = await Antennas.findOne({ + id: ps.antennaId, + userId: user.id + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + await Antennas.delete(antenna.id); + + publishInternalEvent('antennaDeleted', antenna); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/list.ts b/packages/backend/src/server/api/endpoints/antennas/list.ts new file mode 100644 index 0000000000..8baae8435b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/list.ts @@ -0,0 +1,28 @@ +import define from '../../define'; +import { Antennas } from '@/models/index'; + +export const meta = { + tags: ['antennas', 'account'], + + requireCredential: true as const, + + kind: 'read:account', + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Antenna' + } + } +}; + +export default define(meta, async (ps, me) => { + const antennas = await Antennas.find({ + userId: me.id, + }); + + return await Promise.all(antennas.map(x => Antennas.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts new file mode 100644 index 0000000000..1759e95b4c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -0,0 +1,93 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import readNote from '@/services/note/read'; +import { Antennas, Notes, AntennaNotes } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { ApiError } from '../../error'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['antennas', 'account', 'notes'], + + requireCredential: true as const, + + kind: 'read:account', + + params: { + antennaId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + errors: { + noSuchAntenna: { + message: 'No such antenna.', + code: 'NO_SUCH_ANTENNA', + id: '850926e0-fd3b-49b6-b69a-b28a5dbd82fe' + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note' + } + } +}; + +export default define(meta, async (ps, user) => { + const antenna = await Antennas.findOne({ + id: ps.antennaId, + userId: user.id + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + const antennaQuery = AntennaNotes.createQueryBuilder('joining') + .select('joining.noteId') + .where('joining.antennaId = :antennaId', { antennaId: antenna.id }); + + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(`note.id IN (${ antennaQuery.getQuery() })`) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .setParameters(antennaQuery.getParameters()); + + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + + const notes = await query + .take(ps.limit!) + .getMany(); + + if (notes.length > 0) { + readNote(user.id, notes); + } + + return await Notes.packMany(notes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/show.ts b/packages/backend/src/server/api/endpoints/antennas/show.ts new file mode 100644 index 0000000000..3cdf4dcb61 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/show.ts @@ -0,0 +1,47 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Antennas } from '@/models/index'; + +export const meta = { + tags: ['antennas', 'account'], + + requireCredential: true as const, + + kind: 'read:account', + + params: { + antennaId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchAntenna: { + message: 'No such antenna.', + code: 'NO_SUCH_ANTENNA', + id: 'c06569fb-b025-4f23-b22d-1fcd20d2816b' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Antenna' + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the antenna + const antenna = await Antennas.findOne({ + id: ps.antennaId, + userId: me.id, + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + return await Antennas.pack(antenna); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts new file mode 100644 index 0000000000..d69b4feee6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -0,0 +1,143 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Antennas, UserLists, UserGroupJoinings } from '@/models/index'; +import { publishInternalEvent } from '@/services/stream'; + +export const meta = { + tags: ['antennas'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + antennaId: { + validator: $.type(ID), + }, + + name: { + validator: $.str.range(1, 100) + }, + + src: { + validator: $.str.or(['home', 'all', 'users', 'list', 'group']) + }, + + userListId: { + validator: $.nullable.optional.type(ID), + }, + + userGroupId: { + validator: $.nullable.optional.type(ID), + }, + + keywords: { + validator: $.arr($.arr($.str)) + }, + + excludeKeywords: { + validator: $.arr($.arr($.str)) + }, + + users: { + validator: $.arr($.str) + }, + + caseSensitive: { + validator: $.bool + }, + + withReplies: { + validator: $.bool + }, + + withFile: { + validator: $.bool + }, + + notify: { + validator: $.bool + } + }, + + errors: { + noSuchAntenna: { + message: 'No such antenna.', + code: 'NO_SUCH_ANTENNA', + id: '10c673ac-8852-48eb-aa1f-f5b67f069290' + }, + + noSuchUserList: { + message: 'No such user list.', + code: 'NO_SUCH_USER_LIST', + id: '1c6b35c9-943e-48c2-81e4-2844989407f7' + }, + + noSuchUserGroup: { + message: 'No such user group.', + code: 'NO_SUCH_USER_GROUP', + id: '109ed789-b6eb-456e-b8a9-6059d567d385' + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Antenna' + } +}; + +export default define(meta, async (ps, user) => { + // Fetch the antenna + const antenna = await Antennas.findOne({ + id: ps.antennaId, + userId: user.id + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + let userList; + let userGroupJoining; + + if (ps.src === 'list' && ps.userListId) { + userList = await UserLists.findOne({ + id: ps.userListId, + userId: user.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchUserList); + } + } else if (ps.src === 'group' && ps.userGroupId) { + userGroupJoining = await UserGroupJoinings.findOne({ + userGroupId: ps.userGroupId, + userId: user.id, + }); + + if (userGroupJoining == null) { + throw new ApiError(meta.errors.noSuchUserGroup); + } + } + + await Antennas.update(antenna.id, { + name: ps.name, + src: ps.src, + userListId: userList ? userList.id : null, + userGroupJoiningId: userGroupJoining ? userGroupJoining.id : null, + keywords: ps.keywords, + excludeKeywords: ps.excludeKeywords, + users: ps.users, + caseSensitive: ps.caseSensitive, + withReplies: ps.withReplies, + withFile: ps.withFile, + notify: ps.notify, + }); + + publishInternalEvent('antennaUpdated', await Antennas.findOneOrFail(antenna.id)); + + return await Antennas.pack(antenna.id); +}); diff --git a/packages/backend/src/server/api/endpoints/ap/get.ts b/packages/backend/src/server/api/endpoints/ap/get.ts new file mode 100644 index 0000000000..78919f43b0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/ap/get.ts @@ -0,0 +1,36 @@ +import $ from 'cafy'; +import define from '../../define'; +import Resolver from '@/remote/activitypub/resolver'; +import { ApiError } from '../../error'; +import * as ms from 'ms'; + +export const meta = { + tags: ['federation'], + + requireCredential: true as const, + + limit: { + duration: ms('1hour'), + max: 30 + }, + + params: { + uri: { + validator: $.str, + }, + }, + + errors: { + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + } +}; + +export default define(meta, async (ps) => { + const resolver = new Resolver(); + const object = await resolver.resolve(ps.uri); + return object; +}); diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts new file mode 100644 index 0000000000..2280d93724 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -0,0 +1,190 @@ +import $ from 'cafy'; +import define from '../../define'; +import config from '@/config/index'; +import { createPerson } from '@/remote/activitypub/models/person'; +import { createNote } from '@/remote/activitypub/models/note'; +import Resolver from '@/remote/activitypub/resolver'; +import { ApiError } from '../../error'; +import { extractDbHost } from '@/misc/convert-host'; +import { Users, Notes } from '@/models/index'; +import { Note } from '@/models/entities/note'; +import { User } from '@/models/entities/user'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { isActor, isPost, getApId } from '@/remote/activitypub/type'; +import * as ms from 'ms'; + +export const meta = { + tags: ['federation'], + + requireCredential: true as const, + + limit: { + duration: ms('1hour'), + max: 30 + }, + + params: { + uri: { + validator: $.str, + }, + }, + + errors: { + noSuchObject: { + message: 'No such object.', + code: 'NO_SUCH_OBJECT', + id: 'dc94d745-1262-4e63-a17d-fecaa57efc82' + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + type: { + type: 'string' as const, + optional: false as const, nullable: false as const, + enum: ['User', 'Note'] + }, + object: { + type: 'object' as const, + optional: false as const, nullable: false as const + } + } + } +}; + +export default define(meta, async (ps) => { + const object = await fetchAny(ps.uri); + if (object) { + return object; + } else { + throw new ApiError(meta.errors.noSuchObject); + } +}); + +/*** + * URIからUserかNoteを解決する + */ +async function fetchAny(uri: string) { + // URIがこのサーバーを指しているなら、ローカルユーザーIDとしてDBからフェッチ + if (uri.startsWith(config.url + '/')) { + const parts = uri.split('/'); + const id = parts.pop(); + const type = parts.pop(); + + if (type === 'notes') { + const note = await Notes.findOne(id); + + if (note) { + return { + type: 'Note', + object: await Notes.pack(note, null, { detail: true }) + }; + } + } else if (type === 'users') { + const user = await Users.findOne(id); + + if (user) { + return { + type: 'User', + object: await Users.pack(user, null, { detail: true }) + }; + } + } + } + + // ブロックしてたら中断 + const meta = await fetchMeta(); + if (meta.blockedHosts.includes(extractDbHost(uri))) return null; + + // URI(AP Object id)としてDB検索 + { + const [user, note] = await Promise.all([ + Users.findOne({ uri: uri }), + Notes.findOne({ uri: uri }) + ]); + + const packed = await mergePack(user, note); + if (packed !== null) return packed; + } + + // リモートから一旦オブジェクトフェッチ + const resolver = new Resolver(); + const object = await resolver.resolve(uri) as any; + + // /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する + // これはDBに存在する可能性があるため再度DB検索 + if (uri !== object.id) { + if (object.id.startsWith(config.url + '/')) { + const parts = object.id.split('/'); + const id = parts.pop(); + const type = parts.pop(); + + if (type === 'notes') { + const note = await Notes.findOne(id); + + if (note) { + return { + type: 'Note', + object: await Notes.pack(note, null, { detail: true }) + }; + } + } else if (type === 'users') { + const user = await Users.findOne(id); + + if (user) { + return { + type: 'User', + object: await Users.pack(user, null, { detail: true }) + }; + } + } + } + + const [user, note] = await Promise.all([ + Users.findOne({ uri: object.id }), + Notes.findOne({ uri: object.id }) + ]); + + const packed = await mergePack(user, note); + if (packed !== null) return packed; + } + + // それでもみつからなければ新規であるため登録 + if (isActor(object)) { + const user = await createPerson(getApId(object)); + return { + type: 'User', + object: await Users.pack(user, null, { detail: true }) + }; + } + + if (isPost(object)) { + const note = await createNote(getApId(object), undefined, true); + return { + type: 'Note', + object: await Notes.pack(note!, null, { detail: true }) + }; + } + + return null; +} + +async function mergePack(user: User | null | undefined, note: Note | null | undefined) { + if (user != null) { + return { + type: 'User', + object: await Users.pack(user, null, { detail: true }) + }; + } + + if (note != null) { + return { + type: 'Note', + object: await Notes.pack(note, null, { detail: true }) + }; + } + + return null; +} diff --git a/packages/backend/src/server/api/endpoints/app/create.ts b/packages/backend/src/server/api/endpoints/app/create.ts new file mode 100644 index 0000000000..c2ce943dcc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/app/create.ts @@ -0,0 +1,63 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Apps } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { unique } from '@/prelude/array'; +import { secureRndstr } from '@/misc/secure-rndstr'; + +export const meta = { + tags: ['app'], + + requireCredential: false as const, + + params: { + name: { + validator: $.str, + }, + + description: { + validator: $.str, + }, + + permission: { + validator: $.arr($.str).unique(), + }, + + // TODO: Check it is valid url + callbackUrl: { + validator: $.optional.nullable.str, + default: null, + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'App', + }, +}; + +export default define(meta, async (ps, user) => { + // Generate secret + const secret = secureRndstr(32, true); + + // for backward compatibility + const permission = unique(ps.permission.map(v => v.replace(/^(.+)(\/|-)(read|write)$/, '$3:$1'))); + + // Create account + const app = await Apps.save({ + id: genId(), + createdAt: new Date(), + userId: user ? user.id : null, + name: ps.name, + description: ps.description, + permission, + callbackUrl: ps.callbackUrl, + secret: secret + }); + + return await Apps.pack(app, null, { + detail: true, + includeSecret: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/app/show.ts b/packages/backend/src/server/api/endpoints/app/show.ts new file mode 100644 index 0000000000..27f12eb44f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/app/show.ts @@ -0,0 +1,51 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Apps } from '@/models/index'; + +export const meta = { + tags: ['app'], + + params: { + appId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'App', + }, + + errors: { + noSuchApp: { + message: 'No such app.', + code: 'NO_SUCH_APP', + id: 'dce83913-2dc6-4093-8a7b-71dbb11718a3' + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'App' + } +}; + +export default define(meta, async (ps, user, token) => { + const isSecure = user != null && token == null; + + // Lookup app + const ap = await Apps.findOne(ps.appId); + + if (ap == null) { + throw new ApiError(meta.errors.noSuchApp); + } + + return await Apps.pack(ap, user, { + detail: true, + includeSecret: isSecure && (ap.userId === user!.id) + }); +}); diff --git a/packages/backend/src/server/api/endpoints/auth/accept.ts b/packages/backend/src/server/api/endpoints/auth/accept.ts new file mode 100644 index 0000000000..1d1d8ac227 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/auth/accept.ts @@ -0,0 +1,76 @@ +import * as crypto from 'crypto'; +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { AuthSessions, AccessTokens, Apps } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { secureRndstr } from '@/misc/secure-rndstr'; + +export const meta = { + tags: ['auth'], + + requireCredential: true as const, + + secure: true, + + params: { + token: { + validator: $.str + } + }, + + errors: { + noSuchSession: { + message: 'No such session.', + code: 'NO_SUCH_SESSION', + id: '9c72d8de-391a-43c1-9d06-08d29efde8df' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Fetch token + const session = await AuthSessions + .findOne({ token: ps.token }); + + if (session == null) { + throw new ApiError(meta.errors.noSuchSession); + } + + // Generate access token + const accessToken = secureRndstr(32, true); + + // Fetch exist access token + const exist = await AccessTokens.findOne({ + appId: session.appId, + userId: user.id, + }); + + if (exist == null) { + // Lookup app + const app = await Apps.findOneOrFail(session.appId); + + // Generate Hash + const sha256 = crypto.createHash('sha256'); + sha256.update(accessToken + app.secret); + const hash = sha256.digest('hex'); + + const now = new Date(); + + // Insert access token doc + await AccessTokens.insert({ + id: genId(), + createdAt: now, + lastUsedAt: now, + appId: session.appId, + userId: user.id, + token: accessToken, + hash: hash + }); + } + + // Update session + await AuthSessions.update(session.id, { + userId: user.id + }); +}); diff --git a/packages/backend/src/server/api/endpoints/auth/session/generate.ts b/packages/backend/src/server/api/endpoints/auth/session/generate.ts new file mode 100644 index 0000000000..859cf52ed3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/auth/session/generate.ts @@ -0,0 +1,70 @@ +import { v4 as uuid } from 'uuid'; +import $ from 'cafy'; +import config from '@/config/index'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { Apps, AuthSessions } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['auth'], + + requireCredential: false as const, + + params: { + appSecret: { + validator: $.str, + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + token: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + url: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'url', + }, + } + }, + + errors: { + noSuchApp: { + message: 'No such app.', + code: 'NO_SUCH_APP', + id: '92f93e63-428e-4f2f-a5a4-39e1407fe998' + } + } +}; + +export default define(meta, async (ps) => { + // Lookup app + const app = await Apps.findOne({ + secret: ps.appSecret + }); + + if (app == null) { + throw new ApiError(meta.errors.noSuchApp); + } + + // Generate token + const token = uuid(); + + // Create session token document + const doc = await AuthSessions.save({ + id: genId(), + createdAt: new Date(), + appId: app.id, + token: token + }); + + return { + token: doc.token, + url: `${config.authUrl}/${doc.token}` + }; +}); diff --git a/packages/backend/src/server/api/endpoints/auth/session/show.ts b/packages/backend/src/server/api/endpoints/auth/session/show.ts new file mode 100644 index 0000000000..23f1a56a37 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/auth/session/show.ts @@ -0,0 +1,58 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { AuthSessions } from '@/models/index'; + +export const meta = { + tags: ['auth'], + + requireCredential: false as const, + + params: { + token: { + validator: $.str, + } + }, + + errors: { + noSuchSession: { + message: 'No such session.', + code: 'NO_SUCH_SESSION', + id: 'bd72c97d-eba7-4adb-a467-f171b8847250' + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + app: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'App' + }, + token: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + } + } +}; + +export default define(meta, async (ps, user) => { + // Lookup session + const session = await AuthSessions.findOne({ + token: ps.token + }); + + if (session == null) { + throw new ApiError(meta.errors.noSuchSession); + } + + return await AuthSessions.pack(session, user); +}); diff --git a/packages/backend/src/server/api/endpoints/auth/session/userkey.ts b/packages/backend/src/server/api/endpoints/auth/session/userkey.ts new file mode 100644 index 0000000000..72201cb207 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/auth/session/userkey.ts @@ -0,0 +1,98 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { Apps, AuthSessions, AccessTokens, Users } from '@/models/index'; + +export const meta = { + tags: ['auth'], + + requireCredential: false as const, + + params: { + appSecret: { + validator: $.str, + }, + + token: { + validator: $.str, + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + accessToken: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + + user: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + }, + } + }, + + errors: { + noSuchApp: { + message: 'No such app.', + code: 'NO_SUCH_APP', + id: 'fcab192a-2c5a-43b7-8ad8-9b7054d8d40d' + }, + + noSuchSession: { + message: 'No such session.', + code: 'NO_SUCH_SESSION', + id: '5b5a1503-8bc8-4bd0-8054-dc189e8cdcb3' + }, + + pendingSession: { + message: 'This session is not completed yet.', + code: 'PENDING_SESSION', + id: '8c8a4145-02cc-4cca-8e66-29ba60445a8e' + } + } +}; + +export default define(meta, async (ps) => { + // Lookup app + const app = await Apps.findOne({ + secret: ps.appSecret + }); + + if (app == null) { + throw new ApiError(meta.errors.noSuchApp); + } + + // Fetch token + const session = await AuthSessions.findOne({ + token: ps.token, + appId: app.id + }); + + if (session == null) { + throw new ApiError(meta.errors.noSuchSession); + } + + if (session.userId == null) { + throw new ApiError(meta.errors.pendingSession); + } + + // Lookup access token + const accessToken = await AccessTokens.findOneOrFail({ + appId: app.id, + userId: session.userId + }); + + // Delete session + AuthSessions.delete(session.id); + + return { + accessToken: accessToken.token, + user: await Users.pack(session.userId, null, { + detail: true + }) + }; +}); diff --git a/packages/backend/src/server/api/endpoints/blocking/create.ts b/packages/backend/src/server/api/endpoints/blocking/create.ts new file mode 100644 index 0000000000..2953252394 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/blocking/create.ts @@ -0,0 +1,89 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import * as ms from 'ms'; +import create from '@/services/blocking/create'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { Blockings, NoteWatchings, Users } from '@/models/index'; + +export const meta = { + tags: ['account'], + + limit: { + duration: ms('1hour'), + max: 100 + }, + + requireCredential: true as const, + + kind: 'write:blocks', + + params: { + userId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '7cc4f851-e2f1-4621-9633-ec9e1d00c01e' + }, + + blockeeIsYourself: { + message: 'Blockee is yourself.', + code: 'BLOCKEE_IS_YOURSELF', + id: '88b19138-f28d-42c0-8499-6a31bbd0fdc6' + }, + + alreadyBlocking: { + message: 'You are already blocking that user.', + code: 'ALREADY_BLOCKING', + id: '787fed64-acb9-464a-82eb-afbd745b9614' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } +}; + +export default define(meta, async (ps, user) => { + const blocker = await Users.findOneOrFail(user.id); + + // 自分自身 + if (user.id === ps.userId) { + throw new ApiError(meta.errors.blockeeIsYourself); + } + + // Get blockee + const blockee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check if already blocking + const exist = await Blockings.findOne({ + blockerId: blocker.id, + blockeeId: blockee.id + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyBlocking); + } + + await create(blocker, blockee); + + NoteWatchings.delete({ + userId: blocker.id, + noteUserId: blockee.id + }); + + return await Users.pack(blockee.id, blocker, { + detail: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/blocking/delete.ts b/packages/backend/src/server/api/endpoints/blocking/delete.ts new file mode 100644 index 0000000000..a66e46fdf0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/blocking/delete.ts @@ -0,0 +1,85 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import * as ms from 'ms'; +import deleteBlocking from '@/services/blocking/delete'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { Blockings, Users } from '@/models/index'; + +export const meta = { + tags: ['account'], + + limit: { + duration: ms('1hour'), + max: 100 + }, + + requireCredential: true as const, + + kind: 'write:blocks', + + params: { + userId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '8621d8bf-c358-4303-a066-5ea78610eb3f' + }, + + blockeeIsYourself: { + message: 'Blockee is yourself.', + code: 'BLOCKEE_IS_YOURSELF', + id: '06f6fac6-524b-473c-a354-e97a40ae6eac' + }, + + notBlocking: { + message: 'You are not blocking that user.', + code: 'NOT_BLOCKING', + id: '291b2efa-60c6-45c0-9f6a-045c8f9b02cd' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + }, +}; + +export default define(meta, async (ps, user) => { + const blocker = await Users.findOneOrFail(user.id); + + // Check if the blockee is yourself + if (user.id === ps.userId) { + throw new ApiError(meta.errors.blockeeIsYourself); + } + + // Get blockee + const blockee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check not blocking + const exist = await Blockings.findOne({ + blockerId: blocker.id, + blockeeId: blockee.id + }); + + if (exist == null) { + throw new ApiError(meta.errors.notBlocking); + } + + // Delete blocking + await deleteBlocking(blocker, blockee); + + return await Users.pack(blockee.id, blocker, { + detail: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/blocking/list.ts b/packages/backend/src/server/api/endpoints/blocking/list.ts new file mode 100644 index 0000000000..fe25fdaba1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/blocking/list.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Blockings } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'read:blocks', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 30 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Blocking', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(Blockings.createQueryBuilder('blocking'), ps.sinceId, ps.untilId) + .andWhere(`blocking.blockerId = :meId`, { meId: me.id }); + + const blockings = await query + .take(ps.limit!) + .getMany(); + + return await Blockings.packMany(blockings, me); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/create.ts b/packages/backend/src/server/api/endpoints/channels/create.ts new file mode 100644 index 0000000000..0cedfd6c6a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/create.ts @@ -0,0 +1,68 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Channels, DriveFiles } from '@/models/index'; +import { Channel } from '@/models/entities/channel'; +import { genId } from '@/misc/gen-id'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['channels'], + + requireCredential: true as const, + + kind: 'write:channels', + + params: { + name: { + validator: $.str.range(1, 128) + }, + + description: { + validator: $.nullable.optional.str.range(1, 2048) + }, + + bannerId: { + validator: $.nullable.optional.type(ID), + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Channel', + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'cd1e9f3e-5a12-4ab4-96f6-5d0a2cc32050' + }, + } +}; + +export default define(meta, async (ps, user) => { + let banner = null; + if (ps.bannerId != null) { + banner = await DriveFiles.findOne({ + id: ps.bannerId, + userId: user.id + }); + + if (banner == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + const channel = await Channels.save({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + description: ps.description || null, + bannerId: banner ? banner.id : null, + } as Channel); + + return await Channels.pack(channel, user); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/featured.ts b/packages/backend/src/server/api/endpoints/channels/featured.ts new file mode 100644 index 0000000000..dc1f49f960 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/featured.ts @@ -0,0 +1,28 @@ +import define from '../../define'; +import { Channels } from '@/models/index'; + +export const meta = { + tags: ['channels'], + + requireCredential: false as const, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Channel', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = Channels.createQueryBuilder('channel') + .where('channel.lastNotedAt IS NOT NULL') + .orderBy('channel.lastNotedAt', 'DESC'); + + const channels = await query.take(10).getMany(); + + return await Promise.all(channels.map(x => Channels.pack(x, me))); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/follow.ts b/packages/backend/src/server/api/endpoints/channels/follow.ts new file mode 100644 index 0000000000..d4664e6996 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/follow.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Channels, ChannelFollowings } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { publishUserEvent } from '@/services/stream'; + +export const meta = { + tags: ['channels'], + + requireCredential: true as const, + + kind: 'write:channels', + + params: { + channelId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: 'c0031718-d573-4e85-928e-10039f1fbb68' + }, + } +}; + +export default define(meta, async (ps, user) => { + const channel = await Channels.findOne({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + await ChannelFollowings.insert({ + id: genId(), + createdAt: new Date(), + followerId: user.id, + followeeId: channel.id, + }); + + publishUserEvent(user.id, 'followChannel', channel); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/followed.ts b/packages/backend/src/server/api/endpoints/channels/followed.ts new file mode 100644 index 0000000000..be239a01d6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/followed.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Channels, ChannelFollowings } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['channels', 'account'], + + requireCredential: true as const, + + kind: 'read:channels', + + params: { + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 5 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Channel', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(ChannelFollowings.createQueryBuilder(), ps.sinceId, ps.untilId) + .andWhere({ followerId: me.id }); + + const followings = await query + .take(ps.limit!) + .getMany(); + + return await Promise.all(followings.map(x => Channels.pack(x.followeeId, me))); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/owned.ts b/packages/backend/src/server/api/endpoints/channels/owned.ts new file mode 100644 index 0000000000..4a2e9db17b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/owned.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Channels } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['channels', 'account'], + + requireCredential: true as const, + + kind: 'read:channels', + + params: { + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 5 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Channel', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(Channels.createQueryBuilder(), ps.sinceId, ps.untilId) + .andWhere({ userId: me.id }); + + const channels = await query + .take(ps.limit!) + .getMany(); + + return await Promise.all(channels.map(x => Channels.pack(x, me))); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/pin-note.ts b/packages/backend/src/server/api/endpoints/channels/pin-note.ts new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/pin-note.ts diff --git a/packages/backend/src/server/api/endpoints/channels/show.ts b/packages/backend/src/server/api/endpoints/channels/show.ts new file mode 100644 index 0000000000..803ce6363d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/show.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Channels } from '@/models/index'; + +export const meta = { + tags: ['channels'], + + requireCredential: false as const, + + params: { + channelId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Channel', + }, + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: '6f6c314b-7486-4897-8966-c04a66a02923' + }, + } +}; + +export default define(meta, async (ps, me) => { + const channel = await Channels.findOne({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + return await Channels.pack(channel, me); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts new file mode 100644 index 0000000000..0ed057a11e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -0,0 +1,85 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Notes, Channels } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { activeUsersChart } from '@/services/chart/index'; + +export const meta = { + tags: ['notes', 'channels'], + + requireCredential: false as const, + + params: { + channelId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + sinceDate: { + validator: $.optional.num, + }, + + untilDate: { + validator: $.optional.num, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: '4d0eeeba-a02c-4c3c-9966-ef60d38d2e7f' + } + } +}; + +export default define(meta, async (ps, user) => { + const channel = await Channels.findOne({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + //#region Construct query + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.channelId = :channelId', { channelId: channel.id }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); + //#endregion + + const timeline = await query.take(ps.limit!).getMany(); + + if (user) activeUsersChart.update(user); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/unfollow.ts b/packages/backend/src/server/api/endpoints/channels/unfollow.ts new file mode 100644 index 0000000000..700f8e93ba --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/unfollow.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Channels, ChannelFollowings } from '@/models/index'; +import { publishUserEvent } from '@/services/stream'; + +export const meta = { + tags: ['channels'], + + requireCredential: true as const, + + kind: 'write:channels', + + params: { + channelId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: '19959ee9-0153-4c51-bbd9-a98c49dc59d6' + }, + } +}; + +export default define(meta, async (ps, user) => { + const channel = await Channels.findOne({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + await ChannelFollowings.delete({ + followerId: user.id, + followeeId: channel.id, + }); + + publishUserEvent(user.id, 'unfollowChannel', channel); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts new file mode 100644 index 0000000000..9b447bd04b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -0,0 +1,94 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Channels, DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['channels'], + + requireCredential: true as const, + + kind: 'write:channels', + + params: { + channelId: { + validator: $.type(ID), + }, + + name: { + validator: $.optional.str.range(1, 128) + }, + + description: { + validator: $.nullable.optional.str.range(1, 2048) + }, + + bannerId: { + validator: $.nullable.optional.type(ID), + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Channel', + }, + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: 'f9c5467f-d492-4c3c-9a8d-a70dacc86512' + }, + + accessDenied: { + message: 'You do not have edit privilege of the channel.', + code: 'ACCESS_DENIED', + id: '1fb7cb09-d46a-4fdf-b8df-057788cce513' + }, + + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'e86c14a4-0da2-4032-8df3-e737a04c7f3b' + }, + } +}; + +export default define(meta, async (ps, me) => { + const channel = await Channels.findOne({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + if (channel.userId !== me.id) { + throw new ApiError(meta.errors.accessDenied); + } + + // tslint:disable-next-line:no-unnecessary-initializer + let banner = undefined; + if (ps.bannerId != null) { + banner = await DriveFiles.findOne({ + id: ps.bannerId, + userId: me.id + }); + + if (banner == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } else if (ps.bannerId === null) { + banner = null; + } + + await Channels.update(channel.id, { + ...(ps.name !== undefined ? { name: ps.name } : {}), + ...(ps.description !== undefined ? { description: ps.description } : {}), + ...(banner ? { bannerId: banner.id } : {}), + }); + + return await Channels.pack(channel.id, me); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/active-users.ts b/packages/backend/src/server/api/endpoints/charts/active-users.ts new file mode 100644 index 0000000000..c4878f7d61 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/active-users.ts @@ -0,0 +1,30 @@ +import $ from 'cafy'; +import define from '../../define'; +import { convertLog } from '@/services/chart/core'; +import { activeUsersChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts', 'users'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + }, + + res: convertLog(activeUsersChart.schema), +}; + +export default define(meta, async (ps) => { + return await activeUsersChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/drive.ts b/packages/backend/src/server/api/endpoints/charts/drive.ts new file mode 100644 index 0000000000..07bff82cf4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/drive.ts @@ -0,0 +1,30 @@ +import $ from 'cafy'; +import define from '../../define'; +import { convertLog } from '@/services/chart/core'; +import { driveChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts', 'drive'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + }, + + res: convertLog(driveChart.schema), +}; + +export default define(meta, async (ps) => { + return await driveChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/federation.ts b/packages/backend/src/server/api/endpoints/charts/federation.ts new file mode 100644 index 0000000000..9575f9a7b7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/federation.ts @@ -0,0 +1,30 @@ +import $ from 'cafy'; +import define from '../../define'; +import { convertLog } from '@/services/chart/core'; +import { federationChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + }, + + res: convertLog(federationChart.schema), +}; + +export default define(meta, async (ps) => { + return await federationChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/hashtag.ts b/packages/backend/src/server/api/endpoints/charts/hashtag.ts new file mode 100644 index 0000000000..53dc61e51e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/hashtag.ts @@ -0,0 +1,34 @@ +import $ from 'cafy'; +import define from '../../define'; +import { convertLog } from '@/services/chart/core'; +import { hashtagChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts', 'hashtags'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + + tag: { + validator: $.str, + }, + }, + + res: convertLog(hashtagChart.schema), +}; + +export default define(meta, async (ps) => { + return await hashtagChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null, ps.tag); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/instance.ts b/packages/backend/src/server/api/endpoints/charts/instance.ts new file mode 100644 index 0000000000..1835023188 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/instance.ts @@ -0,0 +1,34 @@ +import $ from 'cafy'; +import define from '../../define'; +import { convertLog } from '@/services/chart/core'; +import { instanceChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + + host: { + validator: $.str, + } + }, + + res: convertLog(instanceChart.schema), +}; + +export default define(meta, async (ps) => { + return await instanceChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null, ps.host); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/network.ts b/packages/backend/src/server/api/endpoints/charts/network.ts new file mode 100644 index 0000000000..b524df93be --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/network.ts @@ -0,0 +1,30 @@ +import $ from 'cafy'; +import define from '../../define'; +import { convertLog } from '@/services/chart/core'; +import { networkChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + }, + + res: convertLog(networkChart.schema), +}; + +export default define(meta, async (ps) => { + return await networkChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/notes.ts b/packages/backend/src/server/api/endpoints/charts/notes.ts new file mode 100644 index 0000000000..676f302939 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/notes.ts @@ -0,0 +1,30 @@ +import $ from 'cafy'; +import define from '../../define'; +import { convertLog } from '@/services/chart/core'; +import { notesChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts', 'notes'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + }, + + res: convertLog(notesChart.schema), +}; + +export default define(meta, async (ps) => { + return await notesChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/user/drive.ts b/packages/backend/src/server/api/endpoints/charts/user/drive.ts new file mode 100644 index 0000000000..f2770e2df8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/user/drive.ts @@ -0,0 +1,35 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { convertLog } from '@/services/chart/core'; +import { perUserDriveChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts', 'drive', 'users'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + + userId: { + validator: $.type(ID), + } + }, + + res: convertLog(perUserDriveChart.schema), +}; + +export default define(meta, async (ps) => { + return await perUserDriveChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null, ps.userId); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/user/following.ts b/packages/backend/src/server/api/endpoints/charts/user/following.ts new file mode 100644 index 0000000000..8c97b63e89 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/user/following.ts @@ -0,0 +1,35 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { convertLog } from '@/services/chart/core'; +import { perUserFollowingChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts', 'users', 'following'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + + userId: { + validator: $.type(ID), + } + }, + + res: convertLog(perUserFollowingChart.schema), +}; + +export default define(meta, async (ps) => { + return await perUserFollowingChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null, ps.userId); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/user/notes.ts b/packages/backend/src/server/api/endpoints/charts/user/notes.ts new file mode 100644 index 0000000000..0d5f5a8b6a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/user/notes.ts @@ -0,0 +1,35 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { convertLog } from '@/services/chart/core'; +import { perUserNotesChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts', 'users', 'notes'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + + userId: { + validator: $.type(ID), + } + }, + + res: convertLog(perUserNotesChart.schema), +}; + +export default define(meta, async (ps) => { + return await perUserNotesChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null, ps.userId); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/user/reactions.ts b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts new file mode 100644 index 0000000000..3cabe40d56 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts @@ -0,0 +1,35 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { convertLog } from '@/services/chart/core'; +import { perUserReactionsChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts', 'users', 'reactions'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + + userId: { + validator: $.type(ID), + } + }, + + res: convertLog(perUserReactionsChart.schema), +}; + +export default define(meta, async (ps) => { + return await perUserReactionsChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null, ps.userId); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/users.ts b/packages/backend/src/server/api/endpoints/charts/users.ts new file mode 100644 index 0000000000..deac89b59d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/users.ts @@ -0,0 +1,30 @@ +import $ from 'cafy'; +import define from '../../define'; +import { convertLog } from '@/services/chart/core'; +import { usersChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts', 'users'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + }, + + res: convertLog(usersChart.schema), +}; + +export default define(meta, async (ps) => { + return await usersChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/add-note.ts b/packages/backend/src/server/api/endpoints/clips/add-note.ts new file mode 100644 index 0000000000..79d7b8adde --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/add-note.ts @@ -0,0 +1,76 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ClipNotes, Clips } from '@/models/index'; +import { ApiError } from '../../error'; +import { genId } from '@/misc/gen-id'; +import { getNote } from '../../common/getters'; + +export const meta = { + tags: ['account', 'notes', 'clips'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + clipId: { + validator: $.type(ID), + }, + + noteId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf' + }, + + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'fc8c0b49-c7a3-4664-a0a6-b418d386bb8b' + }, + + alreadyClipped: { + message: 'The note has already been clipped.', + code: 'ALREADY_CLIPPED', + id: '734806c4-542c-463a-9311-15c512803965' + }, + } +}; + +export default define(meta, async (ps, user) => { + const clip = await Clips.findOne({ + id: ps.clipId, + userId: user.id + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const exist = await ClipNotes.findOne({ + noteId: note.id, + clipId: clip.id + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyClipped); + } + + await ClipNotes.insert({ + id: genId(), + noteId: note.id, + clipId: clip.id + }); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/create.ts b/packages/backend/src/server/api/endpoints/clips/create.ts new file mode 100644 index 0000000000..02d2773709 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/create.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import define from '../../define'; +import { genId } from '@/misc/gen-id'; +import { Clips } from '@/models/index'; + +export const meta = { + tags: ['clips'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + name: { + validator: $.str.range(1, 100) + }, + + isPublic: { + validator: $.optional.bool + }, + + description: { + validator: $.optional.nullable.str.range(1, 2048) + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Clip' + } +}; + +export default define(meta, async (ps, user) => { + const clip = await Clips.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + isPublic: ps.isPublic, + description: ps.description, + }).then(x => Clips.findOneOrFail(x.identifiers[0])); + + return await Clips.pack(clip); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/delete.ts b/packages/backend/src/server/api/endpoints/clips/delete.ts new file mode 100644 index 0000000000..ca489af3bf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/delete.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Clips } from '@/models/index'; + +export const meta = { + tags: ['clips'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + clipId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: '70ca08ba-6865-4630-b6fb-8494759aa754' + } + } +}; + +export default define(meta, async (ps, user) => { + const clip = await Clips.findOne({ + id: ps.clipId, + userId: user.id + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + await Clips.delete(clip.id); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/list.ts b/packages/backend/src/server/api/endpoints/clips/list.ts new file mode 100644 index 0000000000..1f6db9b979 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/list.ts @@ -0,0 +1,28 @@ +import define from '../../define'; +import { Clips } from '@/models/index'; + +export const meta = { + tags: ['clips', 'account'], + + requireCredential: true as const, + + kind: 'read:account', + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Clip' + } + } +}; + +export default define(meta, async (ps, me) => { + const clips = await Clips.find({ + userId: me.id, + }); + + return await Promise.all(clips.map(x => Clips.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts new file mode 100644 index 0000000000..5a9fed52fa --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/notes.ts @@ -0,0 +1,93 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ClipNotes, Clips, Notes } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { ApiError } from '../../error'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['account', 'notes', 'clips'], + + requireCredential: false as const, + + kind: 'read:account', + + params: { + clipId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00' + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note' + } + } +}; + +export default define(meta, async (ps, user) => { + const clip = await Clips.findOne({ + id: ps.clipId, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + if (!clip.isPublic && (user == null || (clip.userId !== user.id))) { + throw new ApiError(meta.errors.noSuchClip); + } + + const clipQuery = ClipNotes.createQueryBuilder('joining') + .select('joining.noteId') + .where('joining.clipId = :clipId', { clipId: clip.id }); + + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(`note.id IN (${ clipQuery.getQuery() })`) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .setParameters(clipQuery.getParameters()); + + if (user) { + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + } + + const notes = await query + .take(ps.limit!) + .getMany(); + + return await Notes.packMany(notes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/show.ts b/packages/backend/src/server/api/endpoints/clips/show.ts new file mode 100644 index 0000000000..8f245cd18e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/show.ts @@ -0,0 +1,50 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Clips } from '@/models/index'; + +export const meta = { + tags: ['clips', 'account'], + + requireCredential: false as const, + + kind: 'read:account', + + params: { + clipId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Clip' + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the clip + const clip = await Clips.findOne({ + id: ps.clipId, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + if (!clip.isPublic && (me == null || (clip.userId !== me.id))) { + throw new ApiError(meta.errors.noSuchClip); + } + + return await Clips.pack(clip); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts new file mode 100644 index 0000000000..7f645560bb --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/update.ts @@ -0,0 +1,65 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Clips } from '@/models/index'; + +export const meta = { + tags: ['clips'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + clipId: { + validator: $.type(ID), + }, + + name: { + validator: $.str.range(1, 100), + }, + + isPublic: { + validator: $.optional.bool + }, + + description: { + validator: $.optional.nullable.str.range(1, 2048) + } + }, + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: 'b4d92d70-b216-46fa-9a3f-a8c811699257' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Clip' + } +}; + +export default define(meta, async (ps, user) => { + // Fetch the clip + const clip = await Clips.findOne({ + id: ps.clipId, + userId: user.id + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + await Clips.update(clip.id, { + name: ps.name, + description: ps.description, + isPublic: ps.isPublic, + }); + + return await Clips.pack(clip.id); +}); diff --git a/packages/backend/src/server/api/endpoints/drive.ts b/packages/backend/src/server/api/endpoints/drive.ts new file mode 100644 index 0000000000..2974ccfab9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive.ts @@ -0,0 +1,38 @@ +import define from '../define'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['drive', 'account'], + + requireCredential: true as const, + + kind: 'read:drive', + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + capacity: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + usage: { + type: 'number' as const, + optional: false as const, nullable: false as const, + } + } + } +}; + +export default define(meta, async (ps, user) => { + const instance = await fetchMeta(true); + + // Calculate drive usage + const usage = await DriveFiles.calcDriveUsageOf(user.id); + + return { + capacity: 1024 * 1024 * instance.localDriveCapacityMb, + usage: usage + }; +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts new file mode 100644 index 0000000000..95435e1e43 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files.ts @@ -0,0 +1,70 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { DriveFiles } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'read:drive', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + folderId: { + validator: $.optional.nullable.type(ID), + default: null, + }, + + type: { + validator: $.optional.nullable.str.match(/^[a-zA-Z\/\-*]+$/) + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile', + } + }, +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId) + .andWhere('file.userId = :userId', { userId: user.id }); + + if (ps.folderId) { + query.andWhere('file.folderId = :folderId', { folderId: ps.folderId }); + } else { + query.andWhere('file.folderId IS NULL'); + } + + if (ps.type) { + if (ps.type.endsWith('/*')) { + query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); + } else { + query.andWhere('file.type = :type', { type: ps.type }); + } + } + + const files = await query.take(ps.limit!).getMany(); + + return await DriveFiles.packMany(files, { detail: false, self: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts new file mode 100644 index 0000000000..eec7d7877e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts @@ -0,0 +1,57 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { DriveFiles, Notes } from '@/models/index'; + +export const meta = { + tags: ['drive', 'notes'], + + requireCredential: true as const, + + kind: 'read:drive', + + params: { + fileId: { + validator: $.type(ID), + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'c118ece3-2e4b-4296-99d1-51756e32d232', + } + } +}; + +export default define(meta, async (ps, user) => { + // Fetch file + const file = await DriveFiles.findOne({ + id: ps.fileId, + userId: user.id, + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + + const notes = await Notes.createQueryBuilder('note') + .where(':file = ANY(note.fileIds)', { file: file.id }) + .getMany(); + + return await Notes.packMany(notes, user, { + detail: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/check-existence.ts b/packages/backend/src/server/api/endpoints/drive/files/check-existence.ts new file mode 100644 index 0000000000..2c36078421 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/check-existence.ts @@ -0,0 +1,31 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'read:drive', + + params: { + md5: { + validator: $.str, + } + }, + + res: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne({ + md5: ps.md5, + userId: user.id, + }); + + return file != null; +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts new file mode 100644 index 0000000000..2abc104e6c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -0,0 +1,89 @@ +import * as ms from 'ms'; +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import create from '@/services/drive/add-file'; +import define from '../../../define'; +import { apiLogger } from '../../../logger'; +import { ApiError } from '../../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + limit: { + duration: ms('1hour'), + max: 120 + }, + + requireFile: true, + + kind: 'write:drive', + + params: { + folderId: { + validator: $.optional.nullable.type(ID), + default: null, + }, + + name: { + validator: $.optional.nullable.str, + default: null, + }, + + isSensitive: { + validator: $.optional.either($.bool, $.str), + default: false, + transform: (v: any): boolean => v === true || v === 'true', + }, + + force: { + validator: $.optional.either($.bool, $.str), + default: false, + transform: (v: any): boolean => v === true || v === 'true', + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile', + }, + + errors: { + invalidFileName: { + message: 'Invalid file name.', + code: 'INVALID_FILE_NAME', + id: 'f449b209-0c60-4e51-84d5-29486263bfd4' + } + } +}; + +export default define(meta, async (ps, user, _, file, cleanup) => { + // Get 'name' parameter + let name = ps.name || file.originalname; + if (name !== undefined && name !== null) { + name = name.trim(); + if (name.length === 0) { + name = null; + } else if (name === 'blob') { + name = null; + } else if (!DriveFiles.validateFileName(name)) { + throw new ApiError(meta.errors.invalidFileName); + } + } else { + name = null; + } + + try { + // Create file + const driveFile = await create(user, file.path, name, null, ps.folderId, ps.force, false, null, null, ps.isSensitive); + return await DriveFiles.pack(driveFile, { self: true }); + } catch (e) { + apiLogger.error(e); + throw new ApiError(); + } finally { + cleanup!(); + } +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/delete.ts b/packages/backend/src/server/api/endpoints/drive/files/delete.ts new file mode 100644 index 0000000000..038325694d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/delete.ts @@ -0,0 +1,53 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { deleteFile } from '@/services/drive/delete-file'; +import { publishDriveStream } from '@/services/stream'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'write:drive', + + params: { + fileId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: '908939ec-e52b-4458-b395-1025195cea58' + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '5eb8d909-2540-4970-90b8-dd6f86088121' + }, + } +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + + if (!user.isAdmin && !user.isModerator && (file.userId !== user.id)) { + throw new ApiError(meta.errors.accessDenied); + } + + // Delete + await deleteFile(file); + + // Publish fileDeleted event + publishDriveStream(user.id, 'fileDeleted', file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/find-by-hash.ts b/packages/backend/src/server/api/endpoints/drive/files/find-by-hash.ts new file mode 100644 index 0000000000..5fea7bbbb0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/find-by-hash.ts @@ -0,0 +1,36 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'read:drive', + + params: { + md5: { + validator: $.str, + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile', + } + }, +}; + +export default define(meta, async (ps, user) => { + const files = await DriveFiles.find({ + md5: ps.md5, + userId: user.id, + }); + + return await DriveFiles.packMany(files, { self: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/find.ts b/packages/backend/src/server/api/endpoints/drive/files/find.ts new file mode 100644 index 0000000000..dd419f4c04 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/find.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + tags: ['drive'], + + kind: 'read:drive', + + params: { + name: { + validator: $.str + }, + + folderId: { + validator: $.optional.nullable.type(ID), + default: null, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile', + } + }, +}; + +export default define(meta, async (ps, user) => { + const files = await DriveFiles.find({ + name: ps.name, + userId: user.id, + folderId: ps.folderId + }); + + return await Promise.all(files.map(file => DriveFiles.pack(file, { self: true }))); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/show.ts b/packages/backend/src/server/api/endpoints/drive/files/show.ts new file mode 100644 index 0000000000..a96ebaa123 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/show.ts @@ -0,0 +1,84 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { DriveFile } from '@/models/entities/drive-file'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'read:drive', + + params: { + fileId: { + validator: $.optional.type(ID), + }, + + url: { + validator: $.optional.str, + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile', + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: '067bc436-2718-4795-b0fb-ecbe43949e31' + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '25b73c73-68b1-41d0-bad1-381cfdf6579f' + }, + + fileIdOrUrlRequired: { + message: 'fileId or url required.', + code: 'INVALID_PARAM', + id: '89674805-722c-440c-8d88-5641830dc3e4' + } + } +}; + +export default define(meta, async (ps, user) => { + let file: DriveFile | undefined; + + if (ps.fileId) { + file = await DriveFiles.findOne(ps.fileId); + } else if (ps.url) { + file = await DriveFiles.findOne({ + where: [{ + url: ps.url + }, { + webpublicUrl: ps.url + }, { + thumbnailUrl: ps.url + }], + }); + } else { + throw new ApiError(meta.errors.fileIdOrUrlRequired); + } + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + + if (!user.isAdmin && !user.isModerator && (file.userId !== user.id)) { + throw new ApiError(meta.errors.accessDenied); + } + + return await DriveFiles.pack(file, { + detail: true, + withUser: true, + self: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/update.ts b/packages/backend/src/server/api/endpoints/drive/files/update.ts new file mode 100644 index 0000000000..f277a9c3dc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/update.ts @@ -0,0 +1,116 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { publishDriveStream } from '@/services/stream'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { DriveFiles, DriveFolders } from '@/models/index'; +import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'write:drive', + + params: { + fileId: { + validator: $.type(ID), + }, + + folderId: { + validator: $.optional.nullable.type(ID), + default: undefined as any, + }, + + name: { + validator: $.optional.str.pipe(DriveFiles.validateFileName), + default: undefined as any, + }, + + isSensitive: { + validator: $.optional.bool, + default: undefined as any, + }, + + comment: { + validator: $.optional.nullable.str.max(DB_MAX_IMAGE_COMMENT_LENGTH), + default: undefined as any, + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'e7778c7e-3af9-49cd-9690-6dbc3e6c972d' + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '01a53b27-82fc-445b-a0c1-b558465a8ed2' + }, + + noSuchFolder: { + message: 'No such folder.', + code: 'NO_SUCH_FOLDER', + id: 'ea8fb7a5-af77-4a08-b608-c0218176cd73' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile' + } +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + + if (!user.isAdmin && !user.isModerator && (file.userId !== user.id)) { + throw new ApiError(meta.errors.accessDenied); + } + + if (ps.name) file.name = ps.name; + + if (ps.comment !== undefined) file.comment = ps.comment; + + if (ps.isSensitive !== undefined) file.isSensitive = ps.isSensitive; + + if (ps.folderId !== undefined) { + if (ps.folderId === null) { + file.folderId = null; + } else { + const folder = await DriveFolders.findOne({ + id: ps.folderId, + userId: user.id + }); + + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + + file.folderId = folder.id; + } + } + + await DriveFiles.update(file.id, { + name: file.name, + comment: file.comment, + folderId: file.folderId, + isSensitive: file.isSensitive + }); + + const fileObj = await DriveFiles.pack(file, { self: true }); + + // Publish fileUpdated event + publishDriveStream(user.id, 'fileUpdated', fileObj); + + return fileObj; +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts new file mode 100644 index 0000000000..9f10a42d24 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts @@ -0,0 +1,64 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import * as ms from 'ms'; +import uploadFromUrl from '@/services/drive/upload-from-url'; +import define from '../../../define'; +import { DriveFiles } from '@/models/index'; +import { publishMainStream } from '@/services/stream'; +import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits'; + +export const meta = { + tags: ['drive'], + + limit: { + duration: ms('1hour'), + max: 60 + }, + + requireCredential: true as const, + + kind: 'write:drive', + + params: { + url: { + // TODO: Validate this url + validator: $.str, + }, + + folderId: { + validator: $.optional.nullable.type(ID), + default: null, + }, + + isSensitive: { + validator: $.optional.bool, + default: false, + }, + + comment: { + validator: $.optional.nullable.str.max(DB_MAX_IMAGE_COMMENT_LENGTH), + default: null, + }, + + marker: { + validator: $.optional.nullable.str, + default: null, + }, + + force: { + validator: $.optional.bool, + default: false, + } + } +}; + +export default define(meta, async (ps, user) => { + uploadFromUrl(ps.url, user, ps.folderId, null, ps.isSensitive, ps.force, false, ps.comment).then(file => { + DriveFiles.pack(file, { self: true }).then(packedFile => { + publishMainStream(user.id, 'urlUploadFinished', { + marker: ps.marker, + file: packedFile + }); + }); + }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders.ts b/packages/backend/src/server/api/endpoints/drive/folders.ts new file mode 100644 index 0000000000..6f16878b13 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/folders.ts @@ -0,0 +1,58 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { DriveFolders } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'read:drive', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + folderId: { + validator: $.optional.nullable.type(ID), + default: null, + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFolder', + } + }, +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(DriveFolders.createQueryBuilder('folder'), ps.sinceId, ps.untilId) + .andWhere('folder.userId = :userId', { userId: user.id }); + + if (ps.folderId) { + query.andWhere('folder.parentId = :parentId', { parentId: ps.folderId }); + } else { + query.andWhere('folder.parentId IS NULL'); + } + + const folders = await query.take(ps.limit!).getMany(); + + return await Promise.all(folders.map(folder => DriveFolders.pack(folder))); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/create.ts b/packages/backend/src/server/api/endpoints/drive/folders/create.ts new file mode 100644 index 0000000000..80f96bd641 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/folders/create.ts @@ -0,0 +1,72 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { publishDriveStream } from '@/services/stream'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { DriveFolders } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'write:drive', + + params: { + name: { + validator: $.optional.str.pipe(DriveFolders.validateFolderName), + default: 'Untitled', + }, + + parentId: { + validator: $.optional.nullable.type(ID), + } + }, + + errors: { + noSuchFolder: { + message: 'No such folder.', + code: 'NO_SUCH_FOLDER', + id: '53326628-a00d-40a6-a3cd-8975105c0f95' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFolder' + } +}; + +export default define(meta, async (ps, user) => { + // If the parent folder is specified + let parent = null; + if (ps.parentId) { + // Fetch parent folder + parent = await DriveFolders.findOne({ + id: ps.parentId, + userId: user.id + }); + + if (parent == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + } + + // Create folder + const folder = await DriveFolders.insert({ + id: genId(), + createdAt: new Date(), + name: ps.name, + parentId: parent !== null ? parent.id : null, + userId: user.id + }).then(x => DriveFolders.findOneOrFail(x.identifiers[0])); + + const folderObj = await DriveFolders.pack(folder); + + // Publish folderCreated event + publishDriveStream(user.id, 'folderCreated', folderObj); + + return folderObj; +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/delete.ts b/packages/backend/src/server/api/endpoints/drive/folders/delete.ts new file mode 100644 index 0000000000..38b4aef103 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/folders/delete.ts @@ -0,0 +1,60 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { publishDriveStream } from '@/services/stream'; +import { ApiError } from '../../../error'; +import { DriveFolders, DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'write:drive', + + params: { + folderId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchFolder: { + message: 'No such folder.', + code: 'NO_SUCH_FOLDER', + id: '1069098f-c281-440f-b085-f9932edbe091' + }, + + hasChildFilesOrFolders: { + message: 'This folder has child files or folders.', + code: 'HAS_CHILD_FILES_OR_FOLDERS', + id: 'b0fc8a17-963c-405d-bfbc-859a487295e1' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Get folder + const folder = await DriveFolders.findOne({ + id: ps.folderId, + userId: user.id + }); + + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + + const [childFoldersCount, childFilesCount] = await Promise.all([ + DriveFolders.count({ parentId: folder.id }), + DriveFiles.count({ folderId: folder.id }) + ]); + + if (childFoldersCount !== 0 || childFilesCount !== 0) { + throw new ApiError(meta.errors.hasChildFilesOrFolders); + } + + await DriveFolders.delete(folder.id); + + // Publish folderCreated event + publishDriveStream(user.id, 'folderDeleted', folder.id); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/find.ts b/packages/backend/src/server/api/endpoints/drive/folders/find.ts new file mode 100644 index 0000000000..a6c5a49988 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/folders/find.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { DriveFolders } from '@/models/index'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'read:drive', + + params: { + name: { + validator: $.str + }, + + parentId: { + validator: $.optional.nullable.type(ID), + default: null, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFolder', + } + }, +}; + +export default define(meta, async (ps, user) => { + const folders = await DriveFolders.find({ + name: ps.name, + userId: user.id, + parentId: ps.parentId + }); + + return await Promise.all(folders.map(folder => DriveFolders.pack(folder))); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/show.ts b/packages/backend/src/server/api/endpoints/drive/folders/show.ts new file mode 100644 index 0000000000..e907a24f05 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/folders/show.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { DriveFolders } from '@/models/index'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'read:drive', + + params: { + folderId: { + validator: $.type(ID), + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFolder', + }, + + errors: { + noSuchFolder: { + message: 'No such folder.', + code: 'NO_SUCH_FOLDER', + id: 'd74ab9eb-bb09-4bba-bf24-fb58f761e1e9' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Get folder + const folder = await DriveFolders.findOne({ + id: ps.folderId, + userId: user.id + }); + + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + + return await DriveFolders.pack(folder, { + detail: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/update.ts b/packages/backend/src/server/api/endpoints/drive/folders/update.ts new file mode 100644 index 0000000000..612252e6df --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/folders/update.ts @@ -0,0 +1,123 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { publishDriveStream } from '@/services/stream'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { DriveFolders } from '@/models/index'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'write:drive', + + params: { + folderId: { + validator: $.type(ID), + }, + + name: { + validator: $.optional.str.pipe(DriveFolders.validateFolderName), + }, + + parentId: { + validator: $.optional.nullable.type(ID), + } + }, + + errors: { + noSuchFolder: { + message: 'No such folder.', + code: 'NO_SUCH_FOLDER', + id: 'f7974dac-2c0d-4a27-926e-23583b28e98e' + }, + + noSuchParentFolder: { + message: 'No such parent folder.', + code: 'NO_SUCH_PARENT_FOLDER', + id: 'ce104e3a-faaf-49d5-b459-10ff0cbbcaa1' + }, + + recursiveNesting: { + message: 'It can not be structured like nesting folders recursively.', + code: 'NO_SUCH_PARENT_FOLDER', + id: 'ce104e3a-faaf-49d5-b459-10ff0cbbcaa1' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFolder' + } +}; + +export default define(meta, async (ps, user) => { + // Fetch folder + const folder = await DriveFolders.findOne({ + id: ps.folderId, + userId: user.id + }); + + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + + if (ps.name) folder.name = ps.name; + + if (ps.parentId !== undefined) { + if (ps.parentId === folder.id) { + throw new ApiError(meta.errors.recursiveNesting); + } else if (ps.parentId === null) { + folder.parentId = null; + } else { + // Get parent folder + const parent = await DriveFolders.findOne({ + id: ps.parentId, + userId: user.id + }); + + if (parent == null) { + throw new ApiError(meta.errors.noSuchParentFolder); + } + + // Check if the circular reference will occur + async function checkCircle(folderId: any): Promise<boolean> { + // Fetch folder + const folder2 = await DriveFolders.findOne({ + id: folderId + }); + + if (folder2!.id === folder!.id) { + return true; + } else if (folder2!.parentId) { + return await checkCircle(folder2!.parentId); + } else { + return false; + } + } + + if (parent.parentId !== null) { + if (await checkCircle(parent.parentId)) { + throw new ApiError(meta.errors.recursiveNesting); + } + } + + folder.parentId = parent.id; + } + } + + // Update + DriveFolders.update(folder.id, { + name: folder.name, + parentId: folder.parentId + }); + + const folderObj = await DriveFolders.pack(folder); + + // Publish folderUpdated event + publishDriveStream(user.id, 'folderUpdated', folderObj); + + return folderObj; +}); diff --git a/packages/backend/src/server/api/endpoints/drive/stream.ts b/packages/backend/src/server/api/endpoints/drive/stream.ts new file mode 100644 index 0000000000..141e02f748 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/stream.ts @@ -0,0 +1,59 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { DriveFiles } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'read:drive', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + type: { + validator: $.optional.str.match(/^[a-zA-Z\/\-*]+$/) + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile', + } + }, +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId) + .andWhere('file.userId = :userId', { userId: user.id }); + + if (ps.type) { + if (ps.type.endsWith('/*')) { + query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); + } else { + query.andWhere('file.type = :type', { type: ps.type }); + } + } + + const files = await query.take(ps.limit!).getMany(); + + return await DriveFiles.packMany(files, { detail: false, self: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/email-address/available.ts b/packages/backend/src/server/api/endpoints/email-address/available.ts new file mode 100644 index 0000000000..f6fccd59b0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/email-address/available.ts @@ -0,0 +1,34 @@ +import $ from 'cafy'; +import define from '../../define'; +import { validateEmailForAccount } from '@/services/validate-email-for-account'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + emailAddress: { + validator: $.str + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + available: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + reason: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + } + } +}; + +export default define(meta, async (ps) => { + return await validateEmailForAccount(ps.emailAddress); +}); diff --git a/packages/backend/src/server/api/endpoints/endpoint.ts b/packages/backend/src/server/api/endpoints/endpoint.ts new file mode 100644 index 0000000000..1a04d8bee8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/endpoint.ts @@ -0,0 +1,26 @@ +import $ from 'cafy'; +import define from '../define'; +import endpoints from '../endpoints'; + +export const meta = { + requireCredential: false as const, + + tags: ['meta'], + + params: { + endpoint: { + validator: $.str, + } + }, +}; + +export default define(meta, async (ps) => { + const ep = endpoints.find(x => x.name === ps.endpoint); + if (ep == null) return null; + return { + params: Object.entries(ep.meta.params || {}).map(([k, v]) => ({ + name: k, + type: v.validator.name === 'ID' ? 'String' : v.validator.name + })) + }; +}); diff --git a/packages/backend/src/server/api/endpoints/endpoints.ts b/packages/backend/src/server/api/endpoints/endpoints.ts new file mode 100644 index 0000000000..f7b9757d8d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/endpoints.ts @@ -0,0 +1,30 @@ +import define from '../define'; +import endpoints from '../endpoints'; + +export const meta = { + requireCredential: false as const, + + tags: ['meta'], + + params: { + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + example: [ + 'admin/abuse-user-reports', + 'admin/accounts/create', + 'admin/announcements/create', + '...' + ] + } +}; + +export default define(meta, async () => { + return endpoints.map(x => x.name); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/dns.ts b/packages/backend/src/server/api/endpoints/federation/dns.ts new file mode 100644 index 0000000000..7ba566301a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/dns.ts @@ -0,0 +1,43 @@ +import { promises as dns } from 'dns'; +import $ from 'cafy'; +import define from '../../define'; +import { Instances } from '@/models/index'; +import { toPuny } from '@/misc/convert-host'; + +const resolver = new dns.Resolver(); +resolver.setServers(['1.1.1.1']); + +export const meta = { + tags: ['federation'], + + requireCredential: false as const, + + params: { + host: { + validator: $.str + } + }, +}; + +export default define(meta, async (ps, me) => { + const instance = await Instances.findOneOrFail({ host: toPuny(ps.host) }); + + const [ + resolved4, + resolved6, + resolvedCname, + resolvedTxt, + ] = await Promise.all([ + resolver.resolve4(instance.host).catch(() => []), + resolver.resolve6(instance.host).catch(() => []), + resolver.resolveCname(instance.host).catch(() => []), + resolver.resolveTxt(instance.host).catch(() => []), + ]); + + return { + a: resolved4, + aaaa: resolved6, + cname: resolvedCname, + txt: resolvedTxt, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/federation/followers.ts b/packages/backend/src/server/api/endpoints/federation/followers.ts new file mode 100644 index 0000000000..655e7b7b9a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/followers.ts @@ -0,0 +1,51 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Followings } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['federation'], + + requireCredential: false as const, + + params: { + host: { + validator: $.str + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Following', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) + .andWhere(`following.followeeHost = :host`, { host: ps.host }); + + const followings = await query + .take(ps.limit!) + .getMany(); + + return await Followings.packMany(followings, me, { populateFollowee: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/following.ts b/packages/backend/src/server/api/endpoints/federation/following.ts new file mode 100644 index 0000000000..5b283581a6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/following.ts @@ -0,0 +1,51 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Followings } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['federation'], + + requireCredential: false as const, + + params: { + host: { + validator: $.str + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Following', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) + .andWhere(`following.followerHost = :host`, { host: ps.host }); + + const followings = await query + .take(ps.limit!) + .getMany(); + + return await Followings.packMany(followings, me, { populateFollowee: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts new file mode 100644 index 0000000000..cf5e44ebd5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -0,0 +1,149 @@ +import $ from 'cafy'; +import config from '@/config/index'; +import define from '../../define'; +import { Instances } from '@/models/index'; +import { fetchMeta } from '@/misc/fetch-meta'; + +export const meta = { + tags: ['federation'], + + requireCredential: false as const, + + params: { + host: { + validator: $.optional.nullable.str, + }, + + blocked: { + validator: $.optional.nullable.bool, + }, + + notResponding: { + validator: $.optional.nullable.bool, + }, + + suspended: { + validator: $.optional.nullable.bool, + }, + + federating: { + validator: $.optional.nullable.bool, + }, + + subscribing: { + validator: $.optional.nullable.bool, + }, + + publishing: { + validator: $.optional.nullable.bool, + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 30 + }, + + offset: { + validator: $.optional.num.min(0), + default: 0 + }, + + sort: { + validator: $.optional.str, + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'FederationInstance' + } + } +}; + +export default define(meta, async (ps, me) => { + const query = Instances.createQueryBuilder('instance'); + + switch (ps.sort) { + case '+pubSub': query.orderBy('instance.followingCount', 'DESC').orderBy('instance.followersCount', 'DESC'); break; + case '-pubSub': query.orderBy('instance.followingCount', 'ASC').orderBy('instance.followersCount', 'ASC'); break; + case '+notes': query.orderBy('instance.notesCount', 'DESC'); break; + case '-notes': query.orderBy('instance.notesCount', 'ASC'); break; + case '+users': query.orderBy('instance.usersCount', 'DESC'); break; + case '-users': query.orderBy('instance.usersCount', 'ASC'); break; + case '+following': query.orderBy('instance.followingCount', 'DESC'); break; + case '-following': query.orderBy('instance.followingCount', 'ASC'); break; + case '+followers': query.orderBy('instance.followersCount', 'DESC'); break; + case '-followers': query.orderBy('instance.followersCount', 'ASC'); break; + case '+caughtAt': query.orderBy('instance.caughtAt', 'DESC'); break; + case '-caughtAt': query.orderBy('instance.caughtAt', 'ASC'); break; + case '+lastCommunicatedAt': query.orderBy('instance.lastCommunicatedAt', 'DESC'); break; + case '-lastCommunicatedAt': query.orderBy('instance.lastCommunicatedAt', 'ASC'); break; + case '+driveUsage': query.orderBy('instance.driveUsage', 'DESC'); break; + case '-driveUsage': query.orderBy('instance.driveUsage', 'ASC'); break; + case '+driveFiles': query.orderBy('instance.driveFiles', 'DESC'); break; + case '-driveFiles': query.orderBy('instance.driveFiles', 'ASC'); break; + + default: query.orderBy('instance.id', 'DESC'); break; + } + + if (typeof ps.blocked === 'boolean') { + const meta = await fetchMeta(true); + if (ps.blocked) { + query.andWhere('instance.host IN (:...blocks)', { blocks: meta.blockedHosts }); + } else { + query.andWhere('instance.host NOT IN (:...blocks)', { blocks: meta.blockedHosts }); + } + } + + if (typeof ps.notResponding === 'boolean') { + if (ps.notResponding) { + query.andWhere('instance.isNotResponding = TRUE'); + } else { + query.andWhere('instance.isNotResponding = FALSE'); + } + } + + if (typeof ps.suspended === 'boolean') { + if (ps.suspended) { + query.andWhere('instance.isSuspended = TRUE'); + } else { + query.andWhere('instance.isSuspended = FALSE'); + } + } + + if (typeof ps.federating === 'boolean') { + if (ps.federating) { + query.andWhere('((instance.followingCount > 0) OR (instance.followersCount > 0))'); + } else { + query.andWhere('((instance.followingCount = 0) AND (instance.followersCount = 0))'); + } + } + + if (typeof ps.subscribing === 'boolean') { + if (ps.subscribing) { + query.andWhere('instance.followersCount > 0'); + } else { + query.andWhere('instance.followersCount = 0'); + } + } + + if (typeof ps.publishing === 'boolean') { + if (ps.publishing) { + query.andWhere('instance.followingCount > 0'); + } else { + query.andWhere('instance.followingCount = 0'); + } + } + + if (ps.host) { + query.andWhere('instance.host like :host', { host: '%' + ps.host.toLowerCase() + '%' }); + } + + const instances = await query.take(ps.limit!).skip(ps.offset).getMany(); + + return instances; +}); diff --git a/packages/backend/src/server/api/endpoints/federation/show-instance.ts b/packages/backend/src/server/api/endpoints/federation/show-instance.ts new file mode 100644 index 0000000000..f8352aefb3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/show-instance.ts @@ -0,0 +1,29 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Instances } from '@/models/index'; +import { toPuny } from '@/misc/convert-host'; + +export const meta = { + tags: ['federation'], + + requireCredential: false as const, + + params: { + host: { + validator: $.str + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'FederationInstance' + } +}; + +export default define(meta, async (ps, me) => { + const instance = await Instances + .findOne({ host: toPuny(ps.host) }); + + return instance; +}); diff --git a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts new file mode 100644 index 0000000000..580c3cb3d9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts @@ -0,0 +1,22 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { getRemoteUser } from '../../common/getters'; +import { updatePerson } from '@/remote/activitypub/models/person'; + +export const meta = { + tags: ['federation'], + + requireCredential: true as const, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps) => { + const user = await getRemoteUser(ps.userId); + await updatePerson(user.uri!); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/users.ts b/packages/backend/src/server/api/endpoints/federation/users.ts new file mode 100644 index 0000000000..0e35df3e1c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/users.ts @@ -0,0 +1,51 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Users } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['federation'], + + requireCredential: false as const, + + params: { + host: { + validator: $.str + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(Users.createQueryBuilder('user'), ps.sinceId, ps.untilId) + .andWhere(`user.host = :host`, { host: ps.host }); + + const users = await query + .take(ps.limit!) + .getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/following/create.ts b/packages/backend/src/server/api/endpoints/following/create.ts new file mode 100644 index 0000000000..ba9ca1092d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/create.ts @@ -0,0 +1,100 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import * as ms from 'ms'; +import create from '@/services/following/create'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { Followings, Users } from '@/models/index'; + +export const meta = { + tags: ['following', 'users'], + + limit: { + duration: ms('1hour'), + max: 100 + }, + + requireCredential: true as const, + + kind: 'write:following', + + params: { + userId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5' + }, + + followeeIsYourself: { + message: 'Followee is yourself.', + code: 'FOLLOWEE_IS_YOURSELF', + id: '26fbe7bb-a331-4857-af17-205b426669a9' + }, + + alreadyFollowing: { + message: 'You are already following that user.', + code: 'ALREADY_FOLLOWING', + id: '35387507-38c7-4cb9-9197-300b93783fa0' + }, + + blocking: { + message: 'You are blocking that user.', + code: 'BLOCKING', + id: '4e2206ec-aa4f-4960-b865-6c23ac38e2d9' + }, + + blocked: { + message: 'You are blocked by that user.', + code: 'BLOCKED', + id: 'c4ab57cc-4e41-45e9-bfd9-584f61e35ce0' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } +}; + +export default define(meta, async (ps, user) => { + const follower = user; + + // 自分自身 + if (user.id === ps.userId) { + throw new ApiError(meta.errors.followeeIsYourself); + } + + // Get followee + const followee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check if already following + const exist = await Followings.findOne({ + followerId: follower.id, + followeeId: followee.id + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyFollowing); + } + + try { + await create(follower, followee); + } catch (e) { + if (e.id === '710e8fb0-b8c3-4922-be49-d5d93d8e6a6e') throw new ApiError(meta.errors.blocking); + if (e.id === '3338392a-f764-498d-8855-db939dcf8c48') throw new ApiError(meta.errors.blocked); + throw e; + } + + return await Users.pack(followee.id, user); +}); diff --git a/packages/backend/src/server/api/endpoints/following/delete.ts b/packages/backend/src/server/api/endpoints/following/delete.ts new file mode 100644 index 0000000000..0b0158b86e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/delete.ts @@ -0,0 +1,82 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import * as ms from 'ms'; +import deleteFollowing from '@/services/following/delete'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { Followings, Users } from '@/models/index'; + +export const meta = { + tags: ['following', 'users'], + + limit: { + duration: ms('1hour'), + max: 100 + }, + + requireCredential: true as const, + + kind: 'write:following', + + params: { + userId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '5b12c78d-2b28-4dca-99d2-f56139b42ff8' + }, + + followeeIsYourself: { + message: 'Followee is yourself.', + code: 'FOLLOWEE_IS_YOURSELF', + id: 'd9e400b9-36b0-4808-b1d8-79e707f1296c' + }, + + notFollowing: { + message: 'You are not following that user.', + code: 'NOT_FOLLOWING', + id: '5dbf82f5-c92b-40b1-87d1-6c8c0741fd09' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } +}; + +export default define(meta, async (ps, user) => { + const follower = user; + + // Check if the followee is yourself + if (user.id === ps.userId) { + throw new ApiError(meta.errors.followeeIsYourself); + } + + // Get followee + const followee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check not following + const exist = await Followings.findOne({ + followerId: follower.id, + followeeId: followee.id + }); + + if (exist == null) { + throw new ApiError(meta.errors.notFollowing); + } + + await deleteFollowing(follower, followee); + + return await Users.pack(followee.id, user); +}); diff --git a/packages/backend/src/server/api/endpoints/following/requests/accept.ts b/packages/backend/src/server/api/endpoints/following/requests/accept.ts new file mode 100644 index 0000000000..af39ea1d90 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/requests/accept.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import acceptFollowRequest from '@/services/following/requests/accept'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; + +export const meta = { + tags: ['following', 'account'], + + requireCredential: true as const, + + kind: 'write:following', + + params: { + userId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '66ce1645-d66c-46bb-8b79-96739af885bd' + }, + noFollowRequest: { + message: 'No follow request.', + code: 'NO_FOLLOW_REQUEST', + id: 'bcde4f8b-0913-4614-8881-614e522fb041' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Fetch follower + const follower = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + await acceptFollowRequest(user, follower).catch(e => { + if (e.id === '8884c2dd-5795-4ac9-b27e-6a01d38190f9') throw new ApiError(meta.errors.noFollowRequest); + throw e; + }); + + return; +}); diff --git a/packages/backend/src/server/api/endpoints/following/requests/cancel.ts b/packages/backend/src/server/api/endpoints/following/requests/cancel.ts new file mode 100644 index 0000000000..b69c9d2fe1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/requests/cancel.ts @@ -0,0 +1,58 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import cancelFollowRequest from '@/services/following/requests/cancel'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['following', 'account'], + + requireCredential: true as const, + + kind: 'write:following', + + params: { + userId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '4e68c551-fc4c-4e46-bb41-7d4a37bf9dab' + }, + + followRequestNotFound: { + message: 'Follow request not found.', + code: 'FOLLOW_REQUEST_NOT_FOUND', + id: '089b125b-d338-482a-9a09-e2622ac9f8d4' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } +}; + +export default define(meta, async (ps, user) => { + // Fetch followee + const followee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + try { + await cancelFollowRequest(followee, user); + } catch (e) { + if (e.id === '17447091-ce07-46dd-b331-c1fd4f15b1e7') throw new ApiError(meta.errors.followRequestNotFound); + throw e; + } + + return await Users.pack(followee.id, user); +}); diff --git a/packages/backend/src/server/api/endpoints/following/requests/list.ts b/packages/backend/src/server/api/endpoints/following/requests/list.ts new file mode 100644 index 0000000000..84440ccac7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/requests/list.ts @@ -0,0 +1,44 @@ +import define from '../../../define'; +import { FollowRequests } from '@/models/index'; + +export const meta = { + tags: ['following', 'account'], + + requireCredential: true as const, + + kind: 'read:following', + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + follower: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + }, + followee: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } + } + } + } +}; + +export default define(meta, async (ps, user) => { + const reqs = await FollowRequests.find({ + followeeId: user.id + }); + + return await Promise.all(reqs.map(req => FollowRequests.pack(req))); +}); diff --git a/packages/backend/src/server/api/endpoints/following/requests/reject.ts b/packages/backend/src/server/api/endpoints/following/requests/reject.ts new file mode 100644 index 0000000000..620324361f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/requests/reject.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import rejectFollowRequest from '@/services/following/requests/reject'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; + +export const meta = { + tags: ['following', 'account'], + + requireCredential: true as const, + + kind: 'write:following', + + params: { + userId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'abc2ffa6-25b2-4380-ba99-321ff3a94555' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Fetch follower + const follower = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + await rejectFollowRequest(user, follower); + + return; +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/featured.ts b/packages/backend/src/server/api/endpoints/gallery/featured.ts new file mode 100644 index 0000000000..30ef8cedec --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/featured.ts @@ -0,0 +1,29 @@ +import define from '../../define'; +import { GalleryPosts } from '@/models/index'; + +export const meta = { + tags: ['gallery'], + + requireCredential: false as const, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = GalleryPosts.createQueryBuilder('post') + .andWhere('post.createdAt > :date', { date: new Date(Date.now() - (1000 * 60 * 60 * 24 * 3)) }) + .andWhere('post.likedCount > 0') + .orderBy('post.likedCount', 'DESC'); + + const posts = await query.take(10).getMany(); + + return await GalleryPosts.packMany(posts, me); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/popular.ts b/packages/backend/src/server/api/endpoints/gallery/popular.ts new file mode 100644 index 0000000000..18449b9654 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/popular.ts @@ -0,0 +1,28 @@ +import define from '../../define'; +import { GalleryPosts } from '@/models/index'; + +export const meta = { + tags: ['gallery'], + + requireCredential: false as const, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = GalleryPosts.createQueryBuilder('post') + .andWhere('post.likedCount > 0') + .orderBy('post.likedCount', 'DESC'); + + const posts = await query.take(10).getMany(); + + return await GalleryPosts.packMany(posts, me); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts.ts b/packages/backend/src/server/api/endpoints/gallery/posts.ts new file mode 100644 index 0000000000..53d3236d2d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { GalleryPosts } from '@/models/index'; + +export const meta = { + tags: ['gallery'], + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId) + .innerJoinAndSelect('post.user', 'user'); + + const posts = await query.take(ps.limit!).getMany(); + + return await GalleryPosts.packMany(posts, me); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts new file mode 100644 index 0000000000..38b487e6ea --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts @@ -0,0 +1,77 @@ +import $ from 'cafy'; +import * as ms from 'ms'; +import define from '../../../define'; +import { ID } from '../../../../../misc/cafy-id'; +import { DriveFiles, GalleryPosts } from '@/models/index'; +import { genId } from '../../../../../misc/gen-id'; +import { GalleryPost } from '@/models/entities/gallery-post'; +import { ApiError } from '../../../error'; +import { DriveFile } from '@/models/entities/drive-file'; + +export const meta = { + tags: ['gallery'], + + requireCredential: true as const, + + kind: 'write:gallery', + + limit: { + duration: ms('1hour'), + max: 300 + }, + + params: { + title: { + validator: $.str.min(1), + }, + + description: { + validator: $.optional.nullable.str, + }, + + fileIds: { + validator: $.arr($.type(ID)).unique().range(1, 32), + }, + + isSensitive: { + validator: $.optional.bool, + default: false, + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost', + }, + + errors: { + + } +}; + +export default define(meta, async (ps, user) => { + const files = (await Promise.all(ps.fileIds.map(fileId => + DriveFiles.findOne({ + id: fileId, + userId: user.id + }) + ))).filter((file): file is DriveFile => file != null); + + if (files.length === 0) { + throw new Error(); + } + + const post = await GalleryPosts.insert(new GalleryPost({ + id: genId(), + createdAt: new Date(), + updatedAt: new Date(), + title: ps.title, + description: ps.description, + userId: user.id, + isSensitive: ps.isSensitive, + fileIds: files.map(file => file.id) + })).then(x => GalleryPosts.findOneOrFail(x.identifiers[0])); + + return await GalleryPosts.pack(post, user); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts new file mode 100644 index 0000000000..e5b7c07f2f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { GalleryPosts } from '@/models/index'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['gallery'], + + requireCredential: true as const, + + kind: 'write:gallery', + + params: { + postId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchPost: { + message: 'No such post.', + code: 'NO_SUCH_POST', + id: 'ae52f367-4bd7-4ecd-afc6-5672fff427f5' + }, + } +}; + +export default define(meta, async (ps, user) => { + const post = await GalleryPosts.findOne({ + id: ps.postId, + userId: user.id, + }); + + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + await GalleryPosts.delete(post.id); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts new file mode 100644 index 0000000000..81a25c0ad1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts @@ -0,0 +1,71 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { GalleryPosts, GalleryLikes } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['gallery'], + + requireCredential: true as const, + + kind: 'write:gallery-likes', + + params: { + postId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchPost: { + message: 'No such post.', + code: 'NO_SUCH_POST', + id: '56c06af3-1287-442f-9701-c93f7c4a62ff' + }, + + yourPost: { + message: 'You cannot like your post.', + code: 'YOUR_POST', + id: 'f78f1511-5ebc-4478-a888-1198d752da68' + }, + + alreadyLiked: { + message: 'The post has already been liked.', + code: 'ALREADY_LIKED', + id: '40e9ed56-a59c-473a-bf3f-f289c54fb5a7' + }, + } +}; + +export default define(meta, async (ps, user) => { + const post = await GalleryPosts.findOne(ps.postId); + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + if (post.userId === user.id) { + throw new ApiError(meta.errors.yourPost); + } + + // if already liked + const exist = await GalleryLikes.findOne({ + postId: post.id, + userId: user.id + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyLiked); + } + + // Create like + await GalleryLikes.insert({ + id: genId(), + createdAt: new Date(), + postId: post.id, + userId: user.id + }); + + GalleryPosts.increment({ id: post.id }, 'likedCount', 1); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/show.ts b/packages/backend/src/server/api/endpoints/gallery/posts/show.ts new file mode 100644 index 0000000000..93852a5f8d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts/show.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { GalleryPosts } from '@/models/index'; + +export const meta = { + tags: ['gallery'], + + requireCredential: false as const, + + params: { + postId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchPost: { + message: 'No such post.', + code: 'NO_SUCH_POST', + id: '1137bf14-c5b0-4604-85bb-5b5371b1cd45' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost' + } +}; + +export default define(meta, async (ps, me) => { + const post = await GalleryPosts.findOne({ + id: ps.postId, + }); + + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + return await GalleryPosts.pack(post, me); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts new file mode 100644 index 0000000000..0347cdf79e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { GalleryPosts, GalleryLikes } from '@/models/index'; + +export const meta = { + tags: ['gallery'], + + requireCredential: true as const, + + kind: 'write:gallery-likes', + + params: { + postId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchPost: { + message: 'No such post.', + code: 'NO_SUCH_POST', + id: 'c32e6dd0-b555-4413-925e-b3757d19ed84' + }, + + notLiked: { + message: 'You have not liked that post.', + code: 'NOT_LIKED', + id: 'e3e8e06e-be37-41f7-a5b4-87a8250288f0' + }, + } +}; + +export default define(meta, async (ps, user) => { + const post = await GalleryPosts.findOne(ps.postId); + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + const exist = await GalleryLikes.findOne({ + postId: post.id, + userId: user.id + }); + + if (exist == null) { + throw new ApiError(meta.errors.notLiked); + } + + // Delete like + await GalleryLikes.delete(exist.id); + + GalleryPosts.decrement({ id: post.id }, 'likedCount', 1); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts new file mode 100644 index 0000000000..54eea130d3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts @@ -0,0 +1,82 @@ +import $ from 'cafy'; +import * as ms from 'ms'; +import define from '../../../define'; +import { ID } from '../../../../../misc/cafy-id'; +import { DriveFiles, GalleryPosts } from '@/models/index'; +import { GalleryPost } from '@/models/entities/gallery-post'; +import { ApiError } from '../../../error'; +import { DriveFile } from '@/models/entities/drive-file'; + +export const meta = { + tags: ['gallery'], + + requireCredential: true as const, + + kind: 'write:gallery', + + limit: { + duration: ms('1hour'), + max: 300 + }, + + params: { + postId: { + validator: $.type(ID), + }, + + title: { + validator: $.str.min(1), + }, + + description: { + validator: $.optional.nullable.str, + }, + + fileIds: { + validator: $.arr($.type(ID)).unique().range(1, 32), + }, + + isSensitive: { + validator: $.optional.bool, + default: false, + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost', + }, + + errors: { + + } +}; + +export default define(meta, async (ps, user) => { + const files = (await Promise.all(ps.fileIds.map(fileId => + DriveFiles.findOne({ + id: fileId, + userId: user.id + }) + ))).filter((file): file is DriveFile => file != null); + + if (files.length === 0) { + throw new Error(); + } + + await GalleryPosts.update({ + id: ps.postId, + userId: user.id, + }, { + updatedAt: new Date(), + title: ps.title, + description: ps.description, + isSensitive: ps.isSensitive, + fileIds: files.map(file => file.id) + }); + + const post = await GalleryPosts.findOneOrFail(ps.postId); + + return await GalleryPosts.pack(post, user); +}); diff --git a/packages/backend/src/server/api/endpoints/games/reversi/games.ts b/packages/backend/src/server/api/endpoints/games/reversi/games.ts new file mode 100644 index 0000000000..4db9ecb69f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/games/reversi/games.ts @@ -0,0 +1,156 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ReversiGames } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; +import { Brackets } from 'typeorm'; + +export const meta = { + tags: ['games'], + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + my: { + validator: $.optional.bool, + default: false + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time' + }, + startedAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time' + }, + isStarted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isEnded: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + form1: { + type: 'any' as const, + optional: false as const, nullable: true as const + }, + form2: { + type: 'any' as const, + optional: false as const, nullable: true as const + }, + user1Accepted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + default: false + }, + user2Accepted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + default: false + }, + user1Id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + user2Id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + user1: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + }, + user2: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + }, + winnerId: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id' + }, + winner: { + type: 'object' as const, + optional: false as const, nullable: true as const, + ref: 'User' + }, + surrendered: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id' + }, + black: { + type: 'number' as const, + optional: false as const, nullable: true as const + }, + bw: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + isLlotheo: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + canPutEverywhere: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + loopedBoard: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + } + } + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(ReversiGames.createQueryBuilder('game'), ps.sinceId, ps.untilId) + .andWhere('game.isStarted = TRUE'); + + if (ps.my && user) { + query.andWhere(new Brackets(qb => { qb + .where('game.user1Id = :userId', { userId: user.id }) + .orWhere('game.user2Id = :userId', { userId: user.id }); + })); + } + + // Fetch games + const games = await query.take(ps.limit!).getMany(); + + return await Promise.all(games.map((g) => ReversiGames.pack(g, user, { + detail: false + }))); +}); diff --git a/packages/backend/src/server/api/endpoints/games/reversi/games/show.ts b/packages/backend/src/server/api/endpoints/games/reversi/games/show.ts new file mode 100644 index 0000000000..93afffdb1f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/games/reversi/games/show.ts @@ -0,0 +1,168 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import Reversi from '../../../../../../games/reversi/core'; +import define from '../../../../define'; +import { ApiError } from '../../../../error'; +import { ReversiGames } from '@/models/index'; + +export const meta = { + tags: ['games'], + + params: { + gameId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchGame: { + message: 'No such game.', + code: 'NO_SUCH_GAME', + id: 'f13a03db-fae1-46c9-87f3-43c8165419e1' + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time' + }, + startedAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time' + }, + isStarted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isEnded: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + form1: { + type: 'any' as const, + optional: false as const, nullable: true as const + }, + form2: { + type: 'any' as const, + optional: false as const, nullable: true as const + }, + user1Accepted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + default: false + }, + user2Accepted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + default: false + }, + user1Id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + user2Id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + user1: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + }, + user2: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + }, + winnerId: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id' + }, + winner: { + type: 'object' as const, + optional: false as const, nullable: true as const, + ref: 'User' + }, + surrendered: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id' + }, + black: { + type: 'number' as const, + optional: false as const, nullable: true as const + }, + bw: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + isLlotheo: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + canPutEverywhere: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + loopedBoard: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + board: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'any' as const, + optional: false as const, nullable: false as const + } + }, + turn: { + type: 'any' as const, + optional: false as const, nullable: false as const + } + } + } + } +}; + +export default define(meta, async (ps, user) => { + const game = await ReversiGames.findOne(ps.gameId); + + if (game == null) { + throw new ApiError(meta.errors.noSuchGame); + } + + const o = new Reversi(game.map, { + isLlotheo: game.isLlotheo, + canPutEverywhere: game.canPutEverywhere, + loopedBoard: game.loopedBoard + }); + + for (const log of game.logs) { + o.put(log.color, log.pos); + } + + const packed = await ReversiGames.pack(game, user); + + return Object.assign({ + board: o.board, + turn: o.turn + }, packed); +}); diff --git a/packages/backend/src/server/api/endpoints/games/reversi/games/surrender.ts b/packages/backend/src/server/api/endpoints/games/reversi/games/surrender.ts new file mode 100644 index 0000000000..00d58b19e3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/games/reversi/games/surrender.ts @@ -0,0 +1,67 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { publishReversiGameStream } from '@/services/stream'; +import define from '../../../../define'; +import { ApiError } from '../../../../error'; +import { ReversiGames } from '@/models/index'; + +export const meta = { + tags: ['games'], + + requireCredential: true as const, + + params: { + gameId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchGame: { + message: 'No such game.', + code: 'NO_SUCH_GAME', + id: 'ace0b11f-e0a6-4076-a30d-e8284c81b2df' + }, + + alreadyEnded: { + message: 'That game has already ended.', + code: 'ALREADY_ENDED', + id: '6c2ad4a6-cbf1-4a5b-b187-b772826cfc6d' + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '6e04164b-a992-4c93-8489-2123069973e1' + }, + } +}; + +export default define(meta, async (ps, user) => { + const game = await ReversiGames.findOne(ps.gameId); + + if (game == null) { + throw new ApiError(meta.errors.noSuchGame); + } + + if (game.isEnded) { + throw new ApiError(meta.errors.alreadyEnded); + } + + if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) { + throw new ApiError(meta.errors.accessDenied); + } + + const winnerId = game.user1Id === user.id ? game.user2Id : game.user1Id; + + await ReversiGames.update(game.id, { + surrendered: user.id, + isEnded: true, + winnerId: winnerId + }); + + publishReversiGameStream(game.id, 'ended', { + winnerId: winnerId, + game: await ReversiGames.pack(game.id, user) + }); +}); diff --git a/packages/backend/src/server/api/endpoints/games/reversi/invitations.ts b/packages/backend/src/server/api/endpoints/games/reversi/invitations.ts new file mode 100644 index 0000000000..c8629377b2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/games/reversi/invitations.ts @@ -0,0 +1,58 @@ +import define from '../../../define'; +import { ReversiMatchings } from '@/models/index'; + +export const meta = { + tags: ['games'], + + requireCredential: true as const, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time' + }, + parentId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + parent: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + }, + childId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + child: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } + } + } + } +}; + +export default define(meta, async (ps, user) => { + // Find session + const invitations = await ReversiMatchings.find({ + childId: user.id + }); + + return await Promise.all(invitations.map((i) => ReversiMatchings.pack(i, user))); +}); diff --git a/packages/backend/src/server/api/endpoints/games/reversi/match.ts b/packages/backend/src/server/api/endpoints/games/reversi/match.ts new file mode 100644 index 0000000000..5ceb16c7d7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/games/reversi/match.ts @@ -0,0 +1,108 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { publishMainStream, publishReversiStream } from '@/services/stream'; +import { eighteight } from '../../../../../games/reversi/maps'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { genId } from '@/misc/gen-id'; +import { ReversiMatchings, ReversiGames } from '@/models/index'; +import { ReversiGame } from '@/models/entities/games/reversi/game'; +import { ReversiMatching } from '@/models/entities/games/reversi/matching'; + +export const meta = { + tags: ['games'], + + requireCredential: true as const, + + params: { + userId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '0b4f0559-b484-4e31-9581-3f73cee89b28' + }, + + isYourself: { + message: 'Target user is yourself.', + code: 'TARGET_IS_YOURSELF', + id: '96fd7bd6-d2bc-426c-a865-d055dcd2828e' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Myself + if (ps.userId === user.id) { + throw new ApiError(meta.errors.isYourself); + } + + // Find session + const exist = await ReversiMatchings.findOne({ + parentId: ps.userId, + childId: user.id + }); + + if (exist) { + // Destroy session + ReversiMatchings.delete(exist.id); + + // Create game + const game = await ReversiGames.save({ + id: genId(), + createdAt: new Date(), + user1Id: exist.parentId, + user2Id: user.id, + user1Accepted: false, + user2Accepted: false, + isStarted: false, + isEnded: false, + logs: [], + map: eighteight.data, + bw: 'random', + isLlotheo: false + } as Partial<ReversiGame>); + + publishReversiStream(exist.parentId, 'matched', await ReversiGames.pack(game, { id: exist.parentId })); + + const other = await ReversiMatchings.count({ + childId: user.id + }); + + if (other == 0) { + publishMainStream(user.id, 'reversiNoInvites'); + } + + return await ReversiGames.pack(game, user); + } else { + // Fetch child + const child = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // 以前のセッションはすべて削除しておく + await ReversiMatchings.delete({ + parentId: user.id + }); + + // セッションを作成 + const matching = await ReversiMatchings.save({ + id: genId(), + createdAt: new Date(), + parentId: user.id, + childId: child.id + } as ReversiMatching); + + const packed = await ReversiMatchings.pack(matching, child); + publishReversiStream(child.id, 'invited', packed); + publishMainStream(child.id, 'reversiInvited', packed); + + return; + } +}); diff --git a/packages/backend/src/server/api/endpoints/games/reversi/match/cancel.ts b/packages/backend/src/server/api/endpoints/games/reversi/match/cancel.ts new file mode 100644 index 0000000000..e4a138bb87 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/games/reversi/match/cancel.ts @@ -0,0 +1,14 @@ +import define from '../../../../define'; +import { ReversiMatchings } from '@/models/index'; + +export const meta = { + tags: ['games'], + + requireCredential: true as const +}; + +export default define(meta, async (ps, user) => { + await ReversiMatchings.delete({ + parentId: user.id + }); +}); diff --git a/packages/backend/src/server/api/endpoints/get-online-users-count.ts b/packages/backend/src/server/api/endpoints/get-online-users-count.ts new file mode 100644 index 0000000000..5c80d588d3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/get-online-users-count.ts @@ -0,0 +1,23 @@ +import { USER_ONLINE_THRESHOLD } from '@/const'; +import { Users } from '@/models/index'; +import { MoreThan } from 'typeorm'; +import define from '../define'; + +export const meta = { + tags: ['meta'], + + requireCredential: false as const, + + params: { + } +}; + +export default define(meta, async () => { + const count = await Users.count({ + lastActiveDate: MoreThan(new Date(Date.now() - USER_ONLINE_THRESHOLD)) + }); + + return { + count + }; +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/list.ts b/packages/backend/src/server/api/endpoints/hashtags/list.ts new file mode 100644 index 0000000000..821016a50c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/hashtags/list.ts @@ -0,0 +1,95 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Hashtags } from '@/models/index'; + +export const meta = { + tags: ['hashtags'], + + requireCredential: false as const, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + attachedToUserOnly: { + validator: $.optional.bool, + default: false + }, + + attachedToLocalUserOnly: { + validator: $.optional.bool, + default: false + }, + + attachedToRemoteUserOnly: { + validator: $.optional.bool, + default: false + }, + + sort: { + validator: $.str.or([ + '+mentionedUsers', + '-mentionedUsers', + '+mentionedLocalUsers', + '-mentionedLocalUsers', + '+mentionedRemoteUsers', + '-mentionedRemoteUsers', + '+attachedUsers', + '-attachedUsers', + '+attachedLocalUsers', + '-attachedLocalUsers', + '+attachedRemoteUsers', + '-attachedRemoteUsers', + ]), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Hashtag', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = Hashtags.createQueryBuilder('tag'); + + if (ps.attachedToUserOnly) query.andWhere('tag.attachedUsersCount != 0'); + if (ps.attachedToLocalUserOnly) query.andWhere('tag.attachedLocalUsersCount != 0'); + if (ps.attachedToRemoteUserOnly) query.andWhere('tag.attachedRemoteUsersCount != 0'); + + switch (ps.sort) { + case '+mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'DESC'); break; + case '-mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'ASC'); break; + case '+mentionedLocalUsers': query.orderBy('tag.mentionedLocalUsersCount', 'DESC'); break; + case '-mentionedLocalUsers': query.orderBy('tag.mentionedLocalUsersCount', 'ASC'); break; + case '+mentionedRemoteUsers': query.orderBy('tag.mentionedRemoteUsersCount', 'DESC'); break; + case '-mentionedRemoteUsers': query.orderBy('tag.mentionedRemoteUsersCount', 'ASC'); break; + case '+attachedUsers': query.orderBy('tag.attachedUsersCount', 'DESC'); break; + case '-attachedUsers': query.orderBy('tag.attachedUsersCount', 'ASC'); break; + case '+attachedLocalUsers': query.orderBy('tag.attachedLocalUsersCount', 'DESC'); break; + case '-attachedLocalUsers': query.orderBy('tag.attachedLocalUsersCount', 'ASC'); break; + case '+attachedRemoteUsers': query.orderBy('tag.attachedRemoteUsersCount', 'DESC'); break; + case '-attachedRemoteUsers': query.orderBy('tag.attachedRemoteUsersCount', 'ASC'); break; + } + + query.select([ + 'tag.name', + 'tag.mentionedUsersCount', + 'tag.mentionedLocalUsersCount', + 'tag.mentionedRemoteUsersCount', + 'tag.attachedUsersCount', + 'tag.attachedLocalUsersCount', + 'tag.attachedRemoteUsersCount', + ]); + + const tags = await query.take(ps.limit!).getMany(); + + return Hashtags.packMany(tags); +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/search.ts b/packages/backend/src/server/api/endpoints/hashtags/search.ts new file mode 100644 index 0000000000..fd0cac3983 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/hashtags/search.ts @@ -0,0 +1,46 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Hashtags } from '@/models/index'; + +export const meta = { + tags: ['hashtags'], + + requireCredential: false as const, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + query: { + validator: $.str, + }, + + offset: { + validator: $.optional.num.min(0), + default: 0, + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const, + } + }, +}; + +export default define(meta, async (ps) => { + const hashtags = await Hashtags.createQueryBuilder('tag') + .where('tag.name like :q', { q: ps.query.toLowerCase() + '%' }) + .orderBy('tag.count', 'DESC') + .groupBy('tag.id') + .take(ps.limit!) + .skip(ps.offset) + .getMany(); + + return hashtags.map(tag => tag.name); +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/show.ts b/packages/backend/src/server/api/endpoints/hashtags/show.ts new file mode 100644 index 0000000000..f22edbfffd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/hashtags/show.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Hashtags } from '@/models/index'; +import { normalizeForSearch } from '@/misc/normalize-for-search'; + +export const meta = { + tags: ['hashtags'], + + requireCredential: false as const, + + params: { + tag: { + validator: $.str, + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Hashtag', + }, + + errors: { + noSuchHashtag: { + message: 'No such hashtag.', + code: 'NO_SUCH_HASHTAG', + id: '110ee688-193e-4a3a-9ecf-c167b2e6981e' + } + } +}; + +export default define(meta, async (ps, user) => { + const hashtag = await Hashtags.findOne({ name: normalizeForSearch(ps.tag) }); + if (hashtag == null) { + throw new ApiError(meta.errors.noSuchHashtag); + } + + return await Hashtags.pack(hashtag); +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/trend.ts b/packages/backend/src/server/api/endpoints/hashtags/trend.ts new file mode 100644 index 0000000000..3d67241ab6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/hashtags/trend.ts @@ -0,0 +1,146 @@ +import { Brackets } from 'typeorm'; +import define from '../../define'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Notes } from '@/models/index'; +import { Note } from '@/models/entities/note'; +import { safeForSql } from '@/misc/safe-for-sql'; +import { normalizeForSearch } from '@/misc/normalize-for-search'; + +/* +トレンドに載るためには「『直近a分間のユニーク投稿数が今からa分前~今からb分前の間のユニーク投稿数のn倍以上』のハッシュタグの上位5位以内に入る」ことが必要 +ユニーク投稿数とはそのハッシュタグと投稿ユーザーのペアのカウントで、例えば同じユーザーが複数回同じハッシュタグを投稿してもそのハッシュタグのユニーク投稿数は1とカウントされる + +..が理想だけどPostgreSQLでどうするのか分からないので単に「直近Aの内に投稿されたユニーク投稿数が多いハッシュタグ」で妥協する +*/ + +const rangeA = 1000 * 60 * 60; // 60分 +//const rangeB = 1000 * 60 * 120; // 2時間 +//const coefficient = 1.25; // 「n倍」の部分 +//const requiredUsers = 3; // 最低何人がそのタグを投稿している必要があるか + +const max = 5; + +export const meta = { + tags: ['hashtags'], + + requireCredential: false as const, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + tag: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + chart: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'number' as const, + optional: false as const, nullable: false as const, + } + }, + usersCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + } + } + } + } +}; + +export default define(meta, async () => { + const instance = await fetchMeta(true); + const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t)); + + const now = new Date(); // 5分単位で丸めた現在日時 + now.setMinutes(Math.round(now.getMinutes() / 5) * 5, 0, 0); + + const tagNotes = await Notes.createQueryBuilder('note') + .where(`note.createdAt > :date`, { date: new Date(now.getTime() - rangeA) }) + .andWhere(new Brackets(qb => { qb + .where(`note.visibility = 'public'`) + .orWhere(`note.visibility = 'home'`); + })) + .andWhere(`note.tags != '{}'`) + .select(['note.tags', 'note.userId']) + .cache(60000) // 1 min + .getMany(); + + if (tagNotes.length === 0) { + return []; + } + + const tags: { + name: string; + users: Note['userId'][]; + }[] = []; + + for (const note of tagNotes) { + for (const tag of note.tags) { + if (hiddenTags.includes(tag)) continue; + + const x = tags.find(x => x.name === tag); + if (x) { + if (!x.users.includes(note.userId)) { + x.users.push(note.userId); + } + } else { + tags.push({ + name: tag, + users: [note.userId] + }); + } + } + } + + // タグを人気順に並べ替え + const hots = tags + .sort((a, b) => b.users.length - a.users.length) + .map(tag => tag.name) + .slice(0, max); + + //#region 2(または3)で話題と判定されたタグそれぞれについて過去の投稿数グラフを取得する + const countPromises: Promise<number[]>[] = []; + + const range = 20; + + // 10分 + const interval = 1000 * 60 * 10; + + for (let i = 0; i < range; i++) { + countPromises.push(Promise.all(hots.map(tag => Notes.createQueryBuilder('note') + .select('count(distinct note.userId)') + .where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`) + .andWhere('note.createdAt < :lt', { lt: new Date(now.getTime() - (interval * i)) }) + .andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - (interval * (i + 1))) }) + .cache(60000) // 1 min + .getRawOne() + .then(x => parseInt(x.count, 10)) + ))); + } + + const countsLog = await Promise.all(countPromises); + //#endregion + + const totalCounts = await Promise.all(hots.map(tag => Notes.createQueryBuilder('note') + .select('count(distinct note.userId)') + .where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`) + .andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - rangeA) }) + .cache(60000 * 60) // 60 min + .getRawOne() + .then(x => parseInt(x.count, 10)) + )); + + const stats = hots.map((tag, i) => ({ + tag, + chart: countsLog.map(counts => counts[i]), + usersCount: totalCounts[i] + })); + + return stats; +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/users.ts b/packages/backend/src/server/api/endpoints/hashtags/users.ts new file mode 100644 index 0000000000..8c8cd1510b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/hashtags/users.ts @@ -0,0 +1,89 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Users } from '@/models/index'; +import { normalizeForSearch } from '@/misc/normalize-for-search'; + +export const meta = { + requireCredential: false as const, + + tags: ['hashtags', 'users'], + + params: { + tag: { + validator: $.str, + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sort: { + validator: $.str.or([ + '+follower', + '-follower', + '+createdAt', + '-createdAt', + '+updatedAt', + '-updatedAt', + ]), + }, + + state: { + validator: $.optional.str.or([ + 'all', + 'alive' + ]), + default: 'all' + }, + + origin: { + validator: $.optional.str.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'local' + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = Users.createQueryBuilder('user') + .where(':tag = ANY(user.tags)', { tag: normalizeForSearch(ps.tag) }); + + const recent = new Date(Date.now() - (1000 * 60 * 60 * 24 * 5)); + + if (ps.state === 'alive') { + query.andWhere('user.updatedAt > :date', { date: recent }); + } + + if (ps.origin === 'local') { + query.andWhere('user.host IS NULL'); + } else if (ps.origin === 'remote') { + query.andWhere('user.host IS NOT NULL'); + } + + switch (ps.sort) { + case '+follower': query.orderBy('user.followersCount', 'DESC'); break; + case '-follower': query.orderBy('user.followersCount', 'ASC'); break; + case '+createdAt': query.orderBy('user.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('user.createdAt', 'ASC'); break; + case '+updatedAt': query.orderBy('user.updatedAt', 'DESC'); break; + case '-updatedAt': query.orderBy('user.updatedAt', 'ASC'); break; + } + + const users = await query.take(ps.limit!).getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts new file mode 100644 index 0000000000..0568a962d8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -0,0 +1,26 @@ +import define from '../define'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + params: {}, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + }, +}; + +export default define(meta, async (ps, user, token) => { + const isSecure = token == null; + + // ここで渡ってきている user はキャッシュされていて古い可能性もあるので id だけ渡す + return await Users.pack(user.id, user, { + detail: true, + includeSecrets: isSecure + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/done.ts b/packages/backend/src/server/api/endpoints/i/2fa/done.ts new file mode 100644 index 0000000000..2bd2128cce --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/done.ts @@ -0,0 +1,41 @@ +import $ from 'cafy'; +import * as speakeasy from 'speakeasy'; +import define from '../../../define'; +import { UserProfiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + token: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, user) => { + const token = ps.token.replace(/\s/g, ''); + + const profile = await UserProfiles.findOneOrFail(user.id); + + if (profile.twoFactorTempSecret == null) { + throw new Error('二段階認証の設定が開始されていません'); + } + + const verified = (speakeasy as any).totp.verify({ + secret: profile.twoFactorTempSecret, + encoding: 'base32', + token: token + }); + + if (!verified) { + throw new Error('not verified'); + } + + await UserProfiles.update(user.id, { + twoFactorSecret: profile.twoFactorTempSecret, + twoFactorEnabled: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts new file mode 100644 index 0000000000..b4d3af235a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -0,0 +1,150 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import { promisify } from 'util'; +import * as cbor from 'cbor'; +import define from '../../../define'; +import { + UserProfiles, + UserSecurityKeys, + AttestationChallenges, + Users +} from '@/models/index'; +import config from '@/config/index'; +import { procedures, hash } from '../../../2fa'; +import { publishMainStream } from '@/services/stream'; + +const cborDecodeFirst = promisify(cbor.decodeFirst) as any; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + clientDataJSON: { + validator: $.str + }, + attestationObject: { + validator: $.str + }, + password: { + validator: $.str + }, + challengeId: { + validator: $.str + }, + name: { + validator: $.str + } + } +}; + +const rpIdHashReal = hash(Buffer.from(config.hostname, 'utf-8')); + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + if (!profile.twoFactorEnabled) { + throw new Error('2fa not enabled'); + } + + const clientData = JSON.parse(ps.clientDataJSON); + + if (clientData.type != 'webauthn.create') { + throw new Error('not a creation attestation'); + } + if (clientData.origin != config.scheme + '://' + config.host) { + throw new Error('origin mismatch'); + } + + const clientDataJSONHash = hash(Buffer.from(ps.clientDataJSON, 'utf-8')); + + const attestation = await cborDecodeFirst(ps.attestationObject); + + const rpIdHash = attestation.authData.slice(0, 32); + if (!rpIdHashReal.equals(rpIdHash)) { + throw new Error('rpIdHash mismatch'); + } + + const flags = attestation.authData[32]; + + // tslint:disable-next-line:no-bitwise + if (!(flags & 1)) { + throw new Error('user not present'); + } + + const authData = Buffer.from(attestation.authData); + const credentialIdLength = authData.readUInt16BE(53); + const credentialId = authData.slice(55, 55 + credentialIdLength); + const publicKeyData = authData.slice(55 + credentialIdLength); + const publicKey: Map<number, any> = await cborDecodeFirst(publicKeyData); + if (publicKey.get(3) != -7) { + throw new Error('alg mismatch'); + } + + if (!(procedures as any)[attestation.fmt]) { + throw new Error('unsupported fmt'); + } + + const verificationData = (procedures as any)[attestation.fmt].verify({ + attStmt: attestation.attStmt, + authenticatorData: authData, + clientDataHash: clientDataJSONHash, + credentialId, + publicKey, + rpIdHash + }); + if (!verificationData.valid) throw new Error('signature invalid'); + + const attestationChallenge = await AttestationChallenges.findOne({ + userId: user.id, + id: ps.challengeId, + registrationChallenge: true, + challenge: hash(clientData.challenge).toString('hex') + }); + + if (!attestationChallenge) { + throw new Error('non-existent challenge'); + } + + await AttestationChallenges.delete({ + userId: user.id, + id: ps.challengeId + }); + + // Expired challenge (> 5min old) + if ( + new Date().getTime() - attestationChallenge.createdAt.getTime() >= + 5 * 60 * 1000 + ) { + throw new Error('expired challenge'); + } + + const credentialIdString = credentialId.toString('hex'); + + await UserSecurityKeys.save({ + userId: user.id, + id: credentialIdString, + lastUsed: new Date(), + name: ps.name, + publicKey: verificationData.publicKey.toString('hex') + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, { + detail: true, + includeSecrets: true + })); + + return { + id: credentialIdString, + name: ps.name + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts new file mode 100644 index 0000000000..064828b638 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts @@ -0,0 +1,21 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { UserProfiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + value: { + validator: $.boolean + } + } +}; + +export default define(meta, async (ps, user) => { + await UserProfiles.update(user.id, { + usePasswordLessLogin: ps.value + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts new file mode 100644 index 0000000000..1b385a10ee --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts @@ -0,0 +1,59 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import define from '../../../define'; +import { UserProfiles, AttestationChallenges } from '@/models/index'; +import { promisify } from 'util'; +import * as crypto from 'crypto'; +import { genId } from '@/misc/gen-id'; +import { hash } from '../../../2fa'; + +const randomBytes = promisify(crypto.randomBytes); + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + password: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + if (!profile.twoFactorEnabled) { + throw new Error('2fa not enabled'); + } + + // 32 byte challenge + const entropy = await randomBytes(32); + const challenge = entropy.toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + const challengeId = genId(); + + await AttestationChallenges.save({ + userId: user.id, + id: challengeId, + challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'), + createdAt: new Date(), + registrationChallenge: true + }); + + return { + challengeId, + challenge + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts new file mode 100644 index 0000000000..b03b98188a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import * as speakeasy from 'speakeasy'; +import * as QRCode from 'qrcode'; +import config from '@/config/index'; +import define from '../../../define'; +import { UserProfiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + password: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + // Generate user's secret key + const secret = speakeasy.generateSecret({ + length: 32 + }); + + await UserProfiles.update(user.id, { + twoFactorTempSecret: secret.base32 + }); + + // Get the data URL of the authenticator URL + const dataUrl = await QRCode.toDataURL(speakeasy.otpauthURL({ + secret: secret.base32, + encoding: 'base32', + label: user.username, + issuer: config.host + })); + + return { + qr: dataUrl, + secret: secret.base32, + label: user.username, + issuer: config.host + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts new file mode 100644 index 0000000000..dea56301ab --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import define from '../../../define'; +import { UserProfiles, UserSecurityKeys, Users } from '@/models/index'; +import { publishMainStream } from '@/services/stream'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + password: { + validator: $.str + }, + credentialId: { + validator: $.str + }, + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + // Make sure we only delete the user's own creds + await UserSecurityKeys.delete({ + userId: user.id, + id: ps.credentialId + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, { + detail: true, + includeSecrets: true + })); + + return {}; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts new file mode 100644 index 0000000000..af53033daa --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -0,0 +1,32 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import define from '../../../define'; +import { UserProfiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + password: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + await UserProfiles.update(user.id, { + twoFactorSecret: null, + twoFactorEnabled: false + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/apps.ts b/packages/backend/src/server/api/endpoints/i/apps.ts new file mode 100644 index 0000000000..994528e5c9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/apps.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import define from '../../define'; +import { AccessTokens } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + sort: { + validator: $.optional.str.or([ + '+createdAt', + '-createdAt', + '+lastUsedAt', + '-lastUsedAt', + ]), + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = AccessTokens.createQueryBuilder('token') + .where('token.userId = :userId', { userId: user.id }); + + switch (ps.sort) { + case '+createdAt': query.orderBy('token.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('token.createdAt', 'ASC'); break; + case '+lastUsedAt': query.orderBy('token.lastUsedAt', 'DESC'); break; + case '-lastUsedAt': query.orderBy('token.lastUsedAt', 'ASC'); break; + default: query.orderBy('token.id', 'ASC'); break; + } + + const tokens = await query.getMany(); + + return await Promise.all(tokens.map(token => ({ + id: token.id, + name: token.name, + createdAt: token.createdAt, + lastUsedAt: token.lastUsedAt, + permission: token.permission, + }))); +}); diff --git a/packages/backend/src/server/api/endpoints/i/authorized-apps.ts b/packages/backend/src/server/api/endpoints/i/authorized-apps.ts new file mode 100644 index 0000000000..042fcd14e8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/authorized-apps.ts @@ -0,0 +1,44 @@ +import $ from 'cafy'; +import define from '../../define'; +import { AccessTokens, Apps } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + offset: { + validator: $.optional.num.min(0), + default: 0, + }, + + sort: { + validator: $.optional.str.or('desc|asc'), + default: 'desc', + } + } +}; + +export default define(meta, async (ps, user) => { + // Get tokens + const tokens = await AccessTokens.find({ + where: { + userId: user.id + }, + take: ps.limit!, + skip: ps.offset, + order: { + id: ps.sort == 'asc' ? 1 : -1 + } + }); + + return await Promise.all(tokens.map(token => Apps.pack(token.appId, user, { + detail: true + }))); +}); diff --git a/packages/backend/src/server/api/endpoints/i/change-password.ts b/packages/backend/src/server/api/endpoints/i/change-password.ts new file mode 100644 index 0000000000..7ea5f8c488 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/change-password.ts @@ -0,0 +1,39 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import define from '../../define'; +import { UserProfiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + currentPassword: { + validator: $.str + }, + + newPassword: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.currentPassword, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(ps.newPassword, salt); + + await UserProfiles.update(user.id, { + password: hash + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts new file mode 100644 index 0000000000..10e5adf64a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import define from '../../define'; +import { UserProfiles, Users } from '@/models/index'; +import { doPostSuspend } from '@/services/suspend-user'; +import { publishUserEvent } from '@/services/stream'; +import { createDeleteAccountJob } from '@/queue'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + password: { + validator: $.str + }, + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + const userDetailed = await Users.findOneOrFail(user.id); + if (userDetailed.isDeleted) { + return; + } + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + // 物理削除する前にDelete activityを送信する + await doPostSuspend(user).catch(e => {}); + + createDeleteAccountJob(user, { + soft: false + }); + + await Users.update(user.id, { + isDeleted: true, + }); + + // Terminate streaming + publishUserEvent(user.id, 'terminate', {}); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-blocking.ts b/packages/backend/src/server/api/endpoints/i/export-blocking.ts new file mode 100644 index 0000000000..e4797da0c1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-blocking.ts @@ -0,0 +1,16 @@ +import define from '../../define'; +import { createExportBlockingJob } from '@/queue/index'; +import * as ms from 'ms'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1hour'), + max: 1, + }, +}; + +export default define(meta, async (ps, user) => { + createExportBlockingJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-following.ts b/packages/backend/src/server/api/endpoints/i/export-following.ts new file mode 100644 index 0000000000..b0f154cda8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-following.ts @@ -0,0 +1,16 @@ +import define from '../../define'; +import { createExportFollowingJob } from '@/queue/index'; +import * as ms from 'ms'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1hour'), + max: 1, + }, +}; + +export default define(meta, async (ps, user) => { + createExportFollowingJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-mute.ts b/packages/backend/src/server/api/endpoints/i/export-mute.ts new file mode 100644 index 0000000000..46d547fa53 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-mute.ts @@ -0,0 +1,16 @@ +import define from '../../define'; +import { createExportMuteJob } from '@/queue/index'; +import * as ms from 'ms'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1hour'), + max: 1, + }, +}; + +export default define(meta, async (ps, user) => { + createExportMuteJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-notes.ts b/packages/backend/src/server/api/endpoints/i/export-notes.ts new file mode 100644 index 0000000000..441bf16896 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-notes.ts @@ -0,0 +1,16 @@ +import define from '../../define'; +import { createExportNotesJob } from '@/queue/index'; +import * as ms from 'ms'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1day'), + max: 1, + }, +}; + +export default define(meta, async (ps, user) => { + createExportNotesJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-user-lists.ts b/packages/backend/src/server/api/endpoints/i/export-user-lists.ts new file mode 100644 index 0000000000..24043a862a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-user-lists.ts @@ -0,0 +1,16 @@ +import define from '../../define'; +import { createExportUserListsJob } from '@/queue/index'; +import * as ms from 'ms'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1min'), + max: 1, + }, +}; + +export default define(meta, async (ps, user) => { + createExportUserListsJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/favorites.ts b/packages/backend/src/server/api/endpoints/i/favorites.ts new file mode 100644 index 0000000000..b79d68ae73 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/favorites.ts @@ -0,0 +1,50 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { NoteFavorites } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'notes', 'favorites'], + + requireCredential: true as const, + + kind: 'read:favorites', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'NoteFavorite', + } + }, +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(NoteFavorites.createQueryBuilder('favorite'), ps.sinceId, ps.untilId) + .andWhere(`favorite.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('favorite.note', 'note'); + + const favorites = await query + .take(ps.limit!) + .getMany(); + + return await NoteFavorites.packMany(favorites, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/gallery/likes.ts b/packages/backend/src/server/api/endpoints/i/gallery/likes.ts new file mode 100644 index 0000000000..7a2935a5ec --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/gallery/likes.ts @@ -0,0 +1,57 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { GalleryLikes } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'gallery'], + + requireCredential: true as const, + + kind: 'read:gallery-likes', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + page: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost' + } + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(GalleryLikes.createQueryBuilder('like'), ps.sinceId, ps.untilId) + .andWhere(`like.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('like.post', 'post'); + + const likes = await query + .take(ps.limit!) + .getMany(); + + return await GalleryLikes.packMany(likes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/gallery/posts.ts b/packages/backend/src/server/api/endpoints/i/gallery/posts.ts new file mode 100644 index 0000000000..21bb8759fc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/gallery/posts.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { GalleryPosts } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'gallery'], + + requireCredential: true as const, + + kind: 'read:gallery', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost' + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId) + .andWhere(`post.userId = :meId`, { meId: user.id }); + + const posts = await query + .take(ps.limit!) + .getMany(); + + return await GalleryPosts.packMany(posts, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts b/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts new file mode 100644 index 0000000000..6b9be98582 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts @@ -0,0 +1,33 @@ +import define from '../../define'; +import { MutedNotes } from '@/models/index'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'read:account', + + params: { + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + count: { + type: 'number' as const, + optional: false as const, nullable: false as const + } + } + } +}; + +export default define(meta, async (ps, user) => { + return { + count: await MutedNotes.count({ + userId: user.id, + reason: 'word' + }) + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts new file mode 100644 index 0000000000..d44d0b6077 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -0,0 +1,60 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { createImportBlockingJob } from '@/queue/index'; +import * as ms from 'ms'; +import { ApiError } from '../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + secure: true, + requireCredential: true as const, + + limit: { + duration: ms('1hour'), + max: 1, + }, + + params: { + fileId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'ebb53e5f-6574-9c0c-0b92-7ca6def56d7e' + }, + + unexpectedFileType: { + message: 'We need csv file.', + code: 'UNEXPECTED_FILE_TYPE', + id: 'b6fab7d6-d945-d67c-dfdb-32da1cd12cfe' + }, + + tooBigFile: { + message: 'That file is too big.', + code: 'TOO_BIG_FILE', + id: 'b7fbf0b1-aeef-3b21-29ef-fadd4cb72ccf' + }, + + emptyFile: { + message: 'That file is empty.', + code: 'EMPTY_FILE', + id: '6f3a4dcc-f060-a707-4950-806fbdbe60d6' + }, + } +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + createImportBlockingJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts new file mode 100644 index 0000000000..b3de397661 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -0,0 +1,59 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { createImportFollowingJob } from '@/queue/index'; +import * as ms from 'ms'; +import { ApiError } from '../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1hour'), + max: 1, + }, + + params: { + fileId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'b98644cf-a5ac-4277-a502-0b8054a709a3' + }, + + unexpectedFileType: { + message: 'We need csv file.', + code: 'UNEXPECTED_FILE_TYPE', + id: '660f3599-bce0-4f95-9dde-311fd841c183' + }, + + tooBigFile: { + message: 'That file is too big.', + code: 'TOO_BIG_FILE', + id: 'dee9d4ed-ad07-43ed-8b34-b2856398bc60' + }, + + emptyFile: { + message: 'That file is empty.', + code: 'EMPTY_FILE', + id: '31a1b42c-06f7-42ae-8a38-a661c5c9f691' + }, + } +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + createImportFollowingJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts new file mode 100644 index 0000000000..c17434c587 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -0,0 +1,60 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { createImportMutingJob } from '@/queue/index'; +import * as ms from 'ms'; +import { ApiError } from '../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + secure: true, + requireCredential: true as const, + + limit: { + duration: ms('1hour'), + max: 1, + }, + + params: { + fileId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'e674141e-bd2a-ba85-e616-aefb187c9c2a' + }, + + unexpectedFileType: { + message: 'We need csv file.', + code: 'UNEXPECTED_FILE_TYPE', + id: '568c6e42-c86c-ba09-c004-517f83f9f1a8' + }, + + tooBigFile: { + message: 'That file is too big.', + code: 'TOO_BIG_FILE', + id: '9b4ada6d-d7f7-0472-0713-4f558bd1ec9c' + }, + + emptyFile: { + message: 'That file is empty.', + code: 'EMPTY_FILE', + id: 'd2f12af1-e7b4-feac-86a3-519548f2728e' + }, + } +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + createImportMutingJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts new file mode 100644 index 0000000000..9069a019a9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts @@ -0,0 +1,59 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { createImportUserListsJob } from '@/queue/index'; +import * as ms from 'ms'; +import { ApiError } from '../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1hour'), + max: 1, + }, + + params: { + fileId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'ea9cc34f-c415-4bc6-a6fe-28ac40357049' + }, + + unexpectedFileType: { + message: 'We need csv file.', + code: 'UNEXPECTED_FILE_TYPE', + id: 'a3c9edda-dd9b-4596-be6a-150ef813745c' + }, + + tooBigFile: { + message: 'That file is too big.', + code: 'TOO_BIG_FILE', + id: 'ae6e7a22-971b-4b52-b2be-fc0b9b121fe9' + }, + + emptyFile: { + message: 'That file is empty.', + code: 'EMPTY_FILE', + id: '99efe367-ce6e-4d44-93f8-5fae7b040356' + }, + } +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 30000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + createImportUserListsJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts new file mode 100644 index 0000000000..56668d03b7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -0,0 +1,138 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { readNotification } from '../../common/read-notification'; +import define from '../../define'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Notifications, Followings, Mutings, Users } from '@/models/index'; +import { notificationTypes } from '@/types'; +import read from '@/services/note/read'; +import { Brackets } from 'typeorm'; + +export const meta = { + tags: ['account', 'notifications'], + + requireCredential: true as const, + + kind: 'read:notifications', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + following: { + validator: $.optional.bool, + default: false + }, + + unreadOnly: { + validator: $.optional.bool, + default: false + }, + + markAsRead: { + validator: $.optional.bool, + default: true + }, + + includeTypes: { + validator: $.optional.arr($.str.or(notificationTypes as unknown as string[])), + }, + + excludeTypes: { + validator: $.optional.arr($.str.or(notificationTypes as unknown as string[])), + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Notification', + } + }, +}; + +export default define(meta, async (ps, user) => { + // includeTypes が空の場合はクエリしない + if (ps.includeTypes && ps.includeTypes.length === 0) { + return []; + } + // excludeTypes に全指定されている場合はクエリしない + if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) { + return []; + } + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: user.id }); + + const mutingQuery = Mutings.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: user.id }); + + const suspendedQuery = Users.createQueryBuilder('users') + .select('users.id') + .where('users.isSuspended = TRUE'); + + const query = makePaginationQuery(Notifications.createQueryBuilder('notification'), ps.sinceId, ps.untilId) + .andWhere(`notification.notifieeId = :meId`, { meId: user.id }) + .leftJoinAndSelect('notification.notifier', 'notifier') + .leftJoinAndSelect('notification.note', 'note') + .leftJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + query.andWhere(new Brackets(qb => { qb + .where(`notification.notifierId NOT IN (${ mutingQuery.getQuery() })`) + .orWhere('notification.notifierId IS NULL'); + })); + query.setParameters(mutingQuery.getParameters()); + + query.andWhere(new Brackets(qb => { qb + .where(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`) + .orWhere('notification.notifierId IS NULL'); + })); + + if (ps.following) { + query.andWhere(`((notification.notifierId IN (${ followingQuery.getQuery() })) OR (notification.notifierId = :meId))`, { meId: user.id }); + query.setParameters(followingQuery.getParameters()); + } + + if (ps.includeTypes && ps.includeTypes.length > 0) { + query.andWhere(`notification.type IN (:...includeTypes)`, { includeTypes: ps.includeTypes }); + } else if (ps.excludeTypes && ps.excludeTypes.length > 0) { + query.andWhere(`notification.type NOT IN (:...excludeTypes)`, { excludeTypes: ps.excludeTypes }); + } + + if (ps.unreadOnly) { + query.andWhere(`notification.isRead = false`); + } + + const notifications = await query.take(ps.limit!).getMany(); + + // Mark all as read + if (notifications.length > 0 && ps.markAsRead) { + readNotification(user.id, notifications.map(x => x.id)); + } + + const notes = notifications.filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)).map(notification => notification.note!); + + if (notes.length > 0) { + read(user.id, notes); + } + + return await Notifications.packMany(notifications, user.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/page-likes.ts b/packages/backend/src/server/api/endpoints/i/page-likes.ts new file mode 100644 index 0000000000..fa2bc31730 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/page-likes.ts @@ -0,0 +1,57 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { PageLikes } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'pages'], + + requireCredential: true as const, + + kind: 'read:page-likes', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + page: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Page' + } + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(PageLikes.createQueryBuilder('like'), ps.sinceId, ps.untilId) + .andWhere(`like.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('like.page', 'page'); + + const likes = await query + .take(ps.limit!) + .getMany(); + + return await PageLikes.packMany(likes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/pages.ts b/packages/backend/src/server/api/endpoints/i/pages.ts new file mode 100644 index 0000000000..ee87fffa2d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/pages.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Pages } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'pages'], + + requireCredential: true as const, + + kind: 'read:pages', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Page' + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(Pages.createQueryBuilder('page'), ps.sinceId, ps.untilId) + .andWhere(`page.userId = :meId`, { meId: user.id }); + + const pages = await query + .take(ps.limit!) + .getMany(); + + return await Pages.packMany(pages); +}); diff --git a/packages/backend/src/server/api/endpoints/i/pin.ts b/packages/backend/src/server/api/endpoints/i/pin.ts new file mode 100644 index 0000000000..de94220ba9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/pin.ts @@ -0,0 +1,59 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { addPinned } from '@/services/i/pin'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['account', 'notes'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '56734f8b-3928-431e-bf80-6ff87df40cb3' + }, + + pinLimitExceeded: { + message: 'You can not pin notes any more.', + code: 'PIN_LIMIT_EXCEEDED', + id: '72dab508-c64d-498f-8740-a8eec1ba385a' + }, + + alreadyPinned: { + message: 'That note has already been pinned.', + code: 'ALREADY_PINNED', + id: '8b18c2b7-68fe-4edb-9892-c0cbaeb6c913' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } +}; + +export default define(meta, async (ps, user) => { + await addPinned(user, ps.noteId).catch(e => { + if (e.id === '70c4e51f-5bea-449c-a030-53bee3cce202') throw new ApiError(meta.errors.noSuchNote); + if (e.id === '15a018eb-58e5-4da1-93be-330fcc5e4e1a') throw new ApiError(meta.errors.pinLimitExceeded); + if (e.id === '23f0cf4e-59a3-4276-a91d-61a5891c1514') throw new ApiError(meta.errors.alreadyPinned); + throw e; + }); + + return await Users.pack(user.id, user, { + detail: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts b/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts new file mode 100644 index 0000000000..9aca7611c9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts @@ -0,0 +1,37 @@ +import { publishMainStream } from '@/services/stream'; +import define from '../../define'; +import { MessagingMessages, UserGroupJoinings } from '@/models/index'; + +export const meta = { + tags: ['account', 'messaging'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + } +}; + +export default define(meta, async (ps, user) => { + // Update documents + await MessagingMessages.update({ + recipientId: user.id, + isRead: false + }, { + isRead: true + }); + + const joinings = await UserGroupJoinings.find({ userId: user.id }); + + await Promise.all(joinings.map(j => MessagingMessages.createQueryBuilder().update() + .set({ + reads: (() => `array_append("reads", '${user.id}')`) as any + }) + .where(`groupId = :groupId`, { groupId: j.userGroupId }) + .andWhere('userId != :userId', { userId: user.id }) + .andWhere('NOT (:userId = ANY(reads))', { userId: user.id }) + .execute())); + + publishMainStream(user.id, 'readAllMessagingMessages'); +}); diff --git a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts b/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts new file mode 100644 index 0000000000..2a7102a590 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts @@ -0,0 +1,25 @@ +import { publishMainStream } from '@/services/stream'; +import define from '../../define'; +import { NoteUnreads } from '@/models/index'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + } +}; + +export default define(meta, async (ps, user) => { + // Remove documents + await NoteUnreads.delete({ + userId: user.id + }); + + // 全て既読になったイベントを発行 + publishMainStream(user.id, 'readAllUnreadMentions'); + publishMainStream(user.id, 'readAllUnreadSpecifiedNotes'); +}); diff --git a/packages/backend/src/server/api/endpoints/i/read-announcement.ts b/packages/backend/src/server/api/endpoints/i/read-announcement.ts new file mode 100644 index 0000000000..2f5036f953 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/read-announcement.ts @@ -0,0 +1,60 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { genId } from '@/misc/gen-id'; +import { AnnouncementReads, Announcements, Users } from '@/models/index'; +import { publishMainStream } from '@/services/stream'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + announcementId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchAnnouncement: { + message: 'No such announcement.', + code: 'NO_SUCH_ANNOUNCEMENT', + id: '184663db-df88-4bc2-8b52-fb85f0681939' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Check if announcement exists + const announcement = await Announcements.findOne(ps.announcementId); + + if (announcement == null) { + throw new ApiError(meta.errors.noSuchAnnouncement); + } + + // Check if already read + const read = await AnnouncementReads.findOne({ + announcementId: ps.announcementId, + userId: user.id + }); + + if (read != null) { + return; + } + + // Create read + await AnnouncementReads.insert({ + id: genId(), + createdAt: new Date(), + announcementId: ps.announcementId, + userId: user.id, + }); + + if (!await Users.getHasUnreadAnnouncement(user.id)) { + publishMainStream(user.id, 'readAllAnnouncements'); + } +}); diff --git a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts new file mode 100644 index 0000000000..1cce2d37be --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -0,0 +1,44 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import { publishMainStream, publishUserEvent } from '@/services/stream'; +import generateUserToken from '../../common/generate-native-user-token'; +import define from '../../define'; +import { Users, UserProfiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + password: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + // Generate secret + const secret = generateUserToken(); + + await Users.update(user.id, { + token: secret + }); + + // Publish event + publishMainStream(user.id, 'myTokenRegenerated'); + + // Terminate streaming + setTimeout(() => { + publishUserEvent(user.id, 'terminate', {}); + }, 5000); +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-all.ts b/packages/backend/src/server/api/endpoints/i/registry/get-all.ts new file mode 100644 index 0000000000..c8eaf83a25 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/get-all.ts @@ -0,0 +1,33 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const items = await query.getMany(); + + const res = {} as Record<string, any>; + + for (const item of items) { + res[item.key] = item.value; + } + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts b/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts new file mode 100644 index 0000000000..992800c44c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + key: { + validator: $.str + }, + + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + }, + + errors: { + noSuchKey: { + message: 'No such key.', + code: 'NO_SUCH_KEY', + id: '97a1e8e7-c0f7-47d2-957a-92e61256e01a' + }, + }, +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const item = await query.getOne(); + + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + return { + updatedAt: item.updatedAt, + value: item.value, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get.ts b/packages/backend/src/server/api/endpoints/i/registry/get.ts new file mode 100644 index 0000000000..569c3a9280 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/get.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + key: { + validator: $.str + }, + + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + }, + + errors: { + noSuchKey: { + message: 'No such key.', + code: 'NO_SUCH_KEY', + id: 'ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a' + }, + }, +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const item = await query.getOne(); + + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + return item.value; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts b/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts new file mode 100644 index 0000000000..16a4fee374 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts @@ -0,0 +1,41 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const items = await query.getMany(); + + const res = {} as Record<string, string>; + + for (const item of items) { + const type = typeof item.value; + res[item.key] = + item.value === null ? 'null' : + Array.isArray(item.value) ? 'array' : + type === 'number' ? 'number' : + type === 'string' ? 'string' : + type === 'boolean' ? 'boolean' : + type === 'object' ? 'object' : + null as never; + } + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/keys.ts b/packages/backend/src/server/api/endpoints/i/registry/keys.ts new file mode 100644 index 0000000000..3a8aeaa195 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/keys.ts @@ -0,0 +1,28 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .select('item.key') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const items = await query.getMany(); + + return items.map(x => x.key); +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/remove.ts b/packages/backend/src/server/api/endpoints/i/registry/remove.ts new file mode 100644 index 0000000000..07bc23d4a6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/remove.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + key: { + validator: $.str + }, + + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + }, + + errors: { + noSuchKey: { + message: 'No such key.', + code: 'NO_SUCH_KEY', + id: '1fac4e8a-a6cd-4e39-a4a5-3a7e11f1b019' + }, + }, +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const item = await query.getOne(); + + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + await RegistryItems.remove(item); +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/scopes.ts b/packages/backend/src/server/api/endpoints/i/registry/scopes.ts new file mode 100644 index 0000000000..ecbdb05a8e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/scopes.ts @@ -0,0 +1,29 @@ +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .select('item.scope') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }); + + const items = await query.getMany(); + + const res = [] as string[][]; + + for (const item of items) { + if (res.some(scope => scope.join('.') === item.scope.join('.'))) continue; + res.push(item.scope); + } + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/set.ts b/packages/backend/src/server/api/endpoints/i/registry/set.ts new file mode 100644 index 0000000000..f129ee1b70 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/set.ts @@ -0,0 +1,61 @@ +import $ from 'cafy'; +import { publishMainStream } from '@/services/stream'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + key: { + validator: $.str.min(1) + }, + + value: { + validator: $.nullable.any + }, + + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const existingItem = await query.getOne(); + + if (existingItem) { + await RegistryItems.update(existingItem.id, { + updatedAt: new Date(), + value: ps.value + }); + } else { + await RegistryItems.insert({ + id: genId(), + createdAt: new Date(), + updatedAt: new Date(), + userId: user.id, + domain: null, + scope: ps.scope, + key: ps.key, + value: ps.value + }); + } + + // TODO: サードパーティアプリが傍受出来てしまうのでどうにかする + publishMainStream(user.id, 'registryUpdated', { + scope: ps.scope, + key: ps.key, + value: ps.value + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/revoke-token.ts b/packages/backend/src/server/api/endpoints/i/revoke-token.ts new file mode 100644 index 0000000000..bed868def4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/revoke-token.ts @@ -0,0 +1,31 @@ +import $ from 'cafy'; +import define from '../../define'; +import { AccessTokens } from '@/models/index'; +import { ID } from '@/misc/cafy-id'; +import { publishUserEvent } from '@/services/stream'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + tokenId: { + validator: $.type(ID) + } + } +}; + +export default define(meta, async (ps, user) => { + const token = await AccessTokens.findOne(ps.tokenId); + + if (token) { + await AccessTokens.delete({ + id: ps.tokenId, + userId: user.id, + }); + + // Terminate streaming + publishUserEvent(user.id, 'terminate'); + } +}); diff --git a/packages/backend/src/server/api/endpoints/i/signin-history.ts b/packages/backend/src/server/api/endpoints/i/signin-history.ts new file mode 100644 index 0000000000..a2c10148c6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/signin-history.ts @@ -0,0 +1,35 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Signins } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(Signins.createQueryBuilder('signin'), ps.sinceId, ps.untilId) + .andWhere(`signin.userId = :meId`, { meId: user.id }); + + const history = await query.take(ps.limit!).getMany(); + + return await Promise.all(history.map(record => Signins.pack(record))); +}); diff --git a/packages/backend/src/server/api/endpoints/i/unpin.ts b/packages/backend/src/server/api/endpoints/i/unpin.ts new file mode 100644 index 0000000000..dc79e255ab --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/unpin.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { removePinned } from '@/services/i/pin'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['account', 'notes'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '454170ce-9d63-4a43-9da1-ea10afe81e21' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } +}; + +export default define(meta, async (ps, user) => { + await removePinned(user, ps.noteId).catch(e => { + if (e.id === 'b302d4cf-c050-400a-bbb3-be208681f40c') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + return await Users.pack(user.id, user, { + detail: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts new file mode 100644 index 0000000000..9b6fb9c410 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -0,0 +1,94 @@ +import $ from 'cafy'; +import { publishMainStream } from '@/services/stream'; +import define from '../../define'; +import rndstr from 'rndstr'; +import config from '@/config/index'; +import * as ms from 'ms'; +import * as bcrypt from 'bcryptjs'; +import { Users, UserProfiles } from '@/models/index'; +import { sendEmail } from '@/services/send-email'; +import { ApiError } from '../../error'; +import { validateEmailForAccount } from '@/services/validate-email-for-account'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + limit: { + duration: ms('1hour'), + max: 3 + }, + + params: { + password: { + validator: $.str + }, + + email: { + validator: $.optional.nullable.str + }, + }, + + errors: { + incorrectPassword: { + message: 'Incorrect password.', + code: 'INCORRECT_PASSWORD', + id: 'e54c1d7e-e7d6-4103-86b6-0a95069b4ad3' + }, + + unavailable: { + message: 'Unavailable email address.', + code: 'UNAVAILABLE', + id: 'a2defefb-f220-8849-0af6-17f816099323' + }, + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new ApiError(meta.errors.incorrectPassword); + } + + if (ps.email != null) { + const available = await validateEmailForAccount(ps.email); + if (!available) { + throw new ApiError(meta.errors.unavailable); + } + } + + await UserProfiles.update(user.id, { + email: ps.email, + emailVerified: false, + emailVerifyCode: null + }); + + const iObj = await Users.pack(user.id, user, { + detail: true, + includeSecrets: true + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', iObj); + + if (ps.email != null) { + const code = rndstr('a-z0-9', 16); + + await UserProfiles.update(user.id, { + emailVerifyCode: code + }); + + const link = `${config.url}/verify-email/${code}`; + + sendEmail(ps.email, 'Email verification', + `To verify email, please click this link:<br><a href="${link}">${link}</a>`, + `To verify email, please click this link: ${link}`); + } + + return iObj; +}); diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts new file mode 100644 index 0000000000..d0f201ab60 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -0,0 +1,294 @@ +import $ from 'cafy'; +import * as mfm from 'mfm-js'; +import { ID } from '@/misc/cafy-id'; +import { publishMainStream, publishUserEvent } from '@/services/stream'; +import acceptAllFollowRequests from '@/services/following/requests/accept-all'; +import { publishToFollowers } from '@/services/i/update'; +import define from '../../define'; +import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm'; +import { extractHashtags } from '@/misc/extract-hashtags'; +import * as langmap from 'langmap'; +import { updateUsertags } from '@/services/update-hashtag'; +import { ApiError } from '../../error'; +import { Users, DriveFiles, UserProfiles, Pages } from '@/models/index'; +import { User } from '@/models/entities/user'; +import { UserProfile } from '@/models/entities/user-profile'; +import { notificationTypes } from '@/types'; +import { normalizeForSearch } from '@/misc/normalize-for-search'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + name: { + validator: $.optional.nullable.use(Users.validateName), + }, + + description: { + validator: $.optional.nullable.use(Users.validateDescription), + }, + + lang: { + validator: $.optional.nullable.str.or(Object.keys(langmap)), + }, + + location: { + validator: $.optional.nullable.use(Users.validateLocation), + }, + + birthday: { + validator: $.optional.nullable.use(Users.validateBirthday), + }, + + avatarId: { + validator: $.optional.nullable.type(ID), + }, + + bannerId: { + validator: $.optional.nullable.type(ID), + }, + + fields: { + validator: $.optional.arr($.object()).range(1, 4), + }, + + isLocked: { + validator: $.optional.bool, + }, + + isExplorable: { + validator: $.optional.bool, + }, + + hideOnlineStatus: { + validator: $.optional.bool, + }, + + publicReactions: { + validator: $.optional.bool, + }, + + ffVisibility: { + validator: $.optional.str, + }, + + carefulBot: { + validator: $.optional.bool, + }, + + autoAcceptFollowed: { + validator: $.optional.bool, + }, + + noCrawle: { + validator: $.optional.bool, + }, + + isBot: { + validator: $.optional.bool, + }, + + isCat: { + validator: $.optional.bool, + }, + + injectFeaturedNote: { + validator: $.optional.bool, + }, + + receiveAnnouncementEmail: { + validator: $.optional.bool, + }, + + alwaysMarkNsfw: { + validator: $.optional.bool, + }, + + pinnedPageId: { + validator: $.optional.nullable.type(ID), + }, + + mutedWords: { + validator: $.optional.arr($.arr($.str)) + }, + + mutingNotificationTypes: { + validator: $.optional.arr($.str.or(notificationTypes as unknown as string[])) + }, + + emailNotificationTypes: { + validator: $.optional.arr($.str) + }, + }, + + errors: { + noSuchAvatar: { + message: 'No such avatar file.', + code: 'NO_SUCH_AVATAR', + id: '539f3a45-f215-4f81-a9a8-31293640207f' + }, + + noSuchBanner: { + message: 'No such banner file.', + code: 'NO_SUCH_BANNER', + id: '0d8f5629-f210-41c2-9433-735831a58595' + }, + + avatarNotAnImage: { + message: 'The file specified as an avatar is not an image.', + code: 'AVATAR_NOT_AN_IMAGE', + id: 'f419f9f8-2f4d-46b1-9fb4-49d3a2fd7191' + }, + + bannerNotAnImage: { + message: 'The file specified as a banner is not an image.', + code: 'BANNER_NOT_AN_IMAGE', + id: '75aedb19-2afd-4e6d-87fc-67941256fa60' + }, + + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: '8e01b590-7eb9-431b-a239-860e086c408e' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } +}; + +export default define(meta, async (ps, _user, token) => { + const user = await Users.findOneOrFail(_user.id); + const isSecure = token == null; + + const updates = {} as Partial<User>; + const profileUpdates = {} as Partial<UserProfile>; + + const profile = await UserProfiles.findOneOrFail(user.id); + + if (ps.name !== undefined) updates.name = ps.name; + if (ps.description !== undefined) profileUpdates.description = ps.description; + if (ps.lang !== undefined) profileUpdates.lang = ps.lang; + if (ps.location !== undefined) profileUpdates.location = ps.location; + if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; + if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility; + if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; + if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId; + if (ps.mutedWords !== undefined) { + profileUpdates.mutedWords = ps.mutedWords; + profileUpdates.enableWordMute = ps.mutedWords.length > 0; + } + if (ps.mutingNotificationTypes !== undefined) profileUpdates.mutingNotificationTypes = ps.mutingNotificationTypes as typeof notificationTypes[number][]; + if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; + if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable; + if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus; + if (typeof ps.publicReactions === 'boolean') profileUpdates.publicReactions = ps.publicReactions; + if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot; + if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; + if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; + if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle; + if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat; + if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; + if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; + if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; + if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; + + if (ps.avatarId) { + const avatar = await DriveFiles.findOne(ps.avatarId); + + if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); + if (!avatar.type.startsWith('image/')) throw new ApiError(meta.errors.avatarNotAnImage); + + updates.avatarUrl = DriveFiles.getPublicUrl(avatar, true); + + if (avatar.blurhash) { + updates.avatarBlurhash = avatar.blurhash; + } + } + + if (ps.bannerId) { + const banner = await DriveFiles.findOne(ps.bannerId); + + if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); + if (!banner.type.startsWith('image/')) throw new ApiError(meta.errors.bannerNotAnImage); + + updates.bannerUrl = DriveFiles.getPublicUrl(banner, false); + + if (banner.blurhash) { + updates.bannerBlurhash = banner.blurhash; + } + } + + if (ps.pinnedPageId) { + const page = await Pages.findOne(ps.pinnedPageId); + + if (page == null || page.userId !== user.id) throw new ApiError(meta.errors.noSuchPage); + + profileUpdates.pinnedPageId = page.id; + } else if (ps.pinnedPageId === null) { + profileUpdates.pinnedPageId = null; + } + + if (ps.fields) { + profileUpdates.fields = ps.fields + .filter(x => typeof x.name === 'string' && x.name !== '' && typeof x.value === 'string' && x.value !== '') + .map(x => { + return { name: x.name, value: x.value }; + }); + } + + //#region emojis/tags + + let emojis = [] as string[]; + let tags = [] as string[]; + + const newName = updates.name === undefined ? user.name : updates.name; + const newDescription = profileUpdates.description === undefined ? profile.description : profileUpdates.description; + + if (newName != null) { + const tokens = mfm.parsePlain(newName); + emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!)); + } + + if (newDescription != null) { + const tokens = mfm.parse(newDescription); + emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!)); + tags = extractHashtags(tokens!).map(tag => normalizeForSearch(tag)).splice(0, 32); + } + + updates.emojis = emojis; + updates.tags = tags; + + // ハッシュタグ更新 + updateUsertags(user, tags); + //#endregion + + if (Object.keys(updates).length > 0) await Users.update(user.id, updates); + if (Object.keys(profileUpdates).length > 0) await UserProfiles.update(user.id, profileUpdates); + + const iObj = await Users.pack(user.id, user, { + detail: true, + includeSecrets: isSecure + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', iObj); + publishUserEvent(user.id, 'updateUserProfile', await UserProfiles.findOne(user.id)); + + // 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認 + if (user.isLocked && ps.isLocked === false) { + acceptAllFollowRequests(user); + } + + // フォロワーにUpdateを配信 + publishToFollowers(user.id); + + return iObj; +}); diff --git a/packages/backend/src/server/api/endpoints/i/user-group-invites.ts b/packages/backend/src/server/api/endpoints/i/user-group-invites.ts new file mode 100644 index 0000000000..1ebde243ca --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/user-group-invites.ts @@ -0,0 +1,61 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { UserGroupInvitations } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'groups'], + + requireCredential: true as const, + + kind: 'read:user-groups', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + group: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserGroup' + } + } + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(UserGroupInvitations.createQueryBuilder('invitation'), ps.sinceId, ps.untilId) + .andWhere(`invitation.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('invitation.userGroup', 'user_group'); + + const invitations = await query + .take(ps.limit!) + .getMany(); + + return await UserGroupInvitations.packMany(invitations); +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/history.ts b/packages/backend/src/server/api/endpoints/messaging/history.ts new file mode 100644 index 0000000000..e447703546 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/history.ts @@ -0,0 +1,94 @@ +import $ from 'cafy'; +import define from '../../define'; +import { MessagingMessage } from '@/models/entities/messaging-message'; +import { MessagingMessages, Mutings, UserGroupJoinings } from '@/models/index'; +import { Brackets } from 'typeorm'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true as const, + + kind: 'read:messaging', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + group: { + validator: $.optional.bool, + default: false + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'MessagingMessage', + } + }, +}; + +export default define(meta, async (ps, user) => { + const mute = await Mutings.find({ + muterId: user.id, + }); + + const groups = ps.group ? await UserGroupJoinings.find({ + userId: user.id, + }).then(xs => xs.map(x => x.userGroupId)) : []; + + if (ps.group && groups.length === 0) { + return []; + } + + const history: MessagingMessage[] = []; + + for (let i = 0; i < ps.limit!; i++) { + const found = ps.group + ? history.map(m => m.groupId!) + : history.map(m => (m.userId === user.id) ? m.recipientId! : m.userId!); + + const query = MessagingMessages.createQueryBuilder('message') + .orderBy('message.createdAt', 'DESC'); + + if (ps.group) { + query.where(`message.groupId IN (:...groups)`, { groups: groups }); + + if (found.length > 0) { + query.andWhere(`message.groupId NOT IN (:...found)`, { found: found }); + } + } else { + query.where(new Brackets(qb => { qb + .where(`message.userId = :userId`, { userId: user.id }) + .orWhere(`message.recipientId = :userId`, { userId: user.id }); + })); + query.andWhere(`message.groupId IS NULL`); + + if (found.length > 0) { + query.andWhere(`message.userId NOT IN (:...found)`, { found: found }); + query.andWhere(`message.recipientId NOT IN (:...found)`, { found: found }); + } + + if (mute.length > 0) { + query.andWhere(`message.userId NOT IN (:...mute)`, { mute: mute.map(m => m.muteeId) }); + query.andWhere(`message.recipientId NOT IN (:...mute)`, { mute: mute.map(m => m.muteeId) }); + } + } + + const message = await query.getOne(); + + if (message) { + history.push(message); + } else { + break; + } + } + + return await Promise.all(history.map(h => MessagingMessages.pack(h.id, user))); +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/messages.ts b/packages/backend/src/server/api/endpoints/messaging/messages.ts new file mode 100644 index 0000000000..6baa24609e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages.ts @@ -0,0 +1,148 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { MessagingMessages, UserGroups, UserGroupJoinings, Users } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Brackets } from 'typeorm'; +import { readUserMessagingMessage, readGroupMessagingMessage, deliverReadActivity } from '../../common/read-messaging-message'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true as const, + + kind: 'read:messaging', + + params: { + userId: { + validator: $.optional.type(ID), + }, + + groupId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + markAsRead: { + validator: $.optional.bool, + default: true + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'MessagingMessage', + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '11795c64-40ea-4198-b06e-3c873ed9039d' + }, + + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: 'c4d9f88c-9270-4632-b032-6ed8cee36f7f' + }, + + groupAccessDenied: { + message: 'You can not read messages of groups that you have not joined.', + code: 'GROUP_ACCESS_DENIED', + id: 'a053a8dd-a491-4718-8f87-50775aad9284' + }, + } +}; + +export default define(meta, async (ps, user) => { + if (ps.userId != null) { + // Fetch recipient (user) + const recipient = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + const query = makePaginationQuery(MessagingMessages.createQueryBuilder('message'), ps.sinceId, ps.untilId) + .andWhere(new Brackets(qb => { qb + .where(new Brackets(qb => { qb + .where('message.userId = :meId') + .andWhere('message.recipientId = :recipientId'); + })) + .orWhere(new Brackets(qb => { qb + .where('message.userId = :recipientId') + .andWhere('message.recipientId = :meId'); + })); + })) + .setParameter('meId', user.id) + .setParameter('recipientId', recipient.id); + + const messages = await query.take(ps.limit!).getMany(); + + // Mark all as read + if (ps.markAsRead) { + readUserMessagingMessage(user.id, recipient.id, messages.filter(m => m.recipientId === user.id).map(x => x.id)); + + // リモートユーザーとのメッセージだったら既読配信 + if (Users.isLocalUser(user) && Users.isRemoteUser(recipient)) { + deliverReadActivity(user, recipient, messages); + } + } + + return await Promise.all(messages.map(message => MessagingMessages.pack(message, user, { + populateRecipient: false + }))); + } else if (ps.groupId != null) { + // Fetch recipient (group) + const recipientGroup = await UserGroups.findOne(ps.groupId); + + if (recipientGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // check joined + const joining = await UserGroupJoinings.findOne({ + userId: user.id, + userGroupId: recipientGroup.id + }); + + if (joining == null) { + throw new ApiError(meta.errors.groupAccessDenied); + } + + const query = makePaginationQuery(MessagingMessages.createQueryBuilder('message'), ps.sinceId, ps.untilId) + .andWhere(`message.groupId = :groupId`, { groupId: recipientGroup.id }); + + const messages = await query.take(ps.limit!).getMany(); + + // Mark all as read + if (ps.markAsRead) { + readGroupMessagingMessage(user.id, recipientGroup.id, messages.map(x => x.id)); + } + + return await Promise.all(messages.map(message => MessagingMessages.pack(message, user, { + populateGroup: false + }))); + } else { + throw new Error(); + } +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/create.ts b/packages/backend/src/server/api/endpoints/messaging/messages/create.ts new file mode 100644 index 0000000000..df0b455cbe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages/create.ts @@ -0,0 +1,148 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { MessagingMessages, DriveFiles, UserGroups, UserGroupJoinings, Blockings } from '@/models/index'; +import { User } from '@/models/entities/user'; +import { UserGroup } from '@/models/entities/user-group'; +import { createMessage } from '@/services/messages/create'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true as const, + + kind: 'write:messaging', + + params: { + userId: { + validator: $.optional.type(ID), + }, + + groupId: { + validator: $.optional.type(ID), + }, + + text: { + validator: $.optional.str.pipe(MessagingMessages.validateText) + }, + + fileId: { + validator: $.optional.type(ID), + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'MessagingMessage', + }, + + errors: { + recipientIsYourself: { + message: 'You can not send a message to yourself.', + code: 'RECIPIENT_IS_YOURSELF', + id: '17e2ba79-e22a-4cbc-bf91-d327643f4a7e' + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '11795c64-40ea-4198-b06e-3c873ed9039d' + }, + + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: 'c94e2a5d-06aa-4914-8fa6-6a42e73d6537' + }, + + groupAccessDenied: { + message: 'You can not send messages to groups that you have not joined.', + code: 'GROUP_ACCESS_DENIED', + id: 'd96b3cca-5ad1-438b-ad8b-02f931308fbd' + }, + + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: '4372b8e2-185d-4146-8749-2f68864a3e5f' + }, + + contentRequired: { + message: 'Content required. You need to set text or fileId.', + code: 'CONTENT_REQUIRED', + id: '25587321-b0e6-449c-9239-f8925092942c' + }, + + youHaveBeenBlocked: { + message: 'You cannot send a message because you have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: 'c15a5199-7422-4968-941a-2a462c478f7d' + }, + } +}; + +export default define(meta, async (ps, user) => { + let recipientUser: User | undefined; + let recipientGroup: UserGroup | undefined; + + if (ps.userId != null) { + // Myself + if (ps.userId === user.id) { + throw new ApiError(meta.errors.recipientIsYourself); + } + + // Fetch recipient (user) + recipientUser = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check blocking + const block = await Blockings.findOne({ + blockerId: recipientUser.id, + blockeeId: user.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } else if (ps.groupId != null) { + // Fetch recipient (group) + recipientGroup = await UserGroups.findOne(ps.groupId); + + if (recipientGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // check joined + const joining = await UserGroupJoinings.findOne({ + userId: user.id, + userGroupId: recipientGroup.id + }); + + if (joining == null) { + throw new ApiError(meta.errors.groupAccessDenied); + } + } + + let file = null; + if (ps.fileId != null) { + file = await DriveFiles.findOne({ + id: ps.fileId, + userId: user.id + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + // テキストが無いかつ添付ファイルも無かったらエラー + if (ps.text == null && file == null) { + throw new ApiError(meta.errors.contentRequired); + } + + return await createMessage(user, recipientUser, recipientGroup, ps.text, file); +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts b/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts new file mode 100644 index 0000000000..bd4890fc8a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import * as ms from 'ms'; +import { ApiError } from '../../../error'; +import { MessagingMessages } from '@/models/index'; +import { deleteMessage } from '@/services/messages/delete'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true as const, + + kind: 'write:messaging', + + limit: { + duration: ms('1hour'), + max: 300, + minInterval: ms('1sec') + }, + + params: { + messageId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: '54b5b326-7925-42cf-8019-130fda8b56af' + }, + } +}; + +export default define(meta, async (ps, user) => { + const message = await MessagingMessages.findOne({ + id: ps.messageId, + userId: user.id + }); + + if (message == null) { + throw new ApiError(meta.errors.noSuchMessage); + } + + await deleteMessage(message); +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/read.ts b/packages/backend/src/server/api/endpoints/messaging/messages/read.ts new file mode 100644 index 0000000000..a1747310d3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages/read.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { MessagingMessages } from '@/models/index'; +import { readUserMessagingMessage, readGroupMessagingMessage } from '../../../common/read-messaging-message'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true as const, + + kind: 'write:messaging', + + params: { + messageId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: '86d56a2f-a9c3-4afb-b13c-3e9bfef9aa14' + }, + } +}; + +export default define(meta, async (ps, user) => { + const message = await MessagingMessages.findOne(ps.messageId); + + if (message == null) { + throw new ApiError(meta.errors.noSuchMessage); + } + + if (message.recipientId) { + await readUserMessagingMessage(user.id, message.userId, [message.id]).catch(e => { + if (e.id === 'e140a4bf-49ce-4fb6-b67c-b78dadf6b52f') throw new ApiError(meta.errors.noSuchMessage); + throw e; + }); + } else if (message.groupId) { + await readGroupMessagingMessage(user.id, message.groupId, [message.id]).catch(e => { + if (e.id === '930a270c-714a-46b2-b776-ad27276dc569') throw new ApiError(meta.errors.noSuchMessage); + throw e; + }); + } +}); diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts new file mode 100644 index 0000000000..ce21556243 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -0,0 +1,598 @@ +import $ from 'cafy'; +import config from '@/config/index'; +import define from '../define'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Ads, Emojis, Users } from '@/models/index'; +import { DB_MAX_NOTE_TEXT_LENGTH } from '@/misc/hard-limits'; +import { MoreThan } from 'typeorm'; + +export const meta = { + tags: ['meta'], + + requireCredential: false as const, + + params: { + detail: { + validator: $.optional.bool, + default: true + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + maintainerName: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + maintainerEmail: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + version: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: config.version + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + uri: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'url', + example: 'https://misskey.example.com' + }, + description: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + langs: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + tosUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + repositoryUrl: { + type: 'string' as const, + optional: false as const, nullable: false as const, + default: 'https://github.com/misskey-dev/misskey' + }, + feedbackUrl: { + type: 'string' as const, + optional: false as const, nullable: false as const, + default: 'https://github.com/misskey-dev/misskey/issues/new' + }, + secure: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + default: false + }, + disableRegistration: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + disableLocalTimeline: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + disableGlobalTimeline: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + driveCapacityPerLocalUserMb: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + driveCapacityPerRemoteUserMb: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + cacheRemoteFiles: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + proxyRemoteFiles: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + emailRequiredForSignup: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + enableHcaptcha: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + hcaptchaSiteKey: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + enableRecaptcha: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + recaptchaSiteKey: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + swPublickey: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + mascotImageUrl: { + type: 'string' as const, + optional: false as const, nullable: false as const, + default: '/assets/ai.png' + }, + bannerUrl: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + errorImageUrl: { + type: 'string' as const, + optional: false as const, nullable: false as const, + default: 'https://xn--931a.moe/aiart/yubitun.png' + }, + iconUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + maxNoteTextLength: { + type: 'number' as const, + optional: false as const, nullable: false as const, + default: 500 + }, + emojis: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + aliases: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + category: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + host: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + url: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'url' + } + } + } + }, + ads: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + place: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + url: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'url' + }, + imageUrl: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'url' + }, + } + } + }, + requireSetup: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + example: false + }, + enableEmail: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + enableTwitterIntegration: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + enableGithubIntegration: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + enableDiscordIntegration: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + enableServiceWorker: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + translatorAvailable: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + proxyAccountName: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + features: { + type: 'object' as const, + optional: true as const, nullable: false as const, + properties: { + registration: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + localTimeLine: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + globalTimeLine: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + elasticsearch: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + hcaptcha: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + recaptcha: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + objectStorage: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + twitter: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + github: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + discord: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + serviceWorker: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + miauth: { + type: 'boolean' as const, + optional: true as const, nullable: false as const, + default: true + }, + } + }, + userStarForReactionFallback: { + type: 'boolean' as const, + optional: true as const, nullable: false as const, + }, + pinnedUsers: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + hiddenTags: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + blockedHosts: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + hcaptchaSecretKey: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + recaptchaSecretKey: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + proxyAccountId: { + type: 'string' as const, + optional: true as const, nullable: true as const, + format: 'id' + }, + twitterConsumerKey: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + twitterConsumerSecret: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + githubClientId: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + githubClientSecret: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + discordClientId: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + discordClientSecret: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + summaryProxy: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + email: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + smtpSecure: { + type: 'boolean' as const, + optional: true as const, nullable: false as const + }, + smtpHost: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + smtpPort: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + smtpUser: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + smtpPass: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + swPrivateKey: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + useObjectStorage: { + type: 'boolean' as const, + optional: true as const, nullable: false as const + }, + objectStorageBaseUrl: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + objectStorageBucket: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + objectStoragePrefix: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + objectStorageEndpoint: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + objectStorageRegion: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + objectStoragePort: { + type: 'number' as const, + optional: true as const, nullable: true as const + }, + objectStorageAccessKey: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + objectStorageSecretKey: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + objectStorageUseSSL: { + type: 'boolean' as const, + optional: true as const, nullable: false as const + }, + objectStorageUseProxy: { + type: 'boolean' as const, + optional: true as const, nullable: false as const + }, + objectStorageSetPublicRead: { + type: 'boolean' as const, + optional: true as const, nullable: false as const + } + } + } +}; + +export default define(meta, async (ps, me) => { + const instance = await fetchMeta(true); + + const emojis = await Emojis.find({ + where: { + host: null + }, + order: { + category: 'ASC', + name: 'ASC' + }, + cache: { + id: 'meta_emojis', + milliseconds: 3600000 // 1 hour + } + }); + + const ads = await Ads.find({ + where: { + expiresAt: MoreThan(new Date()) + }, + }); + + const response: any = { + maintainerName: instance.maintainerName, + maintainerEmail: instance.maintainerEmail, + + version: config.version, + + name: instance.name, + uri: config.url, + description: instance.description, + langs: instance.langs, + tosUrl: instance.ToSUrl, + repositoryUrl: instance.repositoryUrl, + feedbackUrl: instance.feedbackUrl, + + secure: config.https != null, + + disableRegistration: instance.disableRegistration, + disableLocalTimeline: instance.disableLocalTimeline, + disableGlobalTimeline: instance.disableGlobalTimeline, + driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, + driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, + emailRequiredForSignup: instance.emailRequiredForSignup, + enableHcaptcha: instance.enableHcaptcha, + hcaptchaSiteKey: instance.hcaptchaSiteKey, + enableRecaptcha: instance.enableRecaptcha, + recaptchaSiteKey: instance.recaptchaSiteKey, + swPublickey: instance.swPublicKey, + mascotImageUrl: instance.mascotImageUrl, + bannerUrl: instance.bannerUrl, + errorImageUrl: instance.errorImageUrl, + iconUrl: instance.iconUrl, + backgroundImageUrl: instance.backgroundImageUrl, + logoImageUrl: instance.logoImageUrl, + maxNoteTextLength: Math.min(instance.maxNoteTextLength, DB_MAX_NOTE_TEXT_LENGTH), + emojis: await Emojis.packMany(emojis), + ads: ads.map(ad => ({ + id: ad.id, + url: ad.url, + place: ad.place, + ratio: ad.ratio, + imageUrl: ad.imageUrl, + })), + enableEmail: instance.enableEmail, + + enableTwitterIntegration: instance.enableTwitterIntegration, + enableGithubIntegration: instance.enableGithubIntegration, + enableDiscordIntegration: instance.enableDiscordIntegration, + + enableServiceWorker: instance.enableServiceWorker, + + translatorAvailable: instance.deeplAuthKey != null, + + ...(ps.detail ? { + pinnedPages: instance.pinnedPages, + pinnedClipId: instance.pinnedClipId, + cacheRemoteFiles: instance.cacheRemoteFiles, + proxyRemoteFiles: instance.proxyRemoteFiles, + requireSetup: (await Users.count({ + host: null, + })) === 0, + } : {}) + }; + + if (ps.detail) { + const proxyAccount = instance.proxyAccountId ? await Users.pack(instance.proxyAccountId).catch(() => null) : null; + + response.proxyAccountName = proxyAccount ? proxyAccount.username : null; + response.features = { + registration: !instance.disableRegistration, + localTimeLine: !instance.disableLocalTimeline, + globalTimeLine: !instance.disableGlobalTimeline, + emailRequiredForSignup: instance.emailRequiredForSignup, + elasticsearch: config.elasticsearch ? true : false, + hcaptcha: instance.enableHcaptcha, + recaptcha: instance.enableRecaptcha, + objectStorage: instance.useObjectStorage, + twitter: instance.enableTwitterIntegration, + github: instance.enableGithubIntegration, + discord: instance.enableDiscordIntegration, + serviceWorker: instance.enableServiceWorker, + miauth: true, + }; + + if (me && me.isAdmin) { + response.useStarForReactionFallback = instance.useStarForReactionFallback; + response.pinnedUsers = instance.pinnedUsers; + response.hiddenTags = instance.hiddenTags; + response.blockedHosts = instance.blockedHosts; + response.hcaptchaSecretKey = instance.hcaptchaSecretKey; + response.recaptchaSecretKey = instance.recaptchaSecretKey; + response.proxyAccountId = instance.proxyAccountId; + response.twitterConsumerKey = instance.twitterConsumerKey; + response.twitterConsumerSecret = instance.twitterConsumerSecret; + response.githubClientId = instance.githubClientId; + response.githubClientSecret = instance.githubClientSecret; + response.discordClientId = instance.discordClientId; + response.discordClientSecret = instance.discordClientSecret; + response.summalyProxy = instance.summalyProxy; + response.email = instance.email; + response.smtpSecure = instance.smtpSecure; + response.smtpHost = instance.smtpHost; + response.smtpPort = instance.smtpPort; + response.smtpUser = instance.smtpUser; + response.smtpPass = instance.smtpPass; + response.swPrivateKey = instance.swPrivateKey; + response.useObjectStorage = instance.useObjectStorage; + response.objectStorageBaseUrl = instance.objectStorageBaseUrl; + response.objectStorageBucket = instance.objectStorageBucket; + response.objectStoragePrefix = instance.objectStoragePrefix; + response.objectStorageEndpoint = instance.objectStorageEndpoint; + response.objectStorageRegion = instance.objectStorageRegion; + response.objectStoragePort = instance.objectStoragePort; + response.objectStorageAccessKey = instance.objectStorageAccessKey; + response.objectStorageSecretKey = instance.objectStorageSecretKey; + response.objectStorageUseSSL = instance.objectStorageUseSSL; + response.objectStorageUseProxy = instance.objectStorageUseProxy; + response.objectStorageSetPublicRead = instance.objectStorageSetPublicRead; + response.objectStorageS3ForcePathStyle = instance.objectStorageS3ForcePathStyle; + response.deeplAuthKey = instance.deeplAuthKey; + response.deeplIsPro = instance.deeplIsPro; + } + } + + return response; +}); diff --git a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts new file mode 100644 index 0000000000..321fa42fc9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts @@ -0,0 +1,72 @@ +import $ from 'cafy'; +import define from '../../define'; +import { AccessTokens } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { secureRndstr } from '@/misc/secure-rndstr'; + +export const meta = { + tags: ['auth'], + + requireCredential: true as const, + + secure: true, + + params: { + session: { + validator: $.nullable.str + }, + + name: { + validator: $.nullable.optional.str + }, + + description: { + validator: $.nullable.optional.str, + }, + + iconUrl: { + validator: $.nullable.optional.str, + }, + + permission: { + validator: $.arr($.str).unique(), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + token: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + } + } +}; + +export default define(meta, async (ps, user) => { + // Generate access token + const accessToken = secureRndstr(32, true); + + const now = new Date(); + + // Insert access token doc + await AccessTokens.insert({ + id: genId(), + createdAt: now, + lastUsedAt: now, + session: ps.session, + userId: user.id, + token: accessToken, + hash: accessToken, + name: ps.name, + description: ps.description, + iconUrl: ps.iconUrl, + permission: ps.permission, + }); + + return { + token: accessToken + }; +}); diff --git a/packages/backend/src/server/api/endpoints/mute/create.ts b/packages/backend/src/server/api/endpoints/mute/create.ts new file mode 100644 index 0000000000..3fc64d3eba --- /dev/null +++ b/packages/backend/src/server/api/endpoints/mute/create.ts @@ -0,0 +1,83 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { genId } from '@/misc/gen-id'; +import { Mutings, NoteWatchings } from '@/models/index'; +import { Muting } from '@/models/entities/muting'; +import { publishUserEvent } from '@/services/stream'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'write:mutes', + + params: { + userId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '6fef56f3-e765-4957-88e5-c6f65329b8a5' + }, + + muteeIsYourself: { + message: 'Mutee is yourself.', + code: 'MUTEE_IS_YOURSELF', + id: 'a4619cb2-5f23-484b-9301-94c903074e10' + }, + + alreadyMuting: { + message: 'You are already muting that user.', + code: 'ALREADY_MUTING', + id: '7e7359cb-160c-4956-b08f-4d1c653cd007' + }, + } +}; + +export default define(meta, async (ps, user) => { + const muter = user; + + // 自分自身 + if (user.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check if already muting + const exist = await Mutings.findOne({ + muterId: muter.id, + muteeId: mutee.id + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyMuting); + } + + // Create mute + await Mutings.insert({ + id: genId(), + createdAt: new Date(), + muterId: muter.id, + muteeId: mutee.id, + } as Muting); + + publishUserEvent(user.id, 'mute', mutee); + + NoteWatchings.delete({ + userId: muter.id, + noteUserId: mutee.id + }); +}); diff --git a/packages/backend/src/server/api/endpoints/mute/delete.ts b/packages/backend/src/server/api/endpoints/mute/delete.ts new file mode 100644 index 0000000000..3ffd1f4562 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/mute/delete.ts @@ -0,0 +1,73 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { Mutings } from '@/models/index'; +import { publishUserEvent } from '@/services/stream'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'write:mutes', + + params: { + userId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'b851d00b-8ab1-4a56-8b1b-e24187cb48ef' + }, + + muteeIsYourself: { + message: 'Mutee is yourself.', + code: 'MUTEE_IS_YOURSELF', + id: 'f428b029-6b39-4d48-a1d2-cc1ae6dd5cf9' + }, + + notMuting: { + message: 'You are not muting that user.', + code: 'NOT_MUTING', + id: '5467d020-daa9-4553-81e1-135c0c35a96d' + }, + } +}; + +export default define(meta, async (ps, user) => { + const muter = user; + + // Check if the mutee is yourself + if (user.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check not muting + const exist = await Mutings.findOne({ + muterId: muter.id, + muteeId: mutee.id + }); + + if (exist == null) { + throw new ApiError(meta.errors.notMuting); + } + + // Delete mute + await Mutings.delete({ + id: exist.id + }); + + publishUserEvent(user.id, 'unmute', mutee); +}); diff --git a/packages/backend/src/server/api/endpoints/mute/list.ts b/packages/backend/src/server/api/endpoints/mute/list.ts new file mode 100644 index 0000000000..ae4c3a719d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/mute/list.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Mutings } from '@/models/index'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'read:mutes', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 30 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Muting', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(Mutings.createQueryBuilder('muting'), ps.sinceId, ps.untilId) + .andWhere(`muting.muterId = :meId`, { meId: me.id }); + + const mutings = await query + .take(ps.limit!) + .getMany(); + + return await Mutings.packMany(mutings, me); +}); diff --git a/packages/backend/src/server/api/endpoints/my/apps.ts b/packages/backend/src/server/api/endpoints/my/apps.ts new file mode 100644 index 0000000000..d91562b62f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/my/apps.ts @@ -0,0 +1,86 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Apps } from '@/models/index'; + +export const meta = { + tags: ['account', 'app'], + + requireCredential: true as const, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + offset: { + validator: $.optional.num.min(0), + default: 0 + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + callbackUrl: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + permission: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + secret: { + type: 'string' as const, + optional: true as const, nullable: false as const + }, + isAuthorized: { + type: 'object' as const, + optional: true as const, nullable: false as const, + properties: { + appId: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + userId: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + } + } + } + } + } +}; + +export default define(meta, async (ps, user) => { + const query = { + userId: user.id + }; + + const apps = await Apps.find({ + where: query, + take: ps.limit!, + skip: ps.offset, + }); + + return await Promise.all(apps.map(app => Apps.pack(app, user, { + detail: true + }))); +}); diff --git a/packages/backend/src/server/api/endpoints/notes.ts b/packages/backend/src/server/api/endpoints/notes.ts new file mode 100644 index 0000000000..a3f6e187f2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes.ts @@ -0,0 +1,94 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../define'; +import { makePaginationQuery } from '../common/make-pagination-query'; +import { Notes } from '@/models/index'; + +export const meta = { + tags: ['notes'], + + params: { + local: { + validator: $.optional.bool, + }, + + reply: { + validator: $.optional.bool, + }, + + renote: { + validator: $.optional.bool, + }, + + withFiles: { + validator: $.optional.bool, + }, + + poll: { + validator: $.optional.bool, + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, +}; + +export default define(meta, async (ps) => { + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(`note.visibility = 'public'`) + .andWhere(`note.localOnly = FALSE`) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + if (ps.local) { + query.andWhere('note.userHost IS NULL'); + } + + if (ps.reply != undefined) { + query.andWhere(ps.reply ? 'note.replyId IS NOT NULL' : 'note.replyId IS NULL'); + } + + if (ps.renote != undefined) { + query.andWhere(ps.renote ? 'note.renoteId IS NOT NULL' : 'note.renoteId IS NULL'); + } + + if (ps.withFiles != undefined) { + query.andWhere(ps.withFiles ? `note.fileIds != '{}'` : `note.fileIds = '{}'`); + } + + if (ps.poll != undefined) { + query.andWhere(ps.poll ? 'note.hasPoll = TRUE' : 'note.hasPoll = FALSE'); + } + + // TODO + //if (bot != undefined) { + // query.isBot = bot; + //} + + const notes = await query.take(ps.limit!).getMany(); + + return await Notes.packMany(notes); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts new file mode 100644 index 0000000000..68881fda9e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -0,0 +1,72 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { Brackets } from 'typeorm'; +import { Notes } from '@/models/index'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes'], + + requireCredential: false as const, + + params: { + noteId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(new Brackets(qb => { qb + .where(`note.replyId = :noteId`, { noteId: ps.noteId }) + .orWhere(new Brackets(qb => { qb + .where(`note.renoteId = :noteId`, { noteId: ps.noteId }) + .andWhere(new Brackets(qb => { qb + .where(`note.text IS NOT NULL`) + .orWhere(`note.fileIds != '{}'`) + .orWhere(`note.hasPoll = TRUE`); + })); + })); + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + generateVisibilityQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + const notes = await query.take(ps.limit!).getMany(); + + return await Notes.packMany(notes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/clips.ts b/packages/backend/src/server/api/endpoints/notes/clips.ts new file mode 100644 index 0000000000..6b303d87ec --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/clips.ts @@ -0,0 +1,55 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ClipNotes, Clips } from '@/models/index'; +import { getNote } from '../../common/getters'; +import { ApiError } from '../../error'; +import { In } from 'typeorm'; + +export const meta = { + tags: ['clips', 'notes'], + + requireCredential: false as const, + + params: { + noteId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '47db1a1c-b0af-458d-8fb4-986e4efafe1e' + } + } +}; + +export default define(meta, async (ps, me) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const clipNotes = await ClipNotes.find({ + noteId: note.id, + }); + + const clips = await Clips.find({ + id: In(clipNotes.map(x => x.clipId)), + isPublic: true + }); + + return await Promise.all(clips.map(x => Clips.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/conversation.ts b/packages/backend/src/server/api/endpoints/notes/conversation.ts new file mode 100644 index 0000000000..0fe323ea00 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/conversation.ts @@ -0,0 +1,81 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getNote } from '../../common/getters'; +import { Note } from '@/models/entities/note'; +import { Notes } from '@/models/index'; + +export const meta = { + tags: ['notes'], + + requireCredential: false as const, + + params: { + noteId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + offset: { + validator: $.optional.num.min(0), + default: 0 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'e1035875-9551-45ec-afa8-1ded1fcb53c8' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const conversation: Note[] = []; + let i = 0; + + async function get(id: any) { + i++; + const p = await Notes.findOne(id); + if (p == null) return; + + if (i > ps.offset!) { + conversation.push(p); + } + + if (conversation.length == ps.limit!) { + return; + } + + if (p.replyId) { + await get(p.replyId); + } + } + + if (note.replyId) { + await get(note.replyId); + } + + return await Notes.packMany(conversation, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts new file mode 100644 index 0000000000..751673f955 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -0,0 +1,299 @@ +import $ from 'cafy'; +import * as ms from 'ms'; +import { length } from 'stringz'; +import create from '@/services/note/create'; +import define from '../../define'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { ApiError } from '../../error'; +import { ID } from '@/misc/cafy-id'; +import { User } from '@/models/entities/user'; +import { Users, DriveFiles, Notes, Channels, Blockings } from '@/models/index'; +import { DriveFile } from '@/models/entities/drive-file'; +import { Note } from '@/models/entities/note'; +import { DB_MAX_NOTE_TEXT_LENGTH } from '@/misc/hard-limits'; +import { noteVisibilities } from '../../../../types'; +import { Channel } from '@/models/entities/channel'; + +let maxNoteTextLength = 500; + +setInterval(() => { + fetchMeta().then(m => { + maxNoteTextLength = m.maxNoteTextLength; + }); +}, 3000); + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + limit: { + duration: ms('1hour'), + max: 300 + }, + + kind: 'write:notes', + + params: { + visibility: { + validator: $.optional.str.or(noteVisibilities as unknown as string[]), + default: 'public', + }, + + visibleUserIds: { + validator: $.optional.arr($.type(ID)).unique().min(0), + }, + + text: { + validator: $.optional.nullable.str.pipe(text => + text.trim() != '' + && length(text.trim()) <= maxNoteTextLength + && Array.from(text.trim()).length <= DB_MAX_NOTE_TEXT_LENGTH // DB limit + ), + default: null, + }, + + cw: { + validator: $.optional.nullable.str.pipe(Notes.validateCw), + }, + + viaMobile: { + validator: $.optional.bool, + default: false, + }, + + localOnly: { + validator: $.optional.bool, + default: false, + }, + + noExtractMentions: { + validator: $.optional.bool, + default: false, + }, + + noExtractHashtags: { + validator: $.optional.bool, + default: false, + }, + + noExtractEmojis: { + validator: $.optional.bool, + default: false, + }, + + fileIds: { + validator: $.optional.arr($.type(ID)).unique().range(1, 4), + }, + + mediaIds: { + validator: $.optional.arr($.type(ID)).unique().range(1, 4), + deprecated: true, + }, + + replyId: { + validator: $.optional.nullable.type(ID), + }, + + renoteId: { + validator: $.optional.nullable.type(ID), + }, + + channelId: { + validator: $.optional.nullable.type(ID), + }, + + poll: { + validator: $.optional.nullable.obj({ + choices: $.arr($.str) + .unique() + .range(2, 10) + .each(c => c.length > 0 && c.length < 50), + multiple: $.optional.bool, + expiresAt: $.optional.nullable.num.int(), + expiredAfter: $.optional.nullable.num.int().min(1) + }).strict(), + ref: 'poll' + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + createdNote: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + } + }, + + errors: { + noSuchRenoteTarget: { + message: 'No such renote target.', + code: 'NO_SUCH_RENOTE_TARGET', + id: 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4' + }, + + cannotReRenote: { + message: 'You can not Renote a pure Renote.', + code: 'CANNOT_RENOTE_TO_A_PURE_RENOTE', + id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a' + }, + + noSuchReplyTarget: { + message: 'No such reply target.', + code: 'NO_SUCH_REPLY_TARGET', + id: '749ee0f6-d3da-459a-bf02-282e2da4292c' + }, + + cannotReplyToPureRenote: { + message: 'You can not reply to a pure Renote.', + code: 'CANNOT_REPLY_TO_A_PURE_RENOTE', + id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15' + }, + + contentRequired: { + message: 'Content required. You need to set text, fileIds, renoteId or poll.', + code: 'CONTENT_REQUIRED', + id: '6f57e42b-c348-439b-bc45-993995cc515a' + }, + + cannotCreateAlreadyExpiredPoll: { + message: 'Poll is already expired.', + code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', + id: '04da457d-b083-4055-9082-955525eda5a5' + }, + + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb' + }, + + youHaveBeenBlocked: { + message: 'You have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3' + }, + } +}; + +export default define(meta, async (ps, user) => { + let visibleUsers: User[] = []; + if (ps.visibleUserIds) { + visibleUsers = (await Promise.all(ps.visibleUserIds.map(id => Users.findOne(id)))) + .filter(x => x != null) as User[]; + } + + let files: DriveFile[] = []; + const fileIds = ps.fileIds != null ? ps.fileIds : ps.mediaIds != null ? ps.mediaIds : null; + if (fileIds != null) { + files = (await Promise.all(fileIds.map(fileId => + DriveFiles.findOne({ + id: fileId, + userId: user.id + }) + ))).filter(file => file != null) as DriveFile[]; + } + + let renote: Note | undefined; + if (ps.renoteId != null) { + // Fetch renote to note + renote = await Notes.findOne(ps.renoteId); + + if (renote == null) { + throw new ApiError(meta.errors.noSuchRenoteTarget); + } else if (renote.renoteId && !renote.text && !renote.fileIds) { + throw new ApiError(meta.errors.cannotReRenote); + } + + // Check blocking + if (renote.userId !== user.id) { + const block = await Blockings.findOne({ + blockerId: renote.userId, + blockeeId: user.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + } + + let reply: Note | undefined; + if (ps.replyId != null) { + // Fetch reply + reply = await Notes.findOne(ps.replyId); + + if (reply == null) { + throw new ApiError(meta.errors.noSuchReplyTarget); + } + + // 返信対象が引用でないRenoteだったらエラー + if (reply.renoteId && !reply.text && !reply.fileIds) { + throw new ApiError(meta.errors.cannotReplyToPureRenote); + } + + // Check blocking + if (reply.userId !== user.id) { + const block = await Blockings.findOne({ + blockerId: reply.userId, + blockeeId: user.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + } + + if (ps.poll) { + if (typeof ps.poll.expiresAt === 'number') { + if (ps.poll.expiresAt < Date.now()) + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + } else if (typeof ps.poll.expiredAfter === 'number') { + ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter; + } + } + + // テキストが無いかつ添付ファイルが無いかつRenoteも無いかつ投票も無かったらエラー + if (!(ps.text || files.length || renote || ps.poll)) { + throw new ApiError(meta.errors.contentRequired); + } + + let channel: Channel | undefined; + if (ps.channelId != null) { + channel = await Channels.findOne(ps.channelId); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + } + + // 投稿を作成 + const note = await create(user, { + createdAt: new Date(), + files: files, + poll: ps.poll ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple || false, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null + } : undefined, + text: ps.text || undefined, + reply, + renote, + cw: ps.cw, + viaMobile: ps.viaMobile, + localOnly: ps.localOnly, + visibility: ps.visibility, + visibleUsers, + channel, + apMentions: ps.noExtractMentions ? [] : undefined, + apHashtags: ps.noExtractHashtags ? [] : undefined, + apEmojis: ps.noExtractEmojis ? [] : undefined, + }); + + return { + createdNote: await Notes.pack(note, user) + }; +}); diff --git a/packages/backend/src/server/api/endpoints/notes/delete.ts b/packages/backend/src/server/api/endpoints/notes/delete.ts new file mode 100644 index 0000000000..7163a2b9d2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/delete.ts @@ -0,0 +1,56 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import deleteNote from '@/services/note/delete'; +import define from '../../define'; +import * as ms from 'ms'; +import { getNote } from '../../common/getters'; +import { ApiError } from '../../error'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + kind: 'write:notes', + + limit: { + duration: ms('1hour'), + max: 300, + minInterval: ms('1sec') + }, + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '490be23f-8c1f-4796-819f-94cb4f9d1630' + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: 'fe8d7103-0ea8-4ec3-814d-f8b401dc69e9' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + if (!user.isAdmin && !user.isModerator && (note.userId !== user.id)) { + throw new ApiError(meta.errors.accessDenied); + } + + // この操作を行うのが投稿者とは限らない(例えばモデレーター)ため + await deleteNote(await Users.findOneOrFail(note.userId), note); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts new file mode 100644 index 0000000000..1bb25edd7f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts @@ -0,0 +1,61 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getNote } from '../../../common/getters'; +import { NoteFavorites } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['notes', 'favorites'], + + requireCredential: true as const, + + kind: 'write:favorites', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '6dd26674-e060-4816-909a-45ba3f4da458' + }, + + alreadyFavorited: { + message: 'The note has already been marked as a favorite.', + code: 'ALREADY_FAVORITED', + id: 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Get favoritee + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + // if already favorited + const exist = await NoteFavorites.findOne({ + noteId: note.id, + userId: user.id + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyFavorited); + } + + // Create favorite + await NoteFavorites.insert({ + id: genId(), + createdAt: new Date(), + noteId: note.id, + userId: user.id + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts b/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts new file mode 100644 index 0000000000..75eb9a359a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts @@ -0,0 +1,55 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getNote } from '../../../common/getters'; +import { NoteFavorites } from '@/models/index'; + +export const meta = { + tags: ['notes', 'favorites'], + + requireCredential: true as const, + + kind: 'write:favorites', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '80848a2c-398f-4343-baa9-df1d57696c56' + }, + + notFavorited: { + message: 'You have not marked that note a favorite.', + code: 'NOT_FAVORITED', + id: 'b625fc69-635e-45e9-86f4-dbefbef35af5' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Get favoritee + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + // if already favorited + const exist = await NoteFavorites.findOne({ + noteId: note.id, + userId: user.id + }); + + if (exist == null) { + throw new ApiError(meta.errors.notFavorited); + } + + // Delete favorite + await NoteFavorites.delete(exist.id); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts new file mode 100644 index 0000000000..8d33c0e73d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -0,0 +1,64 @@ +import $ from 'cafy'; +import define from '../../define'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { Notes } from '@/models/index'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes'], + + requireCredential: false as const, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + offset: { + validator: $.optional.num.min(0), + default: 0 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, +}; + +export default define(meta, async (ps, user) => { + const max = 30; + const day = 1000 * 60 * 60 * 24 * 3; // 3日前まで + + const query = Notes.createQueryBuilder('note') + .addSelect('note.score') + .where('note.userHost IS NULL') + .andWhere(`note.score > 0`) + .andWhere(`note.createdAt > :date`, { date: new Date(Date.now() - day) }) + .andWhere(`note.visibility = 'public'`) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + let notes = await query + .orderBy('note.score', 'DESC') + .take(max) + .getMany(); + + notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + notes = notes.slice(ps.offset, ps.offset + ps.limit); + + return await Notes.packMany(notes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts new file mode 100644 index 0000000000..5902c0415c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -0,0 +1,101 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { ApiError } from '../../error'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Notes } from '@/models/index'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { activeUsersChart } from '@/services/chart/index'; +import { generateRepliesQuery } from '../../common/generate-replies-query'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes'], + + params: { + withFiles: { + validator: $.optional.bool, + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + sinceDate: { + validator: $.optional.num + }, + + untilDate: { + validator: $.optional.num + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + gtlDisabled: { + message: 'Global timeline has been disabled.', + code: 'GTL_DISABLED', + id: '0332fc13-6ab2-4427-ae80-a9fadffd1a6b' + }, + } +}; + +export default define(meta, async (ps, user) => { + const m = await fetchMeta(); + if (m.disableGlobalTimeline) { + if (user == null || (!user.isAdmin && !user.isModerator)) { + throw new ApiError(meta.errors.gtlDisabled); + } + } + + //#region Construct query + const query = makePaginationQuery(Notes.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.visibility = \'public\'') + .andWhere('note.channelId IS NULL') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + generateRepliesQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateMutedNoteQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.take(ps.limit!).getMany(); + + process.nextTick(() => { + if (user) { + activeUsersChart.update(user); + } + }); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts new file mode 100644 index 0000000000..47f08f208b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -0,0 +1,158 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { ApiError } from '../../error'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Followings, Notes } from '@/models/index'; +import { Brackets } from 'typeorm'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { activeUsersChart } from '@/services/chart/index'; +import { generateRepliesQuery } from '../../common/generate-replies-query'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; +import { generateChannelQuery } from '../../common/generate-channel-query'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + sinceDate: { + validator: $.optional.num, + }, + + untilDate: { + validator: $.optional.num, + }, + + includeMyRenotes: { + validator: $.optional.bool, + default: true, + }, + + includeRenotedMyNotes: { + validator: $.optional.bool, + default: true, + }, + + includeLocalRenotes: { + validator: $.optional.bool, + default: true, + }, + + withFiles: { + validator: $.optional.bool, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + stlDisabled: { + message: 'Hybrid timeline has been disabled.', + code: 'STL_DISABLED', + id: '620763f4-f621-4533-ab33-0577a1a3c342' + }, + } +}; + +export default define(meta, async (ps, user) => { + const m = await fetchMeta(); + if (m.disableLocalTimeline && !user.isAdmin && !user.isModerator) { + throw new ApiError(meta.errors.stlDisabled); + } + + //#region Construct query + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: user.id }); + + const query = makePaginationQuery(Notes.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere(new Brackets(qb => { + qb.where(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: user.id }) + .orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .setParameters(followingQuery.getParameters()); + + generateChannelQuery(query, user); + generateRepliesQuery(query, user); + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateMutedNoteQuery(query, user); + generateBlockedUserQuery(query, user); + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.take(ps.limit!).getMany(); + + process.nextTick(() => { + if (user) { + activeUsersChart.update(user); + } + }); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts new file mode 100644 index 0000000000..f670d478bf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -0,0 +1,129 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { ApiError } from '../../error'; +import { Notes } from '@/models/index'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { activeUsersChart } from '@/services/chart/index'; +import { Brackets } from 'typeorm'; +import { generateRepliesQuery } from '../../common/generate-replies-query'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; +import { generateChannelQuery } from '../../common/generate-channel-query'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes'], + + params: { + withFiles: { + validator: $.optional.bool, + }, + + fileType: { + validator: $.optional.arr($.str), + }, + + excludeNsfw: { + validator: $.optional.bool, + default: false, + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + sinceDate: { + validator: $.optional.num, + }, + + untilDate: { + validator: $.optional.num, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + ltlDisabled: { + message: 'Local timeline has been disabled.', + code: 'LTL_DISABLED', + id: '45a6eb02-7695-4393-b023-dd3be9aaaefd' + }, + } +}; + +export default define(meta, async (ps, user) => { + const m = await fetchMeta(); + if (m.disableLocalTimeline) { + if (user == null || (!user.isAdmin && !user.isModerator)) { + throw new ApiError(meta.errors.ltlDisabled); + } + } + + //#region Construct query + const query = makePaginationQuery(Notes.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + generateChannelQuery(query, user); + generateRepliesQuery(query, user); + generateVisibilityQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateMutedNoteQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.fileType != null) { + query.andWhere('note.fileIds != \'{}\''); + query.andWhere(new Brackets(qb => { + for (const type of ps.fileType!) { + const i = ps.fileType!.indexOf(type); + qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type }); + } + })); + + if (ps.excludeNsfw) { + query.andWhere('note.cw IS NULL'); + query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); + } + } + //#endregion + + const timeline = await query.take(ps.limit!).getMany(); + + process.nextTick(() => { + if (user) { + activeUsersChart.update(user); + } + }); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts new file mode 100644 index 0000000000..ffaebd6c95 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -0,0 +1,88 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import read from '@/services/note/read'; +import { Notes, Followings } from '@/models/index'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Brackets } from 'typeorm'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; +import { generateMutedNoteThreadQuery } from '../../common/generate-muted-note-thread-query'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + params: { + following: { + validator: $.optional.bool, + default: false + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + visibility: { + validator: $.optional.str, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, +}; + +export default define(meta, async (ps, user) => { + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: user.id }); + + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(new Brackets(qb => { qb + .where(`'{"${user.id}"}' <@ note.mentions`) + .orWhere(`'{"${user.id}"}' <@ note.visibleUserIds`); + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateMutedNoteThreadQuery(query, user); + generateBlockedUserQuery(query, user); + + if (ps.visibility) { + query.andWhere('note.visibility = :visibility', { visibility: ps.visibility }); + } + + if (ps.following) { + query.andWhere(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: user.id }); + query.setParameters(followingQuery.getParameters()); + } + + const mentions = await query.take(ps.limit!).getMany(); + + read(user.id, mentions); + + return await Notes.packMany(mentions, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts new file mode 100644 index 0000000000..0763f0c8fd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts @@ -0,0 +1,77 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Polls, Mutings, Notes, PollVotes } from '@/models/index'; +import { Brackets, In } from 'typeorm'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + offset: { + validator: $.optional.num.min(0), + default: 0 + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note' + } + } +}; + +export default define(meta, async (ps, user) => { + const query = Polls.createQueryBuilder('poll') + .where('poll.userHost IS NULL') + .andWhere(`poll.userId != :meId`, { meId: user.id }) + .andWhere(`poll.noteVisibility = 'public'`) + .andWhere(new Brackets(qb => { qb + .where('poll.expiresAt IS NULL') + .orWhere('poll.expiresAt > :now', { now: new Date() }); + })); + + //#region exclude arleady voted polls + const votedQuery = PollVotes.createQueryBuilder('vote') + .select('vote.noteId') + .where('vote.userId = :meId', { meId: user.id }); + + query + .andWhere(`poll.noteId NOT IN (${ votedQuery.getQuery() })`); + + query.setParameters(votedQuery.getParameters()); + //#endregion + + //#region mute + const mutingQuery = Mutings.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: user.id }); + + query + .andWhere(`poll.userId NOT IN (${ mutingQuery.getQuery() })`); + + query.setParameters(mutingQuery.getParameters()); + //#endregion + + const polls = await query.take(ps.limit!).skip(ps.offset).getMany(); + + if (polls.length === 0) return []; + + const notes = await Notes.find({ + id: In(polls.map(poll => poll.noteId)) + }); + + return await Notes.packMany(notes, user, { + detail: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts new file mode 100644 index 0000000000..f670501385 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts @@ -0,0 +1,170 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { publishNoteStream } from '@/services/stream'; +import { createNotification } from '@/services/create-notification'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getNote } from '../../../common/getters'; +import { deliver } from '@/queue/index'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import renderVote from '@/remote/activitypub/renderer/vote'; +import { deliverQuestionUpdate } from '@/services/note/polls/update'; +import { PollVotes, NoteWatchings, Users, Polls, Blockings } from '@/models/index'; +import { Not } from 'typeorm'; +import { IRemoteUser } from '@/models/entities/user'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + kind: 'write:votes', + + params: { + noteId: { + validator: $.type(ID), + }, + + choice: { + validator: $.num + }, + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'ecafbd2e-c283-4d6d-aecb-1a0a33b75396' + }, + + noPoll: { + message: 'The note does not attach a poll.', + code: 'NO_POLL', + id: '5f979967-52d9-4314-a911-1c673727f92f' + }, + + invalidChoice: { + message: 'Choice ID is invalid.', + code: 'INVALID_CHOICE', + id: 'e0cc9a04-f2e8-41e4-a5f1-4127293260cc' + }, + + alreadyVoted: { + message: 'You have already voted.', + code: 'ALREADY_VOTED', + id: '0963fc77-efac-419b-9424-b391608dc6d8' + }, + + alreadyExpired: { + message: 'The poll is already expired.', + code: 'ALREADY_EXPIRED', + id: '1022a357-b085-4054-9083-8f8de358337e' + }, + + youHaveBeenBlocked: { + message: 'You cannot vote this poll because you have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: '85a5377e-b1e9-4617-b0b9-5bea73331e49' + }, + } +}; + +export default define(meta, async (ps, user) => { + const createdAt = new Date(); + + // Get votee + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + if (!note.hasPoll) { + throw new ApiError(meta.errors.noPoll); + } + + // Check blocking + if (note.userId !== user.id) { + const block = await Blockings.findOne({ + blockerId: note.userId, + blockeeId: user.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + + const poll = await Polls.findOneOrFail({ noteId: note.id }); + + if (poll.expiresAt && poll.expiresAt < createdAt) { + throw new ApiError(meta.errors.alreadyExpired); + } + + if (poll.choices[ps.choice] == null) { + throw new ApiError(meta.errors.invalidChoice); + } + + // if already voted + const exist = await PollVotes.find({ + noteId: note.id, + userId: user.id + }); + + if (exist.length) { + if (poll.multiple) { + if (exist.some(x => x.choice == ps.choice)) + throw new ApiError(meta.errors.alreadyVoted); + } else { + throw new ApiError(meta.errors.alreadyVoted); + } + } + + // Create vote + const vote = await PollVotes.insert({ + id: genId(), + createdAt, + noteId: note.id, + userId: user.id, + choice: ps.choice + }).then(x => PollVotes.findOneOrFail(x.identifiers[0])); + + // Increment votes count + const index = ps.choice + 1; // In SQL, array index is 1 based + await Polls.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`); + + publishNoteStream(note.id, 'pollVoted', { + choice: ps.choice, + userId: user.id + }); + + // Notify + createNotification(note.userId, 'pollVote', { + notifierId: user.id, + noteId: note.id, + choice: ps.choice + }); + + // Fetch watchers + NoteWatchings.find({ + noteId: note.id, + userId: Not(user.id), + }).then(watchers => { + for (const watcher of watchers) { + createNotification(watcher.userId, 'pollVote', { + notifierId: user.id, + noteId: note.id, + choice: ps.choice + }); + } + }); + + // リモート投票の場合リプライ送信 + if (note.userHost != null) { + const pollOwner = await Users.findOneOrFail(note.userId) as IRemoteUser; + + deliver(user, renderActivity(await renderVote(user, vote, note, poll, pollOwner)), pollOwner.inbox); + } + + // リモートフォロワーにUpdate配信 + deliverQuestionUpdate(note.id); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts new file mode 100644 index 0000000000..09dd6b600b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts @@ -0,0 +1,90 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { getNote } from '../../common/getters'; +import { ApiError } from '../../error'; +import { NoteReactions } from '@/models/index'; +import { DeepPartial } from 'typeorm'; +import { NoteReaction } from '@/models/entities/note-reaction'; + +export const meta = { + tags: ['notes', 'reactions'], + + requireCredential: false as const, + + params: { + noteId: { + validator: $.type(ID), + }, + + type: { + validator: $.optional.nullable.str, + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + offset: { + validator: $.optional.num, + default: 0 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'NoteReaction', + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '263fff3d-d0e1-4af4-bea7-8408059b451a' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const query = { + noteId: note.id + } as DeepPartial<NoteReaction>; + + if (ps.type) { + // ローカルリアクションはホスト名が . とされているが + // DB 上ではそうではないので、必要に応じて変換 + const suffix = '@.:'; + const type = ps.type.endsWith(suffix) ? ps.type.slice(0, ps.type.length - suffix.length) + ':' : ps.type; + query.reaction = type; + } + + const reactions = await NoteReactions.find({ + where: query, + take: ps.limit!, + skip: ps.offset, + order: { + id: -1 + } + }); + + return await Promise.all(reactions.map(reaction => NoteReactions.pack(reaction, user))); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts new file mode 100644 index 0000000000..24a73a8d4f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts @@ -0,0 +1,57 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import createReaction from '@/services/note/reaction/create'; +import define from '../../../define'; +import { getNote } from '../../../common/getters'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['reactions', 'notes'], + + requireCredential: true as const, + + kind: 'write:reactions', + + params: { + noteId: { + validator: $.type(ID), + }, + + reaction: { + validator: $.str, + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '033d0620-5bfe-4027-965d-980b0c85a3ea' + }, + + alreadyReacted: { + message: 'You are already reacting to that note.', + code: 'ALREADY_REACTED', + id: '71efcf98-86d6-4e2b-b2ad-9d032369366b' + }, + + youHaveBeenBlocked: { + message: 'You cannot react this note because you have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: '20ef5475-9f38-4e4c-bd33-de6d979498ec' + }, + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + await createReaction(user, note, ps.reaction).catch(e => { + if (e.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') throw new ApiError(meta.errors.alreadyReacted); + if (e.id === 'e70412a4-7197-4726-8e74-f3e0deb92aa7') throw new ApiError(meta.errors.youHaveBeenBlocked); + throw e; + }); + return; +}); diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts b/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts new file mode 100644 index 0000000000..69550f96de --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts @@ -0,0 +1,52 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import * as ms from 'ms'; +import deleteReaction from '@/services/note/reaction/delete'; +import { getNote } from '../../../common/getters'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['reactions', 'notes'], + + requireCredential: true as const, + + kind: 'write:reactions', + + limit: { + duration: ms('1hour'), + max: 60, + minInterval: ms('3sec') + }, + + params: { + noteId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '764d9fce-f9f2-4a0e-92b1-6ceac9a7ad37' + }, + + notReacted: { + message: 'You are not reacting to that note.', + code: 'NOT_REACTED', + id: '92f4426d-4196-4125-aa5b-02943e2ec8fc' + }, + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + await deleteReaction(user, note).catch(e => { + if (e.id === '60527ec9-b4cb-4a88-a6bd-32d3ad26817d') throw new ApiError(meta.errors.notReacted); + throw e; + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts new file mode 100644 index 0000000000..26bfc1657d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -0,0 +1,76 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { getNote } from '../../common/getters'; +import { ApiError } from '../../error'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Notes } from '@/models/index'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes'], + + requireCredential: false as const, + + params: { + noteId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '12908022-2e21-46cd-ba6a-3edaf6093f46' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(`note.renoteId = :renoteId`, { renoteId: note.id }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + generateVisibilityQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + const renotes = await query.take(ps.limit!).getMany(); + + return await Notes.packMany(renotes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts new file mode 100644 index 0000000000..0bb62413ae --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -0,0 +1,61 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Notes } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes'], + + requireCredential: false as const, + + params: { + noteId: { + validator: $.type(ID), + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.replyId = :replyId', { replyId: ps.noteId }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + generateVisibilityQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + const timeline = await query.take(ps.limit!).getMany(); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts new file mode 100644 index 0000000000..40e1499736 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -0,0 +1,134 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Notes } from '@/models/index'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { Brackets } from 'typeorm'; +import { safeForSql } from '@/misc/safe-for-sql'; +import { normalizeForSearch } from '@/misc/normalize-for-search'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes', 'hashtags'], + + params: { + tag: { + validator: $.optional.str, + }, + + query: { + validator: $.optional.arr($.arr($.str)), + }, + + reply: { + validator: $.optional.nullable.bool, + default: null, + }, + + renote: { + validator: $.optional.nullable.bool, + default: null, + }, + + withFiles: { + validator: $.optional.bool, + }, + + poll: { + validator: $.optional.nullable.bool, + default: null, + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + generateVisibilityQuery(query, me); + if (me) generateMutedUserQuery(query, me); + if (me) generateBlockedUserQuery(query, me); + + try { + if (ps.tag) { + if (!safeForSql(ps.tag)) throw 'Injection'; + query.andWhere(`'{"${normalizeForSearch(ps.tag)}"}' <@ note.tags`); + } else { + query.andWhere(new Brackets(qb => { + for (const tags of ps.query!) { + qb.orWhere(new Brackets(qb => { + for (const tag of tags) { + if (!safeForSql(tag)) throw 'Injection'; + qb.andWhere(`'{"${normalizeForSearch(tag)}"}' <@ note.tags`); + } + })); + } + })); + } + } catch (e) { + if (e === 'Injection') return []; + throw e; + } + + if (ps.reply != null) { + if (ps.reply) { + query.andWhere('note.replyId IS NOT NULL'); + } else { + query.andWhere('note.replyId IS NULL'); + } + } + + if (ps.renote != null) { + if (ps.renote) { + query.andWhere('note.renoteId IS NOT NULL'); + } else { + query.andWhere('note.renoteId IS NULL'); + } + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.poll != null) { + if (ps.poll) { + query.andWhere('note.hasPoll = TRUE'); + } else { + query.andWhere('note.hasPoll = FALSE'); + } + } + + // Search notes + const notes = await query.take(ps.limit!).getMany(); + + return await Notes.packMany(notes, me); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts new file mode 100644 index 0000000000..eb832a6b31 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/search.ts @@ -0,0 +1,152 @@ +import $ from 'cafy'; +import es from '../../../../db/elasticsearch'; +import define from '../../define'; +import { Notes } from '@/models/index'; +import { In } from 'typeorm'; +import { ID } from '@/misc/cafy-id'; +import config from '@/config/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes'], + + requireCredential: false as const, + + params: { + query: { + validator: $.str + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + host: { + validator: $.optional.nullable.str, + default: undefined + }, + + userId: { + validator: $.optional.nullable.type(ID), + default: null + }, + + channelId: { + validator: $.optional.nullable.type(ID), + default: null + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + } +}; + +export default define(meta, async (ps, me) => { + if (es == null) { + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId); + + if (ps.userId) { + query.andWhere('note.userId = :userId', { userId: ps.userId }); + } else if (ps.channelId) { + query.andWhere('note.channelId = :channelId', { channelId: ps.channelId }); + } + + query + .andWhere('note.text ILIKE :q', { q: `%${ps.query}%` }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + generateVisibilityQuery(query, me); + if (me) generateMutedUserQuery(query, me); + if (me) generateBlockedUserQuery(query, me); + + const notes = await query.take(ps.limit!).getMany(); + + return await Notes.packMany(notes, me); + } else { + const userQuery = ps.userId != null ? [{ + term: { + userId: ps.userId + } + }] : []; + + const hostQuery = ps.userId == null ? + ps.host === null ? [{ + bool: { + must_not: { + exists: { + field: 'userHost' + } + } + } + }] : ps.host !== undefined ? [{ + term: { + userHost: ps.host + } + }] : [] + : []; + + const result = await es.search({ + index: config.elasticsearch.index || 'misskey_note', + body: { + size: ps.limit!, + from: ps.offset, + query: { + bool: { + must: [{ + simple_query_string: { + fields: ['text'], + query: ps.query.toLowerCase(), + default_operator: 'and' + }, + }, ...hostQuery, ...userQuery] + } + }, + sort: [{ + _doc: 'desc' + }] + } + }); + + const hits = result.body.hits.hits.map((hit: any) => hit._id); + + if (hits.length === 0) return []; + + // Fetch found notes + const notes = await Notes.find({ + where: { + id: In(hits) + }, + order: { + id: -1 + } + }); + + return await Notes.packMany(notes, me); + } +}); diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts new file mode 100644 index 0000000000..fad63d6483 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/show.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { getNote } from '../../common/getters'; +import { ApiError } from '../../error'; +import { Notes } from '@/models/index'; + +export const meta = { + tags: ['notes'], + + requireCredential: false as const, + + params: { + noteId: { + validator: $.type(ID), + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + return await Notes.pack(note, user, { + detail: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/state.ts b/packages/backend/src/server/api/endpoints/notes/state.ts new file mode 100644 index 0000000000..b3913a5e79 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/state.ts @@ -0,0 +1,69 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { NoteFavorites, Notes, NoteThreadMutings, NoteWatchings } from '@/models/index'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + params: { + noteId: { + validator: $.type(ID), + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + isFavorited: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isWatching: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isMutedThread: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await Notes.findOneOrFail(ps.noteId); + + const [favorite, watching, threadMuting] = await Promise.all([ + NoteFavorites.count({ + where: { + userId: user.id, + noteId: note.id, + }, + take: 1 + }), + NoteWatchings.count({ + where: { + userId: user.id, + noteId: note.id, + }, + take: 1 + }), + NoteThreadMutings.count({ + where: { + userId: user.id, + threadId: note.threadId || note.id, + }, + take: 1 + }), + ]); + + return { + isFavorited: favorite !== 0, + isWatching: watching !== 0, + isMutedThread: threadMuting !== 0, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts new file mode 100644 index 0000000000..2010d54331 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { getNote } from '../../../common/getters'; +import { ApiError } from '../../../error'; +import { Notes, NoteThreadMutings } from '@/models'; +import { genId } from '@/misc/gen-id'; +import readNote from '@/services/note/read'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '5ff67ada-ed3b-2e71-8e87-a1a421e177d2' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const mutedNotes = await Notes.find({ + where: [{ + id: note.threadId || note.id, + }, { + threadId: note.threadId || note.id, + }], + }); + + await readNote(user.id, mutedNotes); + + await NoteThreadMutings.insert({ + id: genId(), + createdAt: new Date(), + threadId: note.threadId || note.id, + userId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts new file mode 100644 index 0000000000..05d5691870 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { getNote } from '../../../common/getters'; +import { ApiError } from '../../../error'; +import { NoteThreadMutings } from '@/models'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'bddd57ac-ceb3-b29d-4334-86ea5fae481a' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + await NoteThreadMutings.delete({ + threadId: note.threadId || note.id, + userId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts new file mode 100644 index 0000000000..1bd0e57d34 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -0,0 +1,150 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Notes, Followings } from '@/models/index'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { activeUsersChart } from '@/services/chart/index'; +import { Brackets } from 'typeorm'; +import { generateRepliesQuery } from '../../common/generate-replies-query'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; +import { generateChannelQuery } from '../../common/generate-channel-query'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + sinceDate: { + validator: $.optional.num, + }, + + untilDate: { + validator: $.optional.num, + }, + + includeMyRenotes: { + validator: $.optional.bool, + default: true, + }, + + includeRenotedMyNotes: { + validator: $.optional.bool, + default: true, + }, + + includeLocalRenotes: { + validator: $.optional.bool, + default: true, + }, + + withFiles: { + validator: $.optional.bool, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, +}; + +export default define(meta, async (ps, user) => { + const hasFollowing = (await Followings.count({ + where: { + followerId: user.id, + }, + take: 1 + })) !== 0; + + //#region Construct query + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: user.id }); + + const query = makePaginationQuery(Notes.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere(new Brackets(qb => { qb + .where('note.userId = :meId', { meId: user.id }); + if (hasFollowing) qb.orWhere(`note.userId IN (${ followingQuery.getQuery() })`); + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .setParameters(followingQuery.getParameters()); + + generateChannelQuery(query, user); + generateRepliesQuery(query, user); + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateMutedNoteQuery(query, user); + generateBlockedUserQuery(query, user); + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.take(ps.limit!).getMany(); + + process.nextTick(() => { + if (user) { + activeUsersChart.update(user); + } + }); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts new file mode 100644 index 0000000000..b56b1debdd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -0,0 +1,89 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { getNote } from '../../common/getters'; +import { ApiError } from '../../error'; +import fetch from 'node-fetch'; +import config from '@/config/index'; +import { getAgentByUrl } from '@/misc/fetch'; +import { URLSearchParams } from 'url'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Notes } from '@/models'; + +export const meta = { + tags: ['notes'], + + requireCredential: false as const, + + params: { + noteId: { + validator: $.type(ID), + }, + targetLang: { + validator: $.str, + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'bea9b03f-36e0-49c5-a4db-627a029f8971' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + if (!(await Notes.isVisibleForMe(note, user ? user.id : null))) { + return 204; // TODO: 良い感じのエラー返す + } + + if (note.text == null) { + return 204; + } + + const instance = await fetchMeta(); + + if (instance.deeplAuthKey == null) { + return 204; // TODO: 良い感じのエラー返す + } + + let targetLang = ps.targetLang; + if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; + + const params = new URLSearchParams(); + params.append('auth_key', instance.deeplAuthKey); + params.append('text', note.text); + params.append('target_lang', targetLang); + + const endpoint = instance.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; + + const res = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': config.userAgent, + Accept: 'application/json, */*' + }, + body: params, + timeout: 10000, + agent: getAgentByUrl, + }); + + const json = await res.json(); + + return { + sourceLang: json.translations[0].detected_source_language, + text: json.translations[0].text + }; +}); diff --git a/packages/backend/src/server/api/endpoints/notes/unrenote.ts b/packages/backend/src/server/api/endpoints/notes/unrenote.ts new file mode 100644 index 0000000000..dce43d9d9c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/unrenote.ts @@ -0,0 +1,52 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import deleteNote from '@/services/note/delete'; +import define from '../../define'; +import * as ms from 'ms'; +import { getNote } from '../../common/getters'; +import { ApiError } from '../../error'; +import { Notes, Users } from '@/models/index'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + kind: 'write:notes', + + limit: { + duration: ms('1hour'), + max: 300, + minInterval: ms('1sec') + }, + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'efd4a259-2442-496b-8dd7-b255aa1a160f' + }, + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const renotes = await Notes.find({ + userId: user.id, + renoteId: note.id + }); + + for (const note of renotes) { + deleteNote(await Users.findOneOrFail(user.id), note); + } +}); diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts new file mode 100644 index 0000000000..32c370004c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -0,0 +1,147 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { UserLists, UserListJoinings, Notes } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { activeUsersChart } from '@/services/chart/index'; +import { Brackets } from 'typeorm'; + +export const meta = { + tags: ['notes', 'lists'], + + requireCredential: true as const, + + params: { + listId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + sinceDate: { + validator: $.optional.num, + }, + + untilDate: { + validator: $.optional.num, + }, + + includeMyRenotes: { + validator: $.optional.bool, + default: true, + }, + + includeRenotedMyNotes: { + validator: $.optional.bool, + default: true, + }, + + includeLocalRenotes: { + validator: $.optional.bool, + default: true, + }, + + withFiles: { + validator: $.optional.bool, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + noSuchList: { + message: 'No such list.', + code: 'NO_SUCH_LIST', + id: '8fb1fbd5-e476-4c37-9fb0-43d55b63a2ff' + } + } +}; + +export default define(meta, async (ps, user) => { + const list = await UserLists.findOne({ + id: ps.listId, + userId: user.id + }); + + if (list == null) { + throw new ApiError(meta.errors.noSuchList); + } + + //#region Construct query + const listQuery = UserListJoinings.createQueryBuilder('joining') + .select('joining.userId') + .where('joining.userListId = :userListId', { userListId: list.id }); + + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(`note.userId IN (${ listQuery.getQuery() })`) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .setParameters(listQuery.getParameters()); + + generateVisibilityQuery(query, user); + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.take(ps.limit!).getMany(); + + activeUsersChart.update(user); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/watching/create.ts b/packages/backend/src/server/api/endpoints/notes/watching/create.ts new file mode 100644 index 0000000000..4d182d3715 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/watching/create.ts @@ -0,0 +1,37 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import watch from '@/services/note/watch'; +import { getNote } from '../../../common/getters'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'ea0e37a6-90a3-4f58-ba6b-c328ca206fc7' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + await watch(user.id, note); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/watching/delete.ts b/packages/backend/src/server/api/endpoints/notes/watching/delete.ts new file mode 100644 index 0000000000..dd58c52b57 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/watching/delete.ts @@ -0,0 +1,37 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import unwatch from '@/services/note/unwatch'; +import { getNote } from '../../../common/getters'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '09b3695c-f72c-4731-a428-7cff825fc82e' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + await unwatch(user.id, note); +}); diff --git a/packages/backend/src/server/api/endpoints/notifications/create.ts b/packages/backend/src/server/api/endpoints/notifications/create.ts new file mode 100644 index 0000000000..8003c497ee --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notifications/create.ts @@ -0,0 +1,37 @@ +import $ from 'cafy'; +import define from '../../define'; +import { createNotification } from '@/services/create-notification'; + +export const meta = { + tags: ['notifications'], + + requireCredential: true as const, + + kind: 'write:notifications', + + params: { + body: { + validator: $.str + }, + + header: { + validator: $.optional.nullable.str + }, + + icon: { + validator: $.optional.nullable.str + }, + }, + + errors: { + } +}; + +export default define(meta, async (ps, user, token) => { + createNotification(user.id, 'app', { + appAccessTokenId: token ? token.id : null, + customBody: ps.body, + customHeader: ps.header, + customIcon: ps.icon, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts new file mode 100644 index 0000000000..8d4e512750 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts @@ -0,0 +1,24 @@ +import { publishMainStream } from '@/services/stream'; +import define from '../../define'; +import { Notifications } from '@/models/index'; + +export const meta = { + tags: ['notifications', 'account'], + + requireCredential: true as const, + + kind: 'write:notifications' +}; + +export default define(meta, async (ps, user) => { + // Update documents + await Notifications.update({ + notifieeId: user.id, + isRead: false, + }, { + isRead: true + }); + + // 全ての通知を読みましたよというイベントを発行 + publishMainStream(user.id, 'readAllNotifications'); +}); diff --git a/packages/backend/src/server/api/endpoints/notifications/read.ts b/packages/backend/src/server/api/endpoints/notifications/read.ts new file mode 100644 index 0000000000..66bbc4efd7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notifications/read.ts @@ -0,0 +1,42 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { publishMainStream } from '@/services/stream'; +import define from '../../define'; +import { Notifications } from '@/models/index'; +import { readNotification } from '../../common/read-notification'; +import { ApiError } from '../../error'; + +export const meta = { + tags: ['notifications', 'account'], + + requireCredential: true as const, + + kind: 'write:notifications', + + params: { + notificationId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchNotification: { + message: 'No such notification.', + code: 'NO_SUCH_NOTIFICATION', + id: 'efa929d5-05b5-47d1-beec-e6a4dbed011e' + }, + }, +}; + +export default define(meta, async (ps, user) => { + const notification = await Notifications.findOne({ + notifieeId: user.id, + id: ps.notificationId, + }); + + if (notification == null) { + throw new ApiError(meta.errors.noSuchNotification); + } + + readNotification(user.id, [notification.id]); +}); diff --git a/packages/backend/src/server/api/endpoints/page-push.ts b/packages/backend/src/server/api/endpoints/page-push.ts new file mode 100644 index 0000000000..a0412e89f1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/page-push.ts @@ -0,0 +1,50 @@ +import $ from 'cafy'; +import define from '../define'; +import { ID } from '@/misc/cafy-id'; +import { publishMainStream } from '@/services/stream'; +import { Users, Pages } from '@/models/index'; +import { ApiError } from '../error'; + +export const meta = { + requireCredential: true as const, + secure: true, + + params: { + pageId: { + validator: $.type(ID) + }, + + event: { + validator: $.str + }, + + var: { + validator: $.optional.nullable.any + } + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: '4a13ad31-6729-46b4-b9af-e86b265c2e74' + } + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + publishMainStream(page.userId, 'pageEvent', { + pageId: ps.pageId, + event: ps.event, + var: ps.var, + userId: user.id, + user: await Users.pack(user.id, { id: page.userId }, { + detail: true + }) + }); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts new file mode 100644 index 0000000000..c23978f093 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/create.ts @@ -0,0 +1,128 @@ +import $ from 'cafy'; +import * as ms from 'ms'; +import define from '../../define'; +import { ID } from '@/misc/cafy-id'; +import { Pages, DriveFiles } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { Page } from '@/models/entities/page'; +import { ApiError } from '../../error'; + +export const meta = { + tags: ['pages'], + + requireCredential: true as const, + + kind: 'write:pages', + + limit: { + duration: ms('1hour'), + max: 300 + }, + + params: { + title: { + validator: $.str, + }, + + name: { + validator: $.str.min(1), + }, + + summary: { + validator: $.optional.nullable.str, + }, + + content: { + validator: $.arr($.obj()) + }, + + variables: { + validator: $.arr($.obj()) + }, + + script: { + validator: $.str, + }, + + eyeCatchingImageId: { + validator: $.optional.nullable.type(ID), + }, + + font: { + validator: $.optional.str.or(['serif', 'sans-serif']), + default: 'sans-serif' + }, + + alignCenter: { + validator: $.optional.bool, + default: false + }, + + hideTitleWhenPinned: { + validator: $.optional.bool, + default: false + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Page', + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'b7b97489-0f66-4b12-a5ff-b21bd63f6e1c' + }, + nameAlreadyExists: { + message: 'Specified name already exists.', + code: 'NAME_ALREADY_EXISTS', + id: '4650348e-301c-499a-83c9-6aa988c66bc1' + } + } +}; + +export default define(meta, async (ps, user) => { + let eyeCatchingImage = null; + if (ps.eyeCatchingImageId != null) { + eyeCatchingImage = await DriveFiles.findOne({ + id: ps.eyeCatchingImageId, + userId: user.id + }); + + if (eyeCatchingImage == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + await Pages.find({ + userId: user.id, + name: ps.name + }).then(result => { + if (result.length > 0) { + throw new ApiError(meta.errors.nameAlreadyExists); + } + }); + + const page = await Pages.save(new Page({ + id: genId(), + createdAt: new Date(), + updatedAt: new Date(), + title: ps.title, + name: ps.name, + summary: ps.summary, + content: ps.content, + variables: ps.variables, + script: ps.script, + eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null, + userId: user.id, + visibility: 'public', + alignCenter: ps.alignCenter, + hideTitleWhenPinned: ps.hideTitleWhenPinned, + font: ps.font + })); + + return await Pages.pack(page); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/delete.ts b/packages/backend/src/server/api/endpoints/pages/delete.ts new file mode 100644 index 0000000000..b1f8c8a709 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/delete.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages } from '@/models/index'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['pages'], + + requireCredential: true as const, + + kind: 'write:pages', + + params: { + pageId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: 'eb0c6e1d-d519-4764-9486-52a7e1c6392a' + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '8b741b3e-2c22-44b3-a15f-29949aa1601e' + }, + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + if (page.userId !== user.id) { + throw new ApiError(meta.errors.accessDenied); + } + + await Pages.delete(page.id); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/featured.ts b/packages/backend/src/server/api/endpoints/pages/featured.ts new file mode 100644 index 0000000000..f891c45f05 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/featured.ts @@ -0,0 +1,29 @@ +import define from '../../define'; +import { Pages } from '@/models/index'; + +export const meta = { + tags: ['pages'], + + requireCredential: false as const, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Page', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = Pages.createQueryBuilder('page') + .where('page.visibility = \'public\'') + .andWhere('page.likedCount > 0') + .orderBy('page.likedCount', 'DESC'); + + const pages = await query.take(10).getMany(); + + return await Pages.packMany(pages, me); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/like.ts b/packages/backend/src/server/api/endpoints/pages/like.ts new file mode 100644 index 0000000000..a95a377802 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/like.ts @@ -0,0 +1,71 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages, PageLikes } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['pages'], + + requireCredential: true as const, + + kind: 'write:page-likes', + + params: { + pageId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: 'cc98a8a2-0dc3-4123-b198-62c71df18ed3' + }, + + yourPage: { + message: 'You cannot like your page.', + code: 'YOUR_PAGE', + id: '28800466-e6db-40f2-8fae-bf9e82aa92b8' + }, + + alreadyLiked: { + message: 'The page has already been liked.', + code: 'ALREADY_LIKED', + id: 'cc98a8a2-0dc3-4123-b198-62c71df18ed3' + }, + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + if (page.userId === user.id) { + throw new ApiError(meta.errors.yourPage); + } + + // if already liked + const exist = await PageLikes.findOne({ + pageId: page.id, + userId: user.id + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyLiked); + } + + // Create like + await PageLikes.insert({ + id: genId(), + createdAt: new Date(), + pageId: page.id, + userId: user.id + }); + + Pages.increment({ id: page.id }, 'likedCount', 1); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/show.ts b/packages/backend/src/server/api/endpoints/pages/show.ts new file mode 100644 index 0000000000..7c55d4a9e6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/show.ts @@ -0,0 +1,65 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages, Users } from '@/models/index'; +import { ID } from '@/misc/cafy-id'; +import { Page } from '@/models/entities/page'; + +export const meta = { + tags: ['pages'], + + requireCredential: false as const, + + params: { + pageId: { + validator: $.optional.type(ID), + }, + + name: { + validator: $.optional.str, + }, + + username: { + validator: $.optional.str, + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Page', + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: '222120c0-3ead-4528-811b-b96f233388d7' + } + } +}; + +export default define(meta, async (ps, user) => { + let page: Page | undefined; + + if (ps.pageId) { + page = await Pages.findOne(ps.pageId); + } else if (ps.name && ps.username) { + const author = await Users.findOne({ + host: null, + usernameLower: ps.username.toLowerCase() + }); + if (author) { + page = await Pages.findOne({ + name: ps.name, + userId: author.id + }); + } + } + + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + return await Pages.pack(page, user); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/unlike.ts b/packages/backend/src/server/api/endpoints/pages/unlike.ts new file mode 100644 index 0000000000..facf2d6d5f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/unlike.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages, PageLikes } from '@/models/index'; + +export const meta = { + tags: ['pages'], + + requireCredential: true as const, + + kind: 'write:page-likes', + + params: { + pageId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: 'a0d41e20-1993-40bd-890e-f6e560ae648e' + }, + + notLiked: { + message: 'You have not liked that page.', + code: 'NOT_LIKED', + id: 'f5e586b0-ce93-4050-b0e3-7f31af5259ee' + }, + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + const exist = await PageLikes.findOne({ + pageId: page.id, + userId: user.id + }); + + if (exist == null) { + throw new ApiError(meta.errors.notLiked); + } + + // Delete like + await PageLikes.delete(exist.id); + + Pages.decrement({ id: page.id }, 'likedCount', 1); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts new file mode 100644 index 0000000000..b3a7f26963 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/update.ts @@ -0,0 +1,141 @@ +import $ from 'cafy'; +import * as ms from 'ms'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages, DriveFiles } from '@/models/index'; +import { ID } from '@/misc/cafy-id'; +import { Not } from 'typeorm'; + +export const meta = { + tags: ['pages'], + + requireCredential: true as const, + + kind: 'write:pages', + + limit: { + duration: ms('1hour'), + max: 300 + }, + + params: { + pageId: { + validator: $.type(ID), + }, + + title: { + validator: $.str, + }, + + name: { + validator: $.str.min(1), + }, + + summary: { + validator: $.optional.nullable.str, + }, + + content: { + validator: $.arr($.obj()) + }, + + variables: { + validator: $.arr($.obj()) + }, + + script: { + validator: $.str, + }, + + eyeCatchingImageId: { + validator: $.optional.nullable.type(ID), + }, + + font: { + validator: $.optional.str.or(['serif', 'sans-serif']), + }, + + alignCenter: { + validator: $.optional.bool, + }, + + hideTitleWhenPinned: { + validator: $.optional.bool, + }, + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: '21149b9e-3616-4778-9592-c4ce89f5a864' + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '3c15cd52-3b4b-4274-967d-6456fc4f792b' + }, + + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'cfc23c7c-3887-490e-af30-0ed576703c82' + }, + nameAlreadyExists: { + message: 'Specified name already exists.', + code: 'NAME_ALREADY_EXISTS', + id: '2298a392-d4a1-44c5-9ebb-ac1aeaa5a9ab' + } + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + if (page.userId !== user.id) { + throw new ApiError(meta.errors.accessDenied); + } + + let eyeCatchingImage = null; + if (ps.eyeCatchingImageId != null) { + eyeCatchingImage = await DriveFiles.findOne({ + id: ps.eyeCatchingImageId, + userId: user.id + }); + + if (eyeCatchingImage == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + await Pages.find({ + id: Not(ps.pageId), + userId: user.id, + name: ps.name + }).then(result => { + if (result.length > 0) { + throw new ApiError(meta.errors.nameAlreadyExists); + } + }); + + await Pages.update(page.id, { + updatedAt: new Date(), + title: ps.title, + name: ps.name === undefined ? page.name : ps.name, + summary: ps.name === undefined ? page.summary : ps.summary, + content: ps.content, + variables: ps.variables, + script: ps.script, + alignCenter: ps.alignCenter === undefined ? page.alignCenter : ps.alignCenter, + hideTitleWhenPinned: ps.hideTitleWhenPinned === undefined ? page.hideTitleWhenPinned : ps.hideTitleWhenPinned, + font: ps.font === undefined ? page.font : ps.font, + eyeCatchingImageId: ps.eyeCatchingImageId === null + ? null + : ps.eyeCatchingImageId === undefined + ? page.eyeCatchingImageId + : eyeCatchingImage!.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/ping.ts b/packages/backend/src/server/api/endpoints/ping.ts new file mode 100644 index 0000000000..0b1bb6e164 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/ping.ts @@ -0,0 +1,27 @@ +import define from '../define'; + +export const meta = { + requireCredential: false as const, + + tags: ['meta'], + + params: { + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + pong: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } + } +}; + +export default define(meta, async () => { + return { + pong: Date.now(), + }; +}); diff --git a/packages/backend/src/server/api/endpoints/pinned-users.ts b/packages/backend/src/server/api/endpoints/pinned-users.ts new file mode 100644 index 0000000000..39cf7b0df1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pinned-users.ts @@ -0,0 +1,32 @@ +import define from '../define'; +import { Users } from '@/models/index'; +import { fetchMeta } from '@/misc/fetch-meta'; +import * as Acct from 'misskey-js/built/acct'; +import { User } from '@/models/entities/user'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + } + }, +}; + +export default define(meta, async (ps, me) => { + const meta = await fetchMeta(); + + const users = await Promise.all(meta.pinnedUsers.map(acct => Users.findOne(Acct.parse(acct)))); + + return await Users.packMany(users.filter(x => x !== undefined) as User[], me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/promo/read.ts b/packages/backend/src/server/api/endpoints/promo/read.ts new file mode 100644 index 0000000000..ae57bf9cf1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/promo/read.ts @@ -0,0 +1,50 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getNote } from '../../common/getters'; +import { PromoReads } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'd785b897-fcd3-4fe9-8fc3-b85c26e6c932' + }, + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const exist = await PromoReads.findOne({ + noteId: note.id, + userId: user.id + }); + + if (exist != null) { + return; + } + + await PromoReads.insert({ + id: genId(), + createdAt: new Date(), + noteId: note.id, + userId: user.id + }); +}); diff --git a/packages/backend/src/server/api/endpoints/request-reset-password.ts b/packages/backend/src/server/api/endpoints/request-reset-password.ts new file mode 100644 index 0000000000..f9928c2ee6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/request-reset-password.ts @@ -0,0 +1,73 @@ +import $ from 'cafy'; +import { publishMainStream } from '@/services/stream'; +import define from '../define'; +import rndstr from 'rndstr'; +import config from '@/config/index'; +import * as ms from 'ms'; +import { Users, UserProfiles, PasswordResetRequests } from '@/models/index'; +import { sendEmail } from '@/services/send-email'; +import { ApiError } from '../error'; +import { genId } from '@/misc/gen-id'; +import { IsNull } from 'typeorm'; + +export const meta = { + requireCredential: false as const, + + limit: { + duration: ms('1hour'), + max: 3 + }, + + params: { + username: { + validator: $.str + }, + + email: { + validator: $.str + }, + }, + + errors: { + + } +}; + +export default define(meta, async (ps) => { + const user = await Users.findOne({ + usernameLower: ps.username.toLowerCase(), + host: IsNull() + }); + + // 合致するユーザーが登録されていなかったら無視 + if (user == null) { + return; + } + + const profile = await UserProfiles.findOneOrFail(user.id); + + // 合致するメアドが登録されていなかったら無視 + if (profile.email !== ps.email) { + return; + } + + // メアドが認証されていなかったら無視 + if (!profile.emailVerified) { + return; + } + + const token = rndstr('a-z0-9', 64); + + await PasswordResetRequests.insert({ + id: genId(), + createdAt: new Date(), + userId: profile.userId, + token + }); + + const link = `${config.url}/reset-password/${token}`; + + sendEmail(ps.email, 'Password reset requested', + `To reset password, please click this link:<br><a href="${link}">${link}</a>`, + `To reset password, please click this link: ${link}`); +}); diff --git a/packages/backend/src/server/api/endpoints/reset-db.ts b/packages/backend/src/server/api/endpoints/reset-db.ts new file mode 100644 index 0000000000..f0a9dae4ff --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reset-db.ts @@ -0,0 +1,23 @@ +import $ from 'cafy'; +import define from '../define'; +import { ApiError } from '../error'; +import { resetDb } from '@/db/postgre'; + +export const meta = { + requireCredential: false as const, + + params: { + }, + + errors: { + + } +}; + +export default define(meta, async (ps, user) => { + if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test'; + + await resetDb(); + + await new Promise(resolve => setTimeout(resolve, 1000)); +}); diff --git a/packages/backend/src/server/api/endpoints/reset-password.ts b/packages/backend/src/server/api/endpoints/reset-password.ts new file mode 100644 index 0000000000..53b0bfde0b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reset-password.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import { publishMainStream } from '@/services/stream'; +import define from '../define'; +import { Users, UserProfiles, PasswordResetRequests } from '@/models/index'; +import { ApiError } from '../error'; + +export const meta = { + requireCredential: false as const, + + params: { + token: { + validator: $.str + }, + + password: { + validator: $.str + } + }, + + errors: { + + } +}; + +export default define(meta, async (ps, user) => { + const req = await PasswordResetRequests.findOneOrFail({ + token: ps.token, + }); + + // 発行してから30分以上経過していたら無効 + if (Date.now() - req.createdAt.getTime() > 1000 * 60 * 30) { + throw new Error(); // TODO + } + + // 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 + }); + + PasswordResetRequests.delete(req.id); +}); diff --git a/packages/backend/src/server/api/endpoints/room/show.ts b/packages/backend/src/server/api/endpoints/room/show.ts new file mode 100644 index 0000000000..a6461d4a6e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/room/show.ts @@ -0,0 +1,159 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Users, UserProfiles } from '@/models/index'; +import { ID } from '@/misc/cafy-id'; +import { toPunyNullable } from '@/misc/convert-host'; + +export const meta = { + tags: ['room'], + + requireCredential: false as const, + + params: { + userId: { + validator: $.optional.type(ID), + }, + + username: { + validator: $.optional.str + }, + + host: { + validator: $.optional.nullable.str + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '7ad3fa3e-5e12-42f0-b23a-f3d13f10ee4b' + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + roomType: { + type: 'string' as const, + optional: false as const, nullable: false as const, + enum: ['default', 'washitsu'] + }, + furnitures: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + type: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + props: { + type: 'object' as const, + optional: true as const, nullable: false as const, + }, + position: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + x: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + y: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + z: { + type: 'number' as const, + optional: false as const, nullable: false as const + } + } + }, + rotation: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + x: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + y: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + z: { + type: 'number' as const, + optional: false as const, nullable: false as const + } + } + } + } + } + }, + carpetColor: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'hex', + example: '#85CAF0' + } + } + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId != null + ? { id: ps.userId } + : { usernameLower: ps.username!.toLowerCase(), host: toPunyNullable(ps.host) }); + + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const profile = await UserProfiles.findOneOrFail(user.id); + + if (profile.room.furnitures == null) { + await UserProfiles.update(user.id, { + room: { + furnitures: [], + ...profile.room + } + }); + + profile.room.furnitures = []; + } + + if (profile.room.roomType == null) { + const initialType = 'default'; + await UserProfiles.update(user.id, { + room: { + roomType: initialType as any, + ...profile.room + } + }); + + profile.room.roomType = initialType; + } + + if (profile.room.carpetColor == null) { + const initialColor = '#85CAF0'; + await UserProfiles.update(user.id, { + room: { + carpetColor: initialColor as any, + ...profile.room + } + }); + + profile.room.carpetColor = initialColor; + } + + return profile.room; +}); diff --git a/packages/backend/src/server/api/endpoints/room/update.ts b/packages/backend/src/server/api/endpoints/room/update.ts new file mode 100644 index 0000000000..8c4cfbdea6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/room/update.ts @@ -0,0 +1,51 @@ +import $ from 'cafy'; +import { publishMainStream } from '@/services/stream'; +import define from '../../define'; +import { Users, UserProfiles } from '@/models/index'; + +export const meta = { + tags: ['room'], + + requireCredential: true as const, + + params: { + room: { + validator: $.obj({ + furnitures: $.arr($.obj({ + id: $.str, + type: $.str, + position: $.obj({ + x: $.num, + y: $.num, + z: $.num, + }), + rotation: $.obj({ + x: $.num, + y: $.num, + z: $.num, + }), + props: $.optional.nullable.obj(), + })), + roomType: $.str, + carpetColor: $.str + }) + }, + }, +}; + +export default define(meta, async (ps, user) => { + await UserProfiles.update(user.id, { + room: ps.room as any + }); + + const iObj = await Users.pack(user.id, user, { + detail: true, + includeSecrets: true + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', iObj); + + // TODO: レスポンスがおかしいと思う by YuzuRyo61 + return iObj; +}); diff --git a/packages/backend/src/server/api/endpoints/server-info.ts b/packages/backend/src/server/api/endpoints/server-info.ts new file mode 100644 index 0000000000..4e636d331c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/server-info.ts @@ -0,0 +1,35 @@ +import * as os from 'os'; +import * as si from 'systeminformation'; +import define from '../define'; + +export const meta = { + requireCredential: false as const, + + desc: { + }, + + tags: ['meta'], + + params: { + }, +}; + +export default define(meta, async () => { + const memStats = await si.mem(); + const fsStats = await si.fsSize(); + + return { + machine: os.hostname(), + cpu: { + model: os.cpus()[0].model, + cores: os.cpus().length + }, + mem: { + total: memStats.total + }, + fs: { + total: fsStats[0].size, + used: fsStats[0].used, + }, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/stats.ts b/packages/backend/src/server/api/endpoints/stats.ts new file mode 100644 index 0000000000..15c8001742 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/stats.ts @@ -0,0 +1,83 @@ +import define from '../define'; +import { NoteReactions, Notes, Users } from '@/models/index'; +import { federationChart, driveChart } from '@/services/chart/index'; + +export const meta = { + requireCredential: false as const, + + tags: ['meta'], + + params: { + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + notesCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + originalNotesCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + usersCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + originalUsersCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + instances: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + driveUsageLocal: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + driveUsageRemote: { + type: 'number' as const, + optional: false as const, nullable: false as const + } + } + } +}; + +export default define(meta, async () => { + const [ + notesCount, + originalNotesCount, + usersCount, + originalUsersCount, + reactionsCount, + //originalReactionsCount, + instances, + driveUsageLocal, + driveUsageRemote + ] = await Promise.all([ + Notes.count({ cache: 3600000 }), // 1 hour + Notes.count({ where: { userHost: null }, cache: 3600000 }), + Users.count({ cache: 3600000 }), + Users.count({ where: { host: null }, cache: 3600000 }), + NoteReactions.count({ cache: 3600000 }), // 1 hour + //NoteReactions.count({ where: { userHost: null }, cache: 3600000 }), + federationChart.getChart('hour', 1, null).then(chart => chart.instance.total[0]), + driveChart.getChart('hour', 1, null).then(chart => chart.local.totalSize[0]), + driveChart.getChart('hour', 1, null).then(chart => chart.remote.totalSize[0]), + ]); + + return { + notesCount, + originalNotesCount, + usersCount, + originalUsersCount, + reactionsCount, + //originalReactionsCount, + instances, + driveUsageLocal, + driveUsageRemote + }; +}); diff --git a/packages/backend/src/server/api/endpoints/sw/register.ts b/packages/backend/src/server/api/endpoints/sw/register.ts new file mode 100644 index 0000000000..6e14ba2669 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/sw/register.ts @@ -0,0 +1,74 @@ +import $ from 'cafy'; +import define from '../../define'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { genId } from '@/misc/gen-id'; +import { SwSubscriptions } from '@/models/index'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + params: { + endpoint: { + validator: $.str + }, + + auth: { + validator: $.str + }, + + publickey: { + validator: $.str + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + state: { + type: 'string' as const, + optional: false as const, nullable: false as const, + enum: ['already-subscribed', 'subscribed'] + }, + key: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + } + } +}; + +export default define(meta, async (ps, user) => { + // if already subscribed + const exist = await SwSubscriptions.findOne({ + userId: user.id, + endpoint: ps.endpoint, + auth: ps.auth, + publickey: ps.publickey, + }); + + const instance = await fetchMeta(true); + + if (exist != null) { + return { + state: 'already-subscribed', + key: instance.swPublicKey + }; + } + + await SwSubscriptions.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + endpoint: ps.endpoint, + auth: ps.auth, + publickey: ps.publickey + }); + + return { + state: 'subscribed', + key: instance.swPublicKey + }; +}); diff --git a/packages/backend/src/server/api/endpoints/sw/unregister.ts b/packages/backend/src/server/api/endpoints/sw/unregister.ts new file mode 100644 index 0000000000..817ad1f517 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/sw/unregister.ts @@ -0,0 +1,22 @@ +import $ from 'cafy'; +import define from '../../define'; +import { SwSubscriptions } from '../../../../models'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + params: { + endpoint: { + validator: $.str + }, + } +}; + +export default define(meta, async (ps, user) => { + await SwSubscriptions.delete({ + userId: user.id, + endpoint: ps.endpoint, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/username/available.ts b/packages/backend/src/server/api/endpoints/username/available.ts new file mode 100644 index 0000000000..1ae75448ea --- /dev/null +++ b/packages/backend/src/server/api/endpoints/username/available.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Users, UsedUsernames } from '@/models/index'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + username: { + validator: $.use(Users.validateLocalUsername) + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + available: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + } + } + } +}; + +export default define(meta, async (ps) => { + // Get exist + const exist = await Users.count({ + host: null, + usernameLower: ps.username.toLowerCase() + }); + + const exist2 = await UsedUsernames.count({ username: ps.username.toLowerCase() }); + + return { + available: exist === 0 && exist2 === 0 + }; +}); diff --git a/packages/backend/src/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts new file mode 100644 index 0000000000..930dcc7616 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users.ts @@ -0,0 +1,101 @@ +import $ from 'cafy'; +import define from '../define'; +import { Users } from '@/models/index'; +import { generateMutedUserQueryForUsers } from '../common/generate-muted-user-query'; +import { generateBlockQueryForUsers } from '../common/generate-block-query'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + offset: { + validator: $.optional.num.min(0), + default: 0 + }, + + sort: { + validator: $.optional.str.or([ + '+follower', + '-follower', + '+createdAt', + '-createdAt', + '+updatedAt', + '-updatedAt', + ]), + }, + + state: { + validator: $.optional.str.or([ + 'all', + 'admin', + 'moderator', + 'adminOrModerator', + 'alive' + ]), + default: 'all' + }, + + origin: { + validator: $.optional.str.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'local' + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = Users.createQueryBuilder('user'); + query.where('user.isExplorable = TRUE'); + + switch (ps.state) { + case 'admin': query.andWhere('user.isAdmin = TRUE'); break; + case 'moderator': query.andWhere('user.isModerator = TRUE'); break; + case 'adminOrModerator': query.andWhere('user.isAdmin = TRUE OR user.isModerator = TRUE'); break; + case 'alive': query.andWhere('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break; + } + + switch (ps.origin) { + case 'local': query.andWhere('user.host IS NULL'); break; + case 'remote': query.andWhere('user.host IS NOT NULL'); break; + } + + switch (ps.sort) { + case '+follower': query.orderBy('user.followersCount', 'DESC'); break; + case '-follower': query.orderBy('user.followersCount', 'ASC'); break; + case '+createdAt': query.orderBy('user.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('user.createdAt', 'ASC'); break; + case '+updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'DESC'); break; + case '-updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'ASC'); break; + default: query.orderBy('user.id', 'ASC'); break; + } + + if (me) generateMutedUserQueryForUsers(query, me); + if (me) generateBlockQueryForUsers(query, me); + + query.take(ps.limit!); + query.skip(ps.offset); + + const users = await query.getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/clips.ts b/packages/backend/src/server/api/endpoints/users/clips.ts new file mode 100644 index 0000000000..8feca9422a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/clips.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Clips } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['users', 'clips'], + + params: { + userId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(Clips.createQueryBuilder('clip'), ps.sinceId, ps.untilId) + .andWhere(`clip.userId = :userId`, { userId: ps.userId }) + .andWhere('clip.isPublic = true'); + + const clips = await query + .take(ps.limit!) + .getMany(); + + return await Clips.packMany(clips); +}); diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts new file mode 100644 index 0000000000..6d042a2861 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/followers.ts @@ -0,0 +1,104 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Users, Followings, UserProfiles } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { toPunyNullable } from '@/misc/convert-host'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + userId: { + validator: $.optional.type(ID), + }, + + username: { + validator: $.optional.str + }, + + host: { + validator: $.optional.nullable.str + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Following', + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '27fa5435-88ab-43de-9360-387de88727cd' + }, + + forbidden: { + message: 'Forbidden.', + code: 'FORBIDDEN', + id: '3c6a84db-d619-26af-ca14-06232a21df8a' + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId != null + ? { id: ps.userId } + : { usernameLower: ps.username!.toLowerCase(), host: toPunyNullable(ps.host) }); + + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const profile = await UserProfiles.findOneOrFail(user.id); + + if (profile.ffVisibility === 'private') { + if (me == null || (me.id !== user.id)) { + throw new ApiError(meta.errors.forbidden); + } + } else if (profile.ffVisibility === 'followers') { + if (me == null) { + throw new ApiError(meta.errors.forbidden); + } else if (me.id !== user.id) { + const following = await Followings.findOne({ + followeeId: user.id, + followerId: me.id, + }); + if (following == null) { + throw new ApiError(meta.errors.forbidden); + } + } + } + + const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) + .andWhere(`following.followeeId = :userId`, { userId: user.id }) + .innerJoinAndSelect('following.follower', 'follower'); + + const followings = await query + .take(ps.limit!) + .getMany(); + + return await Followings.packMany(followings, me, { populateFollower: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts new file mode 100644 index 0000000000..1033117ef8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -0,0 +1,104 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Users, Followings, UserProfiles } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { toPunyNullable } from '@/misc/convert-host'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + userId: { + validator: $.optional.type(ID), + }, + + username: { + validator: $.optional.str + }, + + host: { + validator: $.optional.nullable.str + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Following', + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '63e4aba4-4156-4e53-be25-c9559e42d71b' + }, + + forbidden: { + message: 'Forbidden.', + code: 'FORBIDDEN', + id: 'f6cdb0df-c19f-ec5c-7dbb-0ba84a1f92ba' + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId != null + ? { id: ps.userId } + : { usernameLower: ps.username!.toLowerCase(), host: toPunyNullable(ps.host) }); + + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const profile = await UserProfiles.findOneOrFail(user.id); + + if (profile.ffVisibility === 'private') { + if (me == null || (me.id !== user.id)) { + throw new ApiError(meta.errors.forbidden); + } + } else if (profile.ffVisibility === 'followers') { + if (me == null) { + throw new ApiError(meta.errors.forbidden); + } else if (me.id !== user.id) { + const following = await Followings.findOne({ + followeeId: user.id, + followerId: me.id, + }); + if (following == null) { + throw new ApiError(meta.errors.forbidden); + } + } + } + + const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) + .andWhere(`following.followerId = :userId`, { userId: user.id }) + .innerJoinAndSelect('following.followee', 'followee'); + + const followings = await query + .take(ps.limit!) + .getMany(); + + return await Followings.packMany(followings, me, { populateFollowee: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/gallery/posts.ts b/packages/backend/src/server/api/endpoints/users/gallery/posts.ts new file mode 100644 index 0000000000..845de1089c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/gallery/posts.ts @@ -0,0 +1,39 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { GalleryPosts } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; + +export const meta = { + tags: ['users', 'gallery'], + + params: { + userId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId) + .andWhere(`post.userId = :userId`, { userId: ps.userId }); + + const posts = await query + .take(ps.limit!) + .getMany(); + + return await GalleryPosts.packMany(posts, user); +}); diff --git a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts new file mode 100644 index 0000000000..32ebfd683a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts @@ -0,0 +1,105 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { maximum } from '@/prelude/array'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { Not, In, IsNull } from 'typeorm'; +import { Notes, Users } from '@/models/index'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + userId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'e6965129-7b2a-40a4-bae2-cd84cd434822' + } + } +}; + +export default define(meta, async (ps, me) => { + // Lookup user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Fetch recent notes + const recentNotes = await Notes.find({ + where: { + userId: user.id, + replyId: Not(IsNull()) + }, + order: { + id: -1 + }, + take: 1000, + select: ['replyId'] + }); + + // 投稿が少なかったら中断 + if (recentNotes.length === 0) { + return []; + } + + // TODO ミュートを考慮 + const replyTargetNotes = await Notes.find({ + where: { + id: In(recentNotes.map(p => p.replyId)), + }, + select: ['userId'] + }); + + const repliedUsers: any = {}; + + // Extract replies from recent notes + for (const userId of replyTargetNotes.map(x => x.userId.toString())) { + if (repliedUsers[userId]) { + repliedUsers[userId]++; + } else { + repliedUsers[userId] = 1; + } + } + + // Calc peak + const peak = maximum(Object.values(repliedUsers)); + + // Sort replies by frequency + const repliedUsersSorted = Object.keys(repliedUsers).sort((a, b) => repliedUsers[b] - repliedUsers[a]); + + // Extract top replied users + const topRepliedUsers = repliedUsersSorted.slice(0, ps.limit!); + + // Make replies object (includes weights) + const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({ + user: await Users.pack(user, me, { detail: true }), + weight: repliedUsers[user] / peak + }))); + + return repliesObj; +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/create.ts b/packages/backend/src/server/api/endpoints/users/groups/create.ts new file mode 100644 index 0000000000..dc1ee3879e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/create.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { UserGroups, UserGroupJoinings } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { UserGroup } from '@/models/entities/user-group'; +import { UserGroupJoining } from '@/models/entities/user-group-joining'; + +export const meta = { + tags: ['groups'], + + requireCredential: true as const, + + kind: 'write:user-groups', + + params: { + name: { + validator: $.str.range(1, 100) + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserGroup', + }, +}; + +export default define(meta, async (ps, user) => { + const userGroup = await UserGroups.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + } as UserGroup).then(x => UserGroups.findOneOrFail(x.identifiers[0])); + + // Push the owner + await UserGroupJoinings.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + userGroupId: userGroup.id + } as UserGroupJoining); + + return await UserGroups.pack(userGroup); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/delete.ts b/packages/backend/src/server/api/endpoints/users/groups/delete.ts new file mode 100644 index 0000000000..7da1b4a273 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/delete.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { UserGroups } from '@/models/index'; + +export const meta = { + tags: ['groups'], + + requireCredential: true as const, + + kind: 'write:user-groups', + + params: { + groupId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '63dbd64c-cd77-413f-8e08-61781e210b38' + } + } +}; + +export default define(meta, async (ps, user) => { + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + userId: user.id + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + await UserGroups.delete(userGroup.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts b/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts new file mode 100644 index 0000000000..09e6ae2647 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../../define'; +import { ApiError } from '../../../../error'; +import { UserGroupJoinings, UserGroupInvitations } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { UserGroupJoining } from '@/models/entities/user-group-joining'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true as const, + + kind: 'write:user-groups', + + params: { + invitationId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchInvitation: { + message: 'No such invitation.', + code: 'NO_SUCH_INVITATION', + id: '98c11eca-c890-4f42-9806-c8c8303ebb5e' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Fetch the invitation + const invitation = await UserGroupInvitations.findOne({ + id: ps.invitationId, + }); + + if (invitation == null) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + if (invitation.userId !== user.id) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + // Push the user + await UserGroupJoinings.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + userGroupId: invitation.userGroupId + } as UserGroupJoining); + + UserGroupInvitations.delete(invitation.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts b/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts new file mode 100644 index 0000000000..741fcefb35 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts @@ -0,0 +1,44 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../../define'; +import { ApiError } from '../../../../error'; +import { UserGroupInvitations } from '@/models/index'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true as const, + + kind: 'write:user-groups', + + params: { + invitationId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchInvitation: { + message: 'No such invitation.', + code: 'NO_SUCH_INVITATION', + id: 'ad7471d4-2cd9-44b4-ac68-e7136b4ce656' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Fetch the invitation + const invitation = await UserGroupInvitations.findOne({ + id: ps.invitationId, + }); + + if (invitation == null) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + if (invitation.userId !== user.id) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + await UserGroupInvitations.delete(invitation.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/invite.ts b/packages/backend/src/server/api/endpoints/users/groups/invite.ts new file mode 100644 index 0000000000..f1ee8bf8b7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/invite.ts @@ -0,0 +1,102 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { UserGroups, UserGroupJoinings, UserGroupInvitations } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { UserGroupInvitation } from '@/models/entities/user-group-invitation'; +import { createNotification } from '@/services/create-notification'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true as const, + + kind: 'write:user-groups', + + params: { + groupId: { + validator: $.type(ID), + }, + + userId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '583f8bc0-8eee-4b78-9299-1e14fc91e409' + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'da52de61-002c-475b-90e1-ba64f9cf13a8' + }, + + alreadyAdded: { + message: 'That user has already been added to that group.', + code: 'ALREADY_ADDED', + id: '7e35c6a0-39b2-4488-aea6-6ee20bd5da2c' + }, + + alreadyInvited: { + message: 'That user has already been invited to that group.', + code: 'ALREADY_INVITED', + id: 'ee0f58b4-b529-4d13-b761-b9a3e69f97e6' + } + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + const joining = await UserGroupJoinings.findOne({ + userGroupId: userGroup.id, + userId: user.id + }); + + if (joining) { + throw new ApiError(meta.errors.alreadyAdded); + } + + const existInvitation = await UserGroupInvitations.findOne({ + userGroupId: userGroup.id, + userId: user.id + }); + + if (existInvitation) { + throw new ApiError(meta.errors.alreadyInvited); + } + + const invitation = await UserGroupInvitations.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + userGroupId: userGroup.id + } as UserGroupInvitation).then(x => UserGroupInvitations.findOneOrFail(x.identifiers[0])); + + // 通知を作成 + createNotification(user.id, 'groupInvited', { + notifierId: me.id, + userGroupInvitationId: invitation.id + }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/joined.ts b/packages/backend/src/server/api/endpoints/users/groups/joined.ts new file mode 100644 index 0000000000..d5e8fe4032 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/joined.ts @@ -0,0 +1,36 @@ +import define from '../../../define'; +import { UserGroups, UserGroupJoinings } from '@/models/index'; +import { Not, In } from 'typeorm'; + +export const meta = { + tags: ['groups', 'account'], + + requireCredential: true as const, + + kind: 'read:user-groups', + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserGroup', + } + }, +}; + +export default define(meta, async (ps, me) => { + const ownedGroups = await UserGroups.find({ + userId: me.id, + }); + + const joinings = await UserGroupJoinings.find({ + userId: me.id, + ...(ownedGroups.length > 0 ? { + userGroupId: Not(In(ownedGroups.map(x => x.id))) + } : {}) + }); + + return await Promise.all(joinings.map(x => UserGroups.pack(x.userGroupId))); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/leave.ts b/packages/backend/src/server/api/endpoints/users/groups/leave.ts new file mode 100644 index 0000000000..0e52f2abdf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/leave.ts @@ -0,0 +1,50 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { UserGroups, UserGroupJoinings } from '@/models/index'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true as const, + + kind: 'write:user-groups', + + params: { + groupId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '62780270-1f67-5dc0-daca-3eb510612e31' + }, + + youAreOwner: { + message: 'Your are the owner.', + code: 'YOU_ARE_OWNER', + id: 'b6d6e0c2-ef8a-9bb8-653d-79f4a3107c69' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + if (me.id === userGroup.userId) { + throw new ApiError(meta.errors.youAreOwner); + } + + await UserGroupJoinings.delete({ userGroupId: userGroup.id, userId: me.id }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/owned.ts b/packages/backend/src/server/api/endpoints/users/groups/owned.ts new file mode 100644 index 0000000000..17de370dbc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/owned.ts @@ -0,0 +1,28 @@ +import define from '../../../define'; +import { UserGroups } from '@/models/index'; + +export const meta = { + tags: ['groups', 'account'], + + requireCredential: true as const, + + kind: 'read:user-groups', + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserGroup', + } + }, +}; + +export default define(meta, async (ps, me) => { + const userGroups = await UserGroups.find({ + userId: me.id, + }); + + return await Promise.all(userGroups.map(x => UserGroups.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/pull.ts b/packages/backend/src/server/api/endpoints/users/groups/pull.ts new file mode 100644 index 0000000000..ce4d2e2881 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/pull.ts @@ -0,0 +1,69 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { UserGroups, UserGroupJoinings } from '@/models/index'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true as const, + + kind: 'write:user-groups', + + params: { + groupId: { + validator: $.type(ID), + }, + + userId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '4662487c-05b1-4b78-86e5-fd46998aba74' + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '0b5cc374-3681-41da-861e-8bc1146f7a55' + }, + + isOwner: { + message: 'The user is the owner.', + code: 'IS_OWNER', + id: '1546eed5-4414-4dea-81c1-b0aec4f6d2af' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + if (user.id === userGroup.userId) { + throw new ApiError(meta.errors.isOwner); + } + + // Pull the user + await UserGroupJoinings.delete({ userGroupId: userGroup.id, userId: user.id }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/show.ts b/packages/backend/src/server/api/endpoints/users/groups/show.ts new file mode 100644 index 0000000000..3c030bf3a5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/show.ts @@ -0,0 +1,55 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { UserGroups, UserGroupJoinings } from '@/models/index'; + +export const meta = { + tags: ['groups', 'account'], + + requireCredential: true as const, + + kind: 'read:user-groups', + + params: { + groupId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserGroup', + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: 'ea04751e-9b7e-487b-a509-330fb6bd6b9b' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + const joining = await UserGroupJoinings.findOne({ + userId: me.id, + userGroupId: userGroup.id + }); + + if (joining == null && userGroup.userId !== me.id) { + throw new ApiError(meta.errors.noSuchGroup); + } + + return await UserGroups.pack(userGroup); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/transfer.ts b/packages/backend/src/server/api/endpoints/users/groups/transfer.ts new file mode 100644 index 0000000000..17c42e1127 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/transfer.ts @@ -0,0 +1,83 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { UserGroups, UserGroupJoinings } from '@/models/index'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true as const, + + kind: 'write:user-groups', + + params: { + groupId: { + validator: $.type(ID), + }, + + userId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserGroup', + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '8e31d36b-2f88-4ccd-a438-e2d78a9162db' + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '711f7ebb-bbb9-4dfa-b540-b27809fed5e9' + }, + + noSuchGroupMember: { + message: 'No such group member.', + code: 'NO_SUCH_GROUP_MEMBER', + id: 'd31bebee-196d-42c2-9a3e-9474d4be6cc4' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + const joining = await UserGroupJoinings.findOne({ + userGroupId: userGroup.id, + userId: user.id + }); + + if (joining == null) { + throw new ApiError(meta.errors.noSuchGroupMember); + } + + await UserGroups.update(userGroup.id, { + userId: ps.userId + }); + + return await UserGroups.pack(userGroup.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/update.ts b/packages/backend/src/server/api/endpoints/users/groups/update.ts new file mode 100644 index 0000000000..127bbc47a1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/update.ts @@ -0,0 +1,55 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { UserGroups } from '@/models/index'; + +export const meta = { + tags: ['groups'], + + requireCredential: true as const, + + kind: 'write:user-groups', + + params: { + groupId: { + validator: $.type(ID), + }, + + name: { + validator: $.str.range(1, 100), + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserGroup', + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '9081cda3-7a9e-4fac-a6ce-908d70f282f6' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + userId: me.id + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + await UserGroups.update(userGroup.id, { + name: ps.name + }); + + return await UserGroups.pack(userGroup.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/create.ts b/packages/backend/src/server/api/endpoints/users/lists/create.ts new file mode 100644 index 0000000000..e0bfe611fc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/create.ts @@ -0,0 +1,36 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { UserLists } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { UserList } from '@/models/entities/user-list'; + +export const meta = { + tags: ['lists'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + name: { + validator: $.str.range(1, 100) + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserList', + }, +}; + +export default define(meta, async (ps, user) => { + const userList = await UserLists.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + } as UserList).then(x => UserLists.findOneOrFail(x.identifiers[0])); + + return await UserLists.pack(userList); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/delete.ts b/packages/backend/src/server/api/endpoints/users/lists/delete.ts new file mode 100644 index 0000000000..5fe3bfb03d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/delete.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { UserLists } from '@/models/index'; + +export const meta = { + tags: ['lists'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + listId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchList: { + message: 'No such list.', + code: 'NO_SUCH_LIST', + id: '78436795-db79-42f5-b1e2-55ea2cf19166' + } + } +}; + +export default define(meta, async (ps, user) => { + const userList = await UserLists.findOne({ + id: ps.listId, + userId: user.id + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + await UserLists.delete(userList.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/list.ts b/packages/backend/src/server/api/endpoints/users/lists/list.ts new file mode 100644 index 0000000000..cf0c92bb84 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/list.ts @@ -0,0 +1,28 @@ +import define from '../../../define'; +import { UserLists } from '@/models/index'; + +export const meta = { + tags: ['lists', 'account'], + + requireCredential: true as const, + + kind: 'read:account', + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserList', + } + }, +}; + +export default define(meta, async (ps, me) => { + const userLists = await UserLists.find({ + userId: me.id, + }); + + return await Promise.all(userLists.map(x => UserLists.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/pull.ts b/packages/backend/src/server/api/endpoints/users/lists/pull.ts new file mode 100644 index 0000000000..d4357fc5e7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/pull.ts @@ -0,0 +1,62 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { publishUserListStream } from '@/services/stream'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { UserLists, UserListJoinings, Users } from '@/models/index'; + +export const meta = { + tags: ['lists', 'users'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + listId: { + validator: $.type(ID), + }, + + userId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchList: { + message: 'No such list.', + code: 'NO_SUCH_LIST', + id: '7f44670e-ab16-43b8-b4c1-ccd2ee89cc02' + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '588e7f72-c744-4a61-b180-d354e912bda2' + } + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the list + const userList = await UserLists.findOne({ + id: ps.listId, + userId: me.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + // Fetch the user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Pull the user + await UserListJoinings.delete({ userListId: userList.id, userId: user.id }); + + publishUserListStream(userList.id, 'userRemoved', await Users.pack(user)); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts new file mode 100644 index 0000000000..8e21059d3d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts @@ -0,0 +1,92 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { pushUserToUserList } from '@/services/user-list/push'; +import { UserLists, UserListJoinings, Blockings } from '@/models/index'; + +export const meta = { + tags: ['lists', 'users'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + listId: { + validator: $.type(ID), + }, + + userId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchList: { + message: 'No such list.', + code: 'NO_SUCH_LIST', + id: '2214501d-ac96-4049-b717-91e42272a711' + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'a89abd3d-f0bc-4cce-beb1-2f446f4f1e6a' + }, + + alreadyAdded: { + message: 'That user has already been added to that list.', + code: 'ALREADY_ADDED', + id: '1de7c884-1595-49e9-857e-61f12f4d4fc5' + }, + + youHaveBeenBlocked: { + message: 'You cannot push this user because you have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: '990232c5-3f9d-4d83-9f3f-ef27b6332a4b' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the list + const userList = await UserLists.findOne({ + id: ps.listId, + userId: me.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + // Fetch the user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check blocking + if (user.id !== me.id) { + const block = await Blockings.findOne({ + blockerId: user.id, + blockeeId: me.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + + const exist = await UserListJoinings.findOne({ + userListId: userList.id, + userId: user.id + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyAdded); + } + + // Push the user + await pushUserToUserList(user, userList); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/show.ts b/packages/backend/src/server/api/endpoints/users/lists/show.ts new file mode 100644 index 0000000000..f9a35cdab3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/show.ts @@ -0,0 +1,47 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { UserLists } from '@/models/index'; + +export const meta = { + tags: ['lists', 'account'], + + requireCredential: true as const, + + kind: 'read:account', + + params: { + listId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserList', + }, + + errors: { + noSuchList: { + message: 'No such list.', + code: 'NO_SUCH_LIST', + id: '7bc05c21-1d7a-41ae-88f1-66820f4dc686' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the list + const userList = await UserLists.findOne({ + id: ps.listId, + userId: me.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + return await UserLists.pack(userList); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/update.ts b/packages/backend/src/server/api/endpoints/users/lists/update.ts new file mode 100644 index 0000000000..1185af5043 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/update.ts @@ -0,0 +1,55 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { UserLists } from '@/models/index'; + +export const meta = { + tags: ['lists'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + listId: { + validator: $.type(ID), + }, + + name: { + validator: $.str.range(1, 100), + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserList', + }, + + errors: { + noSuchList: { + message: 'No such list.', + code: 'NO_SUCH_LIST', + id: '796666fe-3dff-4d39-becb-8a5932c1d5b7' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Fetch the list + const userList = await UserLists.findOne({ + id: ps.listId, + userId: user.id + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + await UserLists.update(userList.id, { + name: ps.name + }); + + return await UserLists.pack(userList.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts new file mode 100644 index 0000000000..0afbad9d04 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -0,0 +1,144 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { Notes } from '@/models/index'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { Brackets } from 'typeorm'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['users', 'notes'], + + params: { + userId: { + validator: $.type(ID), + }, + + includeReplies: { + validator: $.optional.bool, + default: true, + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + sinceDate: { + validator: $.optional.num, + }, + + untilDate: { + validator: $.optional.num, + }, + + includeMyRenotes: { + validator: $.optional.bool, + default: true, + }, + + withFiles: { + validator: $.optional.bool, + default: false, + }, + + fileType: { + validator: $.optional.arr($.str), + }, + + excludeNsfw: { + validator: $.optional.bool, + default: false, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '27e494ba-2ac2-48e8-893b-10d4d8c2387b' + } + } +}; + +export default define(meta, async (ps, me) => { + // Lookup user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + //#region Construct query + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.userId = :userId', { userId: user.id }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + generateVisibilityQuery(query, me); + if (me) generateMutedUserQuery(query, me, user); + if (me) generateBlockedUserQuery(query, me); + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.fileType != null) { + query.andWhere('note.fileIds != \'{}\''); + query.andWhere(new Brackets(qb => { + for (const type of ps.fileType!) { + const i = ps.fileType!.indexOf(type); + qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type }); + } + })); + + if (ps.excludeNsfw) { + query.andWhere('note.cw IS NULL'); + query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); + } + } + + if (!ps.includeReplies) { + query.andWhere('note.replyId IS NULL'); + } + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :userId', { userId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + //#endregion + + const timeline = await query.take(ps.limit!).getMany(); + + return await Notes.packMany(timeline, me); +}); diff --git a/packages/backend/src/server/api/endpoints/users/pages.ts b/packages/backend/src/server/api/endpoints/users/pages.ts new file mode 100644 index 0000000000..24e9e207fd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/pages.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Pages } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['users', 'pages'], + + params: { + userId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(Pages.createQueryBuilder('page'), ps.sinceId, ps.untilId) + .andWhere(`page.userId = :userId`, { userId: ps.userId }) + .andWhere('page.visibility = \'public\''); + + const pages = await query + .take(ps.limit!) + .getMany(); + + return await Pages.packMany(pages); +}); diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts new file mode 100644 index 0000000000..fe5e4d84a9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/reactions.ts @@ -0,0 +1,79 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { NoteReactions, UserProfiles } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { ApiError } from '../../error'; + +export const meta = { + tags: ['users', 'reactions'], + + requireCredential: false as const, + + params: { + userId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + sinceDate: { + validator: $.optional.num, + }, + + untilDate: { + validator: $.optional.num, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'NoteReaction', + } + }, + + errors: { + reactionsNotPublic: { + message: 'Reactions of the user is not public.', + code: 'REACTIONS_NOT_PUBLIC', + id: '673a7dd2-6924-1093-e0c0-e68456ceae5c' + }, + } +}; + +export default define(meta, async (ps, me) => { + const profile = await UserProfiles.findOneOrFail(ps.userId); + + if (me == null || (me.id !== ps.userId && !profile.publicReactions)) { + throw new ApiError(meta.errors.reactionsNotPublic); + } + + const query = makePaginationQuery(NoteReactions.createQueryBuilder('reaction'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere(`reaction.userId = :userId`, { userId: ps.userId }) + .leftJoinAndSelect('reaction.note', 'note'); + + generateVisibilityQuery(query, me); + + const reactions = await query + .take(ps.limit!) + .getMany(); + + return await Promise.all(reactions.map(reaction => NoteReactions.pack(reaction, me, { withNote: true }))); +}); diff --git a/packages/backend/src/server/api/endpoints/users/recommendation.ts b/packages/backend/src/server/api/endpoints/users/recommendation.ts new file mode 100644 index 0000000000..dde6bb1037 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/recommendation.ts @@ -0,0 +1,63 @@ +import * as ms from 'ms'; +import $ from 'cafy'; +import define from '../../define'; +import { Users, Followings } from '@/models/index'; +import { generateMutedUserQueryForUsers } from '../../common/generate-muted-user-query'; +import { generateBlockedUserQuery, generateBlockQueryForUsers } from '../../common/generate-block-query'; + +export const meta = { + tags: ['users'], + + requireCredential: true as const, + + kind: 'read:account', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + offset: { + validator: $.optional.num.min(0), + default: 0 + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = Users.createQueryBuilder('user') + .where('user.isLocked = FALSE') + .andWhere('user.isExplorable = TRUE') + .andWhere('user.host IS NULL') + .andWhere('user.updatedAt >= :date', { date: new Date(Date.now() - ms('7days')) }) + .andWhere('user.id != :meId', { meId: me.id }) + .orderBy('user.followersCount', 'DESC'); + + generateMutedUserQueryForUsers(query, me); + generateBlockQueryForUsers(query, me); + generateBlockedUserQuery(query, me); + + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); + + query + .andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`); + + query.setParameters(followingQuery.getParameters()); + + const users = await query.take(ps.limit!).skip(ps.offset).getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts new file mode 100644 index 0000000000..32d76a5322 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/relation.ts @@ -0,0 +1,111 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ID } from '@/misc/cafy-id'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['users'], + + requireCredential: true as const, + + params: { + userId: { + validator: $.either($.type(ID), $.arr($.type(ID)).unique()), + } + }, + + res: { + oneOf: [ + { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + isFollowing: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + hasPendingFollowRequestFromYou: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + hasPendingFollowRequestToYou: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isFollowed: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isBlocking: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isBlocked: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isMuted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + } + } + }, + { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + isFollowing: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + hasPendingFollowRequestFromYou: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + hasPendingFollowRequestToYou: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isFollowed: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isBlocking: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isBlocked: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isMuted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + } + } + } + } + ] + } +}; + +export default define(meta, async (ps, me) => { + const ids = Array.isArray(ps.userId) ? ps.userId : [ps.userId]; + + const relations = await Promise.all(ids.map(id => Users.getRelation(me.id, id))); + + return Array.isArray(ps.userId) ? relations : relations[0]; +}); diff --git a/packages/backend/src/server/api/endpoints/users/report-abuse.ts b/packages/backend/src/server/api/endpoints/users/report-abuse.ts new file mode 100644 index 0000000000..2c8672cd47 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/report-abuse.ts @@ -0,0 +1,90 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { publishAdminStream } from '@/services/stream'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { AbuseUserReports, Users } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['users'], + + requireCredential: true as const, + + params: { + userId: { + validator: $.type(ID), + }, + + comment: { + validator: $.str.range(1, 2048), + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '1acefcb5-0959-43fd-9685-b48305736cb5' + }, + + cannotReportYourself: { + message: 'Cannot report yourself.', + code: 'CANNOT_REPORT_YOURSELF', + id: '1e13149e-b1e8-43cf-902e-c01dbfcb202f' + }, + + cannotReportAdmin: { + message: 'Cannot report the admin.', + code: 'CANNOT_REPORT_THE_ADMIN', + id: '35e166f5-05fb-4f87-a2d5-adb42676d48f' + } + } +}; + +export default define(meta, async (ps, me) => { + // Lookup user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + if (user.id === me.id) { + throw new ApiError(meta.errors.cannotReportYourself); + } + + if (user.isAdmin) { + throw new ApiError(meta.errors.cannotReportAdmin); + } + + const report = await AbuseUserReports.save({ + id: genId(), + createdAt: new Date(), + targetUserId: user.id, + targetUserHost: user.host, + reporterId: me.id, + reporterHost: null, + comment: ps.comment, + }); + + // Publish event to moderators + setTimeout(async () => { + const moderators = await Users.find({ + where: [{ + isAdmin: true + }, { + isModerator: true + }] + }); + + for (const moderator of moderators) { + publishAdminStream(moderator.id, 'newAbuseUserReport', { + id: report.id, + targetUserId: report.targetUserId, + reporterId: report.reporterId, + comment: report.comment + }); + } + }, 1); +}); diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts new file mode 100644 index 0000000000..1ec5e1a743 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts @@ -0,0 +1,116 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Followings, Users } from '@/models/index'; +import { Brackets } from 'typeorm'; +import { USER_ACTIVE_THRESHOLD } from '@/const'; +import { User } from '@/models/entities/user'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + username: { + validator: $.optional.nullable.str, + }, + + host: { + validator: $.optional.nullable.str, + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + detail: { + validator: $.optional.bool, + default: true, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + } + }, +}; + +export default define(meta, async (ps, me) => { + const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 + + if (ps.host) { + const q = Users.createQueryBuilder('user') + .where('user.isSuspended = FALSE') + .andWhere('user.host LIKE :host', { host: ps.host.toLowerCase() + '%' }); + + if (ps.username) { + q.andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }); + } + + q.andWhere('user.updatedAt IS NOT NULL'); + q.orderBy('user.updatedAt', 'DESC'); + + const users = await q.take(ps.limit!).getMany(); + + return await Users.packMany(users, me, { detail: ps.detail }); + } else if (ps.username) { + let users: User[] = []; + + if (me) { + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); + + const query = Users.createQueryBuilder('user') + .where(`user.id IN (${ followingQuery.getQuery() })`) + .andWhere(`user.id != :meId`, { meId: me.id }) + .andWhere('user.isSuspended = FALSE') + .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })); + + query.setParameters(followingQuery.getParameters()); + + users = await query + .orderBy('user.usernameLower', 'ASC') + .take(ps.limit!) + .getMany(); + + if (users.length < ps.limit!) { + const otherQuery = await Users.createQueryBuilder('user') + .where(`user.id NOT IN (${ followingQuery.getQuery() })`) + .andWhere(`user.id != :meId`, { meId: me.id }) + .andWhere('user.isSuspended = FALSE') + .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) + .andWhere('user.updatedAt IS NOT NULL'); + + otherQuery.setParameters(followingQuery.getParameters()); + + const otherUsers = await otherQuery + .orderBy('user.updatedAt', 'DESC') + .take(ps.limit! - users.length) + .getMany(); + + users = users.concat(otherUsers); + } + } else { + users = await Users.createQueryBuilder('user') + .where('user.isSuspended = FALSE') + .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) + .andWhere('user.updatedAt IS NOT NULL') + .orderBy('user.updatedAt', 'DESC') + .take(ps.limit! - users.length) + .getMany(); + } + + return await Users.packMany(users, me, { detail: ps.detail }); + } +}); diff --git a/packages/backend/src/server/api/endpoints/users/search.ts b/packages/backend/src/server/api/endpoints/users/search.ts new file mode 100644 index 0000000000..9aa988d9ed --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/search.ts @@ -0,0 +1,127 @@ +import $ from 'cafy'; +import define from '../../define'; +import { UserProfiles, Users } from '@/models/index'; +import { User } from '@/models/entities/user'; +import { Brackets } from 'typeorm'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + query: { + validator: $.str, + }, + + offset: { + validator: $.optional.num.min(0), + default: 0, + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + origin: { + validator: $.optional.str.or(['local', 'remote', 'combined']), + default: 'combined', + }, + + detail: { + validator: $.optional.bool, + default: true, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + } + }, +}; + +export default define(meta, async (ps, me) => { + const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 + + const isUsername = ps.query.startsWith('@'); + + let users: User[] = []; + + if (isUsername) { + const usernameQuery = Users.createQueryBuilder('user') + .where('user.usernameLower LIKE :username', { username: ps.query.replace('@', '').toLowerCase() + '%' }) + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE'); + + if (ps.origin === 'local') { + usernameQuery.andWhere('user.host IS NULL'); + } else if (ps.origin === 'remote') { + usernameQuery.andWhere('user.host IS NOT NULL'); + } + + users = await usernameQuery + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .take(ps.limit!) + .skip(ps.offset) + .getMany(); + } else { + const nameQuery = Users.createQueryBuilder('user') + .where('user.name ILIKE :query', { query: '%' + ps.query + '%' }) + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE'); + + if (ps.origin === 'local') { + nameQuery.andWhere('user.host IS NULL'); + } else if (ps.origin === 'remote') { + nameQuery.andWhere('user.host IS NOT NULL'); + } + + users = await nameQuery + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .take(ps.limit!) + .skip(ps.offset) + .getMany(); + + if (users.length < ps.limit!) { + const profQuery = UserProfiles.createQueryBuilder('prof') + .select('prof.userId') + .where('prof.description ILIKE :query', { query: '%' + ps.query + '%' }); + + if (ps.origin === 'local') { + profQuery.andWhere('prof.userHost IS NULL'); + } else if (ps.origin === 'remote') { + profQuery.andWhere('prof.userHost IS NOT NULL'); + } + + const query = Users.createQueryBuilder('user') + .where(`user.id IN (${ profQuery.getQuery() })`) + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE') + .setParameters(profQuery.getParameters()); + + users = users.concat(await query + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .take(ps.limit!) + .skip(ps.offset) + .getMany() + ); + } + } + + return await Users.packMany(users, me, { detail: ps.detail }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts new file mode 100644 index 0000000000..f056983636 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -0,0 +1,105 @@ +import $ from 'cafy'; +import { resolveUser } from '@/remote/resolve-user'; +import define from '../../define'; +import { apiLogger } from '../../logger'; +import { ApiError } from '../../error'; +import { ID } from '@/misc/cafy-id'; +import { Users } from '@/models/index'; +import { In } from 'typeorm'; +import { User } from '@/models/entities/user'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + userId: { + validator: $.optional.type(ID), + }, + + userIds: { + validator: $.optional.arr($.type(ID)).unique(), + }, + + username: { + validator: $.optional.str + }, + + host: { + validator: $.optional.nullable.str + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + }, + + errors: { + failedToResolveRemoteUser: { + message: 'Failed to resolve remote user.', + code: 'FAILED_TO_RESOLVE_REMOTE_USER', + id: 'ef7b9be4-9cba-4e6f-ab41-90ed171c7d3c', + kind: 'server' as const + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '4362f8dc-731f-4ad8-a694-be5a88922a24' + }, + } +}; + +export default define(meta, async (ps, me) => { + let user; + + const isAdminOrModerator = me && (me.isAdmin || me.isModerator); + + if (ps.userIds) { + if (ps.userIds.length === 0) { + return []; + } + + const users = await Users.find(isAdminOrModerator ? { + id: In(ps.userIds) + } : { + id: In(ps.userIds), + isSuspended: false + }); + + // リクエストされた通りに並べ替え + const _users: User[] = []; + for (const id of ps.userIds) { + _users.push(users.find(x => x.id === id)!); + } + + return await Promise.all(_users.map(u => Users.pack(u, me, { + detail: true + }))); + } else { + // Lookup user + if (typeof ps.host === 'string' && typeof ps.username === 'string') { + user = await resolveUser(ps.username, ps.host).catch(e => { + apiLogger.warn(`failed to resolve remote user: ${e}`); + throw new ApiError(meta.errors.failedToResolveRemoteUser); + }); + } else { + const q: any = ps.userId != null + ? { id: ps.userId } + : { usernameLower: ps.username!.toLowerCase(), host: null }; + + user = await Users.findOne(q); + } + + if (user == null || (!isAdminOrModerator && user.isSuspended)) { + throw new ApiError(meta.errors.noSuchUser); + } + + return await Users.pack(user, me, { + detail: true + }); + } +}); diff --git a/packages/backend/src/server/api/endpoints/users/stats.ts b/packages/backend/src/server/api/endpoints/users/stats.ts new file mode 100644 index 0000000000..ef8afd5625 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/stats.ts @@ -0,0 +1,144 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { ID } from '@/misc/cafy-id'; +import { DriveFiles, Followings, NoteFavorites, NoteReactions, Notes, PageLikes, PollVotes, ReversiGames, Users } from '@/models/index'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + userId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '9e638e45-3b25-4ef7-8f95-07e8498f1819' + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId); + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const [ + notesCount, + repliesCount, + renotesCount, + repliedCount, + renotedCount, + pollVotesCount, + pollVotedCount, + localFollowingCount, + remoteFollowingCount, + localFollowersCount, + remoteFollowersCount, + sentReactionsCount, + receivedReactionsCount, + noteFavoritesCount, + pageLikesCount, + pageLikedCount, + driveFilesCount, + driveUsage, + reversiCount, + ] = await Promise.all([ + Notes.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + Notes.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .andWhere('note.replyId IS NOT NULL') + .getCount(), + Notes.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .andWhere('note.renoteId IS NOT NULL') + .getCount(), + Notes.createQueryBuilder('note') + .where('note.replyUserId = :userId', { userId: user.id }) + .getCount(), + Notes.createQueryBuilder('note') + .where('note.renoteUserId = :userId', { userId: user.id }) + .getCount(), + PollVotes.createQueryBuilder('vote') + .where('vote.userId = :userId', { userId: user.id }) + .getCount(), + PollVotes.createQueryBuilder('vote') + .innerJoin('vote.note', 'note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + Followings.createQueryBuilder('following') + .where('following.followerId = :userId', { userId: user.id }) + .andWhere('following.followeeHost IS NULL') + .getCount(), + Followings.createQueryBuilder('following') + .where('following.followerId = :userId', { userId: user.id }) + .andWhere('following.followeeHost IS NOT NULL') + .getCount(), + Followings.createQueryBuilder('following') + .where('following.followeeId = :userId', { userId: user.id }) + .andWhere('following.followerHost IS NULL') + .getCount(), + Followings.createQueryBuilder('following') + .where('following.followeeId = :userId', { userId: user.id }) + .andWhere('following.followerHost IS NOT NULL') + .getCount(), + NoteReactions.createQueryBuilder('reaction') + .where('reaction.userId = :userId', { userId: user.id }) + .getCount(), + NoteReactions.createQueryBuilder('reaction') + .innerJoin('reaction.note', 'note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + NoteFavorites.createQueryBuilder('favorite') + .where('favorite.userId = :userId', { userId: user.id }) + .getCount(), + PageLikes.createQueryBuilder('like') + .where('like.userId = :userId', { userId: user.id }) + .getCount(), + PageLikes.createQueryBuilder('like') + .innerJoin('like.page', 'page') + .where('page.userId = :userId', { userId: user.id }) + .getCount(), + DriveFiles.createQueryBuilder('file') + .where('file.userId = :userId', { userId: user.id }) + .getCount(), + DriveFiles.calcDriveUsageOf(user), + ReversiGames.createQueryBuilder('game') + .where('game.user1Id = :userId', { userId: user.id }) + .orWhere('game.user2Id = :userId', { userId: user.id }) + .getCount(), + ]); + + return { + notesCount, + repliesCount, + renotesCount, + repliedCount, + renotedCount, + pollVotesCount, + pollVotedCount, + localFollowingCount, + remoteFollowingCount, + localFollowersCount, + remoteFollowersCount, + followingCount: localFollowingCount + remoteFollowingCount, + followersCount: localFollowersCount + remoteFollowersCount, + sentReactionsCount, + receivedReactionsCount, + noteFavoritesCount, + pageLikesCount, + pageLikedCount, + driveFilesCount, + driveUsage, + reversiCount, + }; +}); diff --git a/packages/backend/src/server/api/error.ts b/packages/backend/src/server/api/error.ts new file mode 100644 index 0000000000..cb0bdc9f47 --- /dev/null +++ b/packages/backend/src/server/api/error.ts @@ -0,0 +1,28 @@ +type E = { message: string, code: string, id: string, kind?: 'client' | 'server', httpStatusCode?: number }; + +export class ApiError extends Error { + public message: string; + public code: string; + public id: string; + public kind: string; + public httpStatusCode?: number; + public info?: any; + + constructor(e?: E | null | undefined, info?: any | null | undefined) { + if (e == null) e = { + message: 'Internal error occurred. Please contact us if the error persists.', + code: 'INTERNAL_ERROR', + id: '5d37dbcb-891e-41ca-a3d6-e690c97775ac', + kind: 'server', + httpStatusCode: 500 + }; + + super(e.message); + this.message = e.message; + this.code = e.code; + this.id = e.id; + this.kind = e.kind || 'client'; + this.httpStatusCode = e.httpStatusCode; + this.info = info; + } +} diff --git a/packages/backend/src/server/api/index.ts b/packages/backend/src/server/api/index.ts new file mode 100644 index 0000000000..82579075eb --- /dev/null +++ b/packages/backend/src/server/api/index.ts @@ -0,0 +1,113 @@ +/** + * API Server + */ + +import * as Koa from 'koa'; +import * as Router from '@koa/router'; +import * as multer from '@koa/multer'; +import * as bodyParser from 'koa-bodyparser'; +import * as cors from '@koa/cors'; + +import endpoints from './endpoints'; +import handler from './api-handler'; +import signup from './private/signup'; +import signin from './private/signin'; +import signupPending from './private/signup-pending'; +import discord from './service/discord'; +import github from './service/github'; +import twitter from './service/twitter'; +import { Instances, AccessTokens, Users } from '@/models/index'; +import config from '@/config'; + +// Init app +const app = new Koa(); + +app.use(cors({ + origin: '*' +})); + +// No caching +app.use(async (ctx, next) => { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + await next(); +}); + +app.use(bodyParser({ + // リクエストが multipart/form-data でない限りはJSONだと見なす + detectJSON: ctx => !ctx.is('multipart/form-data') +})); + +// Init multer instance +const upload = multer({ + storage: multer.diskStorage({}), + limits: { + fileSize: config.maxFileSize || 262144000, + files: 1, + } +}); + +// Init router +const router = new Router(); + +/** + * Register endpoint handlers + */ +for (const endpoint of endpoints) { + if (endpoint.meta.requireFile) { + router.post(`/${endpoint.name}`, upload.single('file'), handler.bind(null, endpoint)); + } else { + if (endpoint.name.includes('-')) { + // 後方互換性のため + router.post(`/${endpoint.name.replace(/-/g, '_')}`, handler.bind(null, endpoint)); + } + router.post(`/${endpoint.name}`, handler.bind(null, endpoint)); + } +} + +router.post('/signup', signup); +router.post('/signin', signin); +router.post('/signup-pending', signupPending); + +router.use(discord.routes()); +router.use(github.routes()); +router.use(twitter.routes()); + +router.get('/v1/instance/peers', async ctx => { + const instances = await Instances.find({ + select: ['host'] + }); + + ctx.body = instances.map(instance => instance.host); +}); + +router.post('/miauth/:session/check', async ctx => { + const token = await AccessTokens.findOne({ + session: ctx.params.session + }); + + if (token && token.session != null && !token.fetched) { + AccessTokens.update(token.id, { + fetched: true + }); + + ctx.body = { + ok: true, + token: token.token, + user: await Users.pack(token.userId, null, { detail: true }) + }; + } else { + ctx.body = { + ok: false, + }; + } +}); + +// Return 404 for unknown API +router.all('(.*)', async ctx => { + ctx.status = 404; +}); + +// Register router +app.use(router.routes()); + +export default app; diff --git a/packages/backend/src/server/api/limiter.ts b/packages/backend/src/server/api/limiter.ts new file mode 100644 index 0000000000..1e2fe5bcb3 --- /dev/null +++ b/packages/backend/src/server/api/limiter.ts @@ -0,0 +1,83 @@ +import * as Limiter from 'ratelimiter'; +import { redisClient } from '../../db/redis'; +import { IEndpoint } from './endpoints'; +import * as Acct from 'misskey-js/built/acct'; +import { User } from '@/models/entities/user'; +import Logger from '@/services/logger'; + +const logger = new Logger('limiter'); + +export default (endpoint: IEndpoint, user: User) => new Promise<void>((ok, reject) => { + const limitation = endpoint.meta.limit!; + + const key = limitation.hasOwnProperty('key') + ? limitation.key + : endpoint.name; + + const hasShortTermLimit = + limitation.hasOwnProperty('minInterval'); + + const hasLongTermLimit = + limitation.hasOwnProperty('duration') && + limitation.hasOwnProperty('max'); + + if (hasShortTermLimit) { + min(); + } else if (hasLongTermLimit) { + max(); + } else { + ok(); + } + + // Short-term limit + function min() { + const minIntervalLimiter = new Limiter({ + id: `${user.id}:${key}:min`, + duration: limitation.minInterval, + max: 1, + db: redisClient + }); + + minIntervalLimiter.get((err, info) => { + if (err) { + return reject('ERR'); + } + + logger.debug(`@${Acct.toString(user)} ${endpoint.name} min remaining: ${info.remaining}`); + + if (info.remaining === 0) { + reject('BRIEF_REQUEST_INTERVAL'); + } else { + if (hasLongTermLimit) { + max(); + } else { + ok(); + } + } + }); + } + + // Long term limit + function max() { + const limiter = new Limiter({ + id: `${user.id}:${key}`, + duration: limitation.duration, + max: limitation.max, + db: redisClient + }); + + limiter.get((err, info) => { + if (err) { + return reject('ERR'); + } + + logger.debug(`@${Acct.toString(user)} ${endpoint.name} max remaining: ${info.remaining}`); + + if (info.remaining === 0) { + reject('RATE_LIMIT_EXCEEDED'); + } else { + ok(); + } + }); + } +}); diff --git a/packages/backend/src/server/api/logger.ts b/packages/backend/src/server/api/logger.ts new file mode 100644 index 0000000000..750defe547 --- /dev/null +++ b/packages/backend/src/server/api/logger.ts @@ -0,0 +1,3 @@ +import Logger from '@/services/logger'; + +export const apiLogger = new Logger('api'); diff --git a/packages/backend/src/server/api/openapi/errors.ts b/packages/backend/src/server/api/openapi/errors.ts new file mode 100644 index 0000000000..43bcc323ba --- /dev/null +++ b/packages/backend/src/server/api/openapi/errors.ts @@ -0,0 +1,69 @@ + +export const errors = { + '400': { + 'INVALID_PARAM': { + value: { + error: { + message: 'Invalid param.', + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + } + } + } + }, + '401': { + 'CREDENTIAL_REQUIRED': { + value: { + error: { + message: 'Credential required.', + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + } + } + } + }, + '403': { + 'AUTHENTICATION_FAILED': { + value: { + error: { + message: 'Authentication failed. Please ensure your token is correct.', + code: 'AUTHENTICATION_FAILED', + id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', + } + } + } + }, + '418': { + 'I_AM_AI': { + value: { + error: { + message: 'You sent a request to Ai-chan, Misskey\'s showgirl, instead of the server.', + code: 'I_AM_AI', + id: '60c46cd1-f23a-46b1-bebe-5d2b73951a84', + } + } + } + }, + '429': { + 'RATE_LIMIT_EXCEEDED': { + value: { + error: { + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', + } + } + } + }, + '500': { + 'INTERNAL_ERROR': { + value: { + error: { + message: 'Internal error occurred. Please contact us if the error persists.', + code: 'INTERNAL_ERROR', + id: '5d37dbcb-891e-41ca-a3d6-e690c97775ac', + } + } + } + } +}; diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts new file mode 100644 index 0000000000..48b819727f --- /dev/null +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -0,0 +1,235 @@ +import endpoints from '../endpoints'; +import { Context } from 'cafy'; +import config from '@/config/index'; +import { errors as basicErrors } from './errors'; +import { schemas, convertSchemaToOpenApiSchema } from './schemas'; + +export function genOpenapiSpec(lang = 'ja-JP') { + const spec = { + openapi: '3.0.0', + + info: { + version: 'v1', + title: 'Misskey API', + 'x-logo': { url: '/static-assets/api-doc.png' } + }, + + externalDocs: { + description: 'Repository', + url: 'https://github.com/misskey-dev/misskey' + }, + + servers: [{ + url: config.apiUrl + }], + + paths: {} as any, + + components: { + schemas: schemas, + + securitySchemes: { + ApiKeyAuth: { + type: 'apiKey', + in: 'body', + name: 'i' + } + } + } + }; + + function genProps(props: { [key: string]: Context; }) { + const properties = {} as any; + + for (const [k, v] of Object.entries(props)) { + properties[k] = genProp(v); + } + + return properties; + } + + function genProp(param: Context): any { + const required = param.name === 'Object' ? (param as any).props ? Object.entries((param as any).props).filter(([k, v]: any) => !v.isOptional).map(([k, v]) => k) : [] : []; + return { + description: (param.data || {}).desc, + default: (param.data || {}).default, + deprecated: (param.data || {}).deprecated, + ...((param.data || {}).default ? { default: (param.data || {}).default } : {}), + type: param.name === 'ID' ? 'string' : param.name.toLowerCase(), + ...(param.name === 'ID' ? { example: 'xxxxxxxxxx', format: 'id' } : {}), + nullable: param.isNullable, + ...(param.name === 'String' ? { + ...((param as any).enum ? { enum: (param as any).enum } : {}), + ...((param as any).minLength ? { minLength: (param as any).minLength } : {}), + ...((param as any).maxLength ? { maxLength: (param as any).maxLength } : {}), + } : {}), + ...(param.name === 'Number' ? { + ...((param as any).minimum ? { minimum: (param as any).minimum } : {}), + ...((param as any).maximum ? { maximum: (param as any).maximum } : {}), + } : {}), + ...(param.name === 'Object' ? { + ...(required.length > 0 ? { required } : {}), + properties: (param as any).props ? genProps((param as any).props) : {} + } : {}), + ...(param.name === 'Array' ? { + items: (param as any).ctx ? genProp((param as any).ctx) : {} + } : {}) + }; + } + + for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) { + const porops = {} as any; + const errors = {} as any; + + if (endpoint.meta.errors) { + for (const e of Object.values(endpoint.meta.errors)) { + errors[e.code] = { + value: { + error: e + } + }; + } + } + + if (endpoint.meta.params) { + for (const [k, v] of Object.entries(endpoint.meta.params)) { + if (v.validator.data == null) v.validator.data = {}; + if (v.desc) v.validator.data.desc = v.desc[lang]; + if (v.deprecated) v.validator.data.deprecated = v.deprecated; + if (v.default) v.validator.data.default = v.default; + porops[k] = v.validator; + } + } + + const required = endpoint.meta.params ? Object.entries(endpoint.meta.params).filter(([k, v]) => !v.validator.isOptional).map(([k, v]) => k) : []; + + const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res) : {}; + + let desc = (endpoint.meta.desc ? endpoint.meta.desc[lang] : 'No description provided.') + '\n\n'; + desc += `**Credential required**: *${endpoint.meta.requireCredential ? 'Yes' : 'No'}*`; + if (endpoint.meta.kind) { + const kind = endpoint.meta.kind; + desc += ` / **Permission**: *${kind}*`; + } + + const info = { + operationId: endpoint.name, + summary: endpoint.name, + description: desc, + externalDocs: { + description: 'Source code', + url: `https://github.com/misskey-dev/misskey/blob/develop/src/server/api/endpoints/${endpoint.name}.ts` + }, + ...(endpoint.meta.tags ? { + tags: [endpoint.meta.tags[0]] + } : {}), + ...(endpoint.meta.requireCredential ? { + security: [{ + ApiKeyAuth: [] + }] + } : {}), + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + ...(required.length > 0 ? { required } : {}), + properties: endpoint.meta.params ? genProps(porops) : {} + } + } + } + }, + responses: { + ...(endpoint.meta.res ? { + '200': { + description: 'OK (with results)', + content: { + 'application/json': { + schema: resSchema + } + } + } + } : { + '204': { + description: 'OK (without any results)', + } + }), + '400': { + description: 'Client error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + }, + examples: { ...errors, ...basicErrors['400'] } + } + } + }, + '401': { + description: 'Authentication error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + }, + examples: basicErrors['401'] + } + } + }, + '403': { + description: 'Forbidden error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + }, + examples: basicErrors['403'] + } + } + }, + '418': { + description: 'I\'m Ai', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + }, + examples: basicErrors['418'] + } + } + }, + ...(endpoint.meta.limit ? { + '429': { + description: 'To many requests', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + }, + examples: basicErrors['429'] + } + } + } + } : {}), + '500': { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + }, + examples: basicErrors['500'] + } + } + }, + } + }; + + spec.paths['/' + endpoint.name] = { + post: info + }; + } + + return spec; +} diff --git a/packages/backend/src/server/api/openapi/schemas.ts b/packages/backend/src/server/api/openapi/schemas.ts new file mode 100644 index 0000000000..12fc207c47 --- /dev/null +++ b/packages/backend/src/server/api/openapi/schemas.ts @@ -0,0 +1,56 @@ +import { refs, Schema } from '@/misc/schema'; + +export function convertSchemaToOpenApiSchema(schema: Schema) { + const res: any = schema; + + if (schema.type === 'object' && schema.properties) { + res.required = Object.entries(schema.properties).filter(([k, v]) => !v.optional).map(([k]) => k); + + for (const k of Object.keys(schema.properties)) { + res.properties[k] = convertSchemaToOpenApiSchema(schema.properties[k]); + } + } + + if (schema.type === 'array' && schema.items) { + res.items = convertSchemaToOpenApiSchema(schema.items); + } + + if (schema.ref) { + res.$ref = `#/components/schemas/${schema.ref}`; + } + + return res; +} + +export const schemas = { + Error: { + type: 'object', + properties: { + error: { + type: 'object', + description: 'An error object.', + properties: { + code: { + type: 'string', + description: 'An error code. Unique within the endpoint.', + }, + message: { + type: 'string', + description: 'An error message.', + }, + id: { + type: 'string', + format: 'uuid', + description: 'An error ID. This ID is static.', + } + }, + required: ['code', 'id', 'message'] + }, + }, + required: ['error'] + }, + + ...Object.fromEntries( + Object.entries(refs).map(([key, schema]) => [key, convertSchemaToOpenApiSchema(schema)]) + ), +}; diff --git a/packages/backend/src/server/api/private/signin.ts b/packages/backend/src/server/api/private/signin.ts new file mode 100644 index 0000000000..83c3dfee94 --- /dev/null +++ b/packages/backend/src/server/api/private/signin.ts @@ -0,0 +1,232 @@ +import * as Koa from 'koa'; +import * as bcrypt from 'bcryptjs'; +import * as speakeasy from 'speakeasy'; +import signin from '../common/signin'; +import config from '@/config/index'; +import { Users, Signins, UserProfiles, UserSecurityKeys, AttestationChallenges } from '@/models/index'; +import { ILocalUser } from '@/models/entities/user'; +import { genId } from '@/misc/gen-id'; +import { verifyLogin, hash } from '../2fa'; +import { randomBytes } from 'crypto'; + +export default async (ctx: Koa.Context) => { + ctx.set('Access-Control-Allow-Origin', config.url); + ctx.set('Access-Control-Allow-Credentials', 'true'); + + const body = ctx.request.body as any; + const username = body['username']; + const password = body['password']; + const token = body['token']; + + function error(status: number, error: { id: string }) { + ctx.status = status; + ctx.body = { error }; + } + + if (typeof username != 'string') { + ctx.status = 400; + return; + } + + if (typeof password != 'string') { + ctx.status = 400; + return; + } + + if (token != null && typeof token != 'string') { + ctx.status = 400; + return; + } + + // Fetch user + const user = await Users.findOne({ + usernameLower: username.toLowerCase(), + host: null + }) as ILocalUser; + + if (user == null) { + error(404, { + id: '6cc579cc-885d-43d8-95c2-b8c7fc963280', + }); + return; + } + + if (user.isSuspended) { + error(403, { + id: 'e03a5f46-d309-4865-9b69-56282d94e1eb', + }); + return; + } + + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(password, profile.password!); + + async function fail(status?: number, failure?: { id: string }) { + // Append signin history + await Signins.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + ip: ctx.ip, + headers: ctx.headers, + success: false + }); + + error(status || 500, failure || { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' }); + } + + if (!profile.twoFactorEnabled) { + if (same) { + signin(ctx, user); + return; + } else { + await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c' + }); + return; + } + } + + if (token) { + if (!same) { + await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c' + }); + return; + } + + const verified = (speakeasy as any).totp.verify({ + secret: profile.twoFactorSecret, + encoding: 'base32', + token: token, + window: 2 + }); + + if (verified) { + signin(ctx, user); + return; + } else { + await fail(403, { + id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f' + }); + return; + } + } else if (body.credentialId) { + if (!same && !profile.usePasswordLessLogin) { + await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c' + }); + return; + } + + const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex'); + const clientData = JSON.parse(clientDataJSON.toString('utf-8')); + const challenge = await AttestationChallenges.findOne({ + userId: user.id, + id: body.challengeId, + registrationChallenge: false, + challenge: hash(clientData.challenge).toString('hex') + }); + + if (!challenge) { + await fail(403, { + id: '2715a88a-2125-4013-932f-aa6fe72792da' + }); + return; + } + + await AttestationChallenges.delete({ + userId: user.id, + id: body.challengeId + }); + + if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) { + await fail(403, { + id: '2715a88a-2125-4013-932f-aa6fe72792da' + }); + return; + } + + const securityKey = await UserSecurityKeys.findOne({ + id: Buffer.from( + body.credentialId + .replace(/-/g, '+') + .replace(/_/g, '/'), + 'base64' + ).toString('hex') + }); + + if (!securityKey) { + await fail(403, { + id: '66269679-aeaf-4474-862b-eb761197e046' + }); + return; + } + + const isValid = verifyLogin({ + publicKey: Buffer.from(securityKey.publicKey, 'hex'), + authenticatorData: Buffer.from(body.authenticatorData, 'hex'), + clientDataJSON, + clientData, + signature: Buffer.from(body.signature, 'hex'), + challenge: challenge.challenge + }); + + if (isValid) { + signin(ctx, user); + return; + } else { + await fail(403, { + id: '93b86c4b-72f9-40eb-9815-798928603d1e' + }); + return; + } + } else { + if (!same && !profile.usePasswordLessLogin) { + await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c' + }); + return; + } + + const keys = await UserSecurityKeys.find({ + userId: user.id + }); + + if (keys.length === 0) { + await fail(403, { + id: 'f27fd449-9af4-4841-9249-1f989b9fa4a4' + }); + return; + } + + // 32 byte challenge + const challenge = randomBytes(32).toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + const challengeId = genId(); + + await AttestationChallenges.insert({ + userId: user.id, + id: challengeId, + challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'), + createdAt: new Date(), + registrationChallenge: false + }); + + ctx.body = { + challenge, + challengeId, + securityKeys: keys.map(key => ({ + id: key.id + })) + }; + ctx.status = 200; + return; + } + // never get here +}; diff --git a/packages/backend/src/server/api/private/signup-pending.ts b/packages/backend/src/server/api/private/signup-pending.ts new file mode 100644 index 0000000000..c0638a1cda --- /dev/null +++ b/packages/backend/src/server/api/private/signup-pending.ts @@ -0,0 +1,35 @@ +import * as Koa from 'koa'; +import { Users, UserPendings, UserProfiles } from '@/models/index'; +import { signup } from '../common/signup'; +import signin from '../common/signin'; + +export default async (ctx: Koa.Context) => { + const body = ctx.request.body; + + const code = body['code']; + + try { + const pendingUser = await UserPendings.findOneOrFail({ code }); + + const { account, secret } = await signup({ + username: pendingUser.username, + passwordHash: pendingUser.password, + }); + + UserPendings.delete({ + id: pendingUser.id, + }); + + const profile = await UserProfiles.findOneOrFail(account.id); + + await UserProfiles.update({ userId: profile.userId }, { + email: pendingUser.email, + emailVerified: true, + emailVerifyCode: null, + }); + + signin(ctx, account); + } catch (e) { + ctx.throw(400, e); + } +}; diff --git a/packages/backend/src/server/api/private/signup.ts b/packages/backend/src/server/api/private/signup.ts new file mode 100644 index 0000000000..2b6a3eb00c --- /dev/null +++ b/packages/backend/src/server/api/private/signup.ts @@ -0,0 +1,112 @@ +import * as Koa from 'koa'; +import rndstr from 'rndstr'; +import * as bcrypt from 'bcryptjs'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { verifyHcaptcha, verifyRecaptcha } from '@/misc/captcha'; +import { Users, RegistrationTickets, UserPendings } from '@/models/index'; +import { signup } from '../common/signup'; +import config from '@/config'; +import { sendEmail } from '@/services/send-email'; +import { genId } from '@/misc/gen-id'; +import { validateEmailForAccount } from '@/services/validate-email-for-account'; + +export default async (ctx: Koa.Context) => { + const body = ctx.request.body; + + const instance = await fetchMeta(true); + + // Verify *Captcha + // ただしテスト時はこの機構は障害となるため無効にする + if (process.env.NODE_ENV !== 'test') { + if (instance.enableHcaptcha && instance.hcaptchaSecretKey) { + await verifyHcaptcha(instance.hcaptchaSecretKey, body['hcaptcha-response']).catch(e => { + ctx.throw(400, e); + }); + } + + if (instance.enableRecaptcha && instance.recaptchaSecretKey) { + await verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(e => { + ctx.throw(400, e); + }); + } + } + + const username = body['username']; + const password = body['password']; + const host: string | null = process.env.NODE_ENV === 'test' ? (body['host'] || null) : null; + const invitationCode = body['invitationCode']; + const emailAddress = body['emailAddress']; + + if (instance.emailRequiredForSignup) { + if (emailAddress == null || typeof emailAddress != 'string') { + ctx.status = 400; + return; + } + + const available = await validateEmailForAccount(emailAddress); + if (!available) { + ctx.status = 400; + return; + } + } + + if (instance.disableRegistration) { + if (invitationCode == null || typeof invitationCode != 'string') { + ctx.status = 400; + return; + } + + const ticket = await RegistrationTickets.findOne({ + code: invitationCode + }); + + if (ticket == null) { + ctx.status = 400; + return; + } + + RegistrationTickets.delete(ticket.id); + } + + if (instance.emailRequiredForSignup) { + const code = rndstr('a-z0-9', 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: username, + password: hash, + }); + + const link = `${config.url}/signup-complete/${code}`; + + sendEmail(emailAddress, 'Signup', + `To complete signup, please click this link:<br><a href="${link}">${link}</a>`, + `To complete signup, please click this link: ${link}`); + + ctx.status = 204; + } else { + try { + const { account, secret } = await signup({ + username, password, host + }); + + const res = await Users.pack(account, account, { + detail: true, + includeSecrets: true + }); + + (res as any).token = secret; + + ctx.body = res; + } catch (e) { + ctx.throw(400, e); + } + } +}; diff --git a/packages/backend/src/server/api/service/discord.ts b/packages/backend/src/server/api/service/discord.ts new file mode 100644 index 0000000000..dd52a23376 --- /dev/null +++ b/packages/backend/src/server/api/service/discord.ts @@ -0,0 +1,286 @@ +import * as Koa from 'koa'; +import * as Router from '@koa/router'; +import { getJson } from '@/misc/fetch'; +import { OAuth2 } from 'oauth'; +import config from '@/config/index'; +import { publishMainStream } from '@/services/stream'; +import { redisClient } from '../../../db/redis'; +import { v4 as uuid } from 'uuid'; +import signin from '../common/signin'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Users, UserProfiles } from '@/models/index'; +import { ILocalUser } from '@/models/entities/user'; + +function getUserToken(ctx: Koa.Context) { + return ((ctx.headers['cookie'] || '').match(/igi=(\w+)/) || [null, null])[1]; +} + +function compareOrigin(ctx: Koa.Context) { + function normalizeUrl(url: string) { + return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : ''; + } + + const referer = ctx.headers['referer']; + + return (normalizeUrl(referer) == normalizeUrl(config.url)); +} + +// Init router +const router = new Router(); + +router.get('/disconnect/discord', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const user = await Users.findOneOrFail({ + host: null, + token: userToken + }); + + const profile = await UserProfiles.findOneOrFail(user.id); + + delete profile.integrations.discord; + + await UserProfiles.update(user.id, { + integrations: profile.integrations, + }); + + ctx.body = `Discordの連携を解除しました :v:`; + + // Publish i updated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { + detail: true, + includeSecrets: true + })); +}); + +async function getOAuth2() { + const meta = await fetchMeta(true); + + if (meta.enableDiscordIntegration) { + return new OAuth2( + meta.discordClientId!, + meta.discordClientSecret!, + 'https://discord.com/', + 'api/oauth2/authorize', + 'api/oauth2/token'); + } else { + return null; + } +} + +router.get('/connect/discord', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const params = { + redirect_uri: `${config.url}/api/dc/cb`, + scope: ['identify'], + state: uuid(), + response_type: 'code' + }; + + redisClient.set(userToken, JSON.stringify(params)); + + const oauth2 = await getOAuth2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); +}); + +router.get('/signin/discord', async ctx => { + const sessid = uuid(); + + const params = { + redirect_uri: `${config.url}/api/dc/cb`, + scope: ['identify'], + state: uuid(), + response_type: 'code' + }; + + ctx.cookies.set('signin_with_discord_sid', sessid, { + path: '/', + secure: config.url.startsWith('https'), + httpOnly: true + }); + + redisClient.set(sessid, JSON.stringify(params)); + + const oauth2 = await getOAuth2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); +}); + +router.get('/dc/cb', async ctx => { + const userToken = getUserToken(ctx); + + const oauth2 = await getOAuth2(); + + if (!userToken) { + const sessid = ctx.cookies.get('signin_with_discord_sid'); + + if (!sessid) { + ctx.throw(400, 'invalid session'); + return; + } + + const code = ctx.query.code; + + if (!code) { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise<any>((res, rej) => { + redisClient.get(sessid, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken, refreshToken, expiresDate } = await new Promise<any>((res, rej) => + oauth2!.getOAuthAccessToken(code, { + grant_type: 'authorization_code', + redirect_uri + }, (err, accessToken, refreshToken, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ + accessToken, + refreshToken, + expiresDate: Date.now() + Number(result.expires_in) * 1000 + }); + } + })); + + const { id, username, discriminator } = await getJson('https://discord.com/api/users/@me', '*/*', 10 * 1000, { + 'Authorization': `Bearer ${accessToken}`, + }); + + if (!id || !username || !discriminator) { + ctx.throw(400, 'invalid session'); + return; + } + + const profile = await UserProfiles.createQueryBuilder() + .where(`"integrations"->'discord'->>'id' = :id`, { id: id }) + .andWhere('"userHost" IS NULL') + .getOne(); + + if (profile == null) { + ctx.throw(404, `@${username}#${discriminator}と連携しているMisskeyアカウントはありませんでした...`); + return; + } + + await UserProfiles.update(profile.userId, { + integrations: { + ...profile.integrations, + discord: { + id: id, + accessToken: accessToken, + refreshToken: refreshToken, + expiresDate: expiresDate, + username: username, + discriminator: discriminator + } + }, + }); + + signin(ctx, await Users.findOne(profile.userId) as ILocalUser, true); + } else { + const code = ctx.query.code; + + if (!code) { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise<any>((res, rej) => { + redisClient.get(userToken, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken, refreshToken, expiresDate } = await new Promise<any>((res, rej) => + oauth2!.getOAuthAccessToken(code, { + grant_type: 'authorization_code', + redirect_uri + }, (err, accessToken, refreshToken, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ + accessToken, + refreshToken, + expiresDate: Date.now() + Number(result.expires_in) * 1000 + }); + } + })); + + const { id, username, discriminator } = await getJson('https://discord.com/api/users/@me', '*/*', 10 * 1000, { + 'Authorization': `Bearer ${accessToken}`, + }); + if (!id || !username || !discriminator) { + ctx.throw(400, 'invalid session'); + return; + } + + const user = await Users.findOneOrFail({ + host: null, + token: userToken + }); + + const profile = await UserProfiles.findOneOrFail(user.id); + + await UserProfiles.update(user.id, { + integrations: { + ...profile.integrations, + discord: { + accessToken: accessToken, + refreshToken: refreshToken, + expiresDate: expiresDate, + id: id, + username: username, + discriminator: discriminator + } + } + }); + + ctx.body = `Discord: @${username}#${discriminator} を、Misskey: @${user.username} に接続しました!`; + + // Publish i updated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { + detail: true, + includeSecrets: true + })); + } +}); + +export default router; diff --git a/packages/backend/src/server/api/service/github.ts b/packages/backend/src/server/api/service/github.ts new file mode 100644 index 0000000000..0616f3f773 --- /dev/null +++ b/packages/backend/src/server/api/service/github.ts @@ -0,0 +1,257 @@ +import * as Koa from 'koa'; +import * as Router from '@koa/router'; +import { getJson } from '@/misc/fetch'; +import { OAuth2 } from 'oauth'; +import config from '@/config/index'; +import { publishMainStream } from '@/services/stream'; +import { redisClient } from '../../../db/redis'; +import { v4 as uuid } from 'uuid'; +import signin from '../common/signin'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Users, UserProfiles } from '@/models/index'; +import { ILocalUser } from '@/models/entities/user'; + +function getUserToken(ctx: Koa.Context) { + return ((ctx.headers['cookie'] || '').match(/igi=(\w+)/) || [null, null])[1]; +} + +function compareOrigin(ctx: Koa.Context) { + function normalizeUrl(url: string) { + return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : ''; + } + + const referer = ctx.headers['referer']; + + return (normalizeUrl(referer) == normalizeUrl(config.url)); +} + +// Init router +const router = new Router(); + +router.get('/disconnect/github', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const user = await Users.findOneOrFail({ + host: null, + token: userToken + }); + + const profile = await UserProfiles.findOneOrFail(user.id); + + delete profile.integrations.github; + + await UserProfiles.update(user.id, { + integrations: profile.integrations, + }); + + ctx.body = `GitHubの連携を解除しました :v:`; + + // Publish i updated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { + detail: true, + includeSecrets: true + })); +}); + +async function getOath2() { + const meta = await fetchMeta(true); + + if (meta.enableGithubIntegration && meta.githubClientId && meta.githubClientSecret) { + return new OAuth2( + meta.githubClientId, + meta.githubClientSecret, + 'https://github.com/', + 'login/oauth/authorize', + 'login/oauth/access_token'); + } else { + return null; + } +} + +router.get('/connect/github', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const params = { + redirect_uri: `${config.url}/api/gh/cb`, + scope: ['read:user'], + state: uuid() + }; + + redisClient.set(userToken, JSON.stringify(params)); + + const oauth2 = await getOath2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); +}); + +router.get('/signin/github', async ctx => { + const sessid = uuid(); + + const params = { + redirect_uri: `${config.url}/api/gh/cb`, + scope: ['read:user'], + state: uuid() + }; + + ctx.cookies.set('signin_with_github_sid', sessid, { + path: '/', + secure: config.url.startsWith('https'), + httpOnly: true + }); + + redisClient.set(sessid, JSON.stringify(params)); + + const oauth2 = await getOath2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); +}); + +router.get('/gh/cb', async ctx => { + const userToken = getUserToken(ctx); + + const oauth2 = await getOath2(); + + if (!userToken) { + const sessid = ctx.cookies.get('signin_with_github_sid'); + + if (!sessid) { + ctx.throw(400, 'invalid session'); + return; + } + + const code = ctx.query.code; + + if (!code) { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise<any>((res, rej) => { + redisClient.get(sessid, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken } = await new Promise<any>((res, rej) => + oauth2!.getOAuthAccessToken(code, { + redirect_uri + }, (err, accessToken, refresh, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ accessToken }); + } + })); + + const { login, id } = await getJson('https://api.github.com/user', 'application/vnd.github.v3+json', 10 * 1000, { + 'Authorization': `bearer ${accessToken}` + }); + if (!login || !id) { + ctx.throw(400, 'invalid session'); + return; + } + + const link = await UserProfiles.createQueryBuilder() + .where(`"integrations"->'github'->>'id' = :id`, { id: id }) + .andWhere('"userHost" IS NULL') + .getOne(); + + if (link == null) { + ctx.throw(404, `@${login}と連携しているMisskeyアカウントはありませんでした...`); + return; + } + + signin(ctx, await Users.findOne(link.userId) as ILocalUser, true); + } else { + const code = ctx.query.code; + + if (!code) { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise<any>((res, rej) => { + redisClient.get(userToken, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken } = await new Promise<any>((res, rej) => + oauth2!.getOAuthAccessToken( + code, + { redirect_uri }, + (err, accessToken, refresh, result) => { + if (err) + rej(err); + else if (result.error) + rej(result.error); + else + res({ accessToken }); + })); + + const { login, id } = await getJson('https://api.github.com/user', 'application/vnd.github.v3+json', 10 * 1000, { + 'Authorization': `bearer ${accessToken}` + }); + + if (!login || !id) { + ctx.throw(400, 'invalid session'); + return; + } + + const user = await Users.findOneOrFail({ + host: null, + token: userToken + }); + + const profile = await UserProfiles.findOneOrFail(user.id); + + await UserProfiles.update(user.id, { + integrations: { + ...profile.integrations, + github: { + accessToken: accessToken, + id: id, + login: login, + } + } + }); + + ctx.body = `GitHub: @${login} を、Misskey: @${user.username} に接続しました!`; + + // Publish i updated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { + detail: true, + includeSecrets: true + })); + } +}); + +export default router; diff --git a/packages/backend/src/server/api/service/twitter.ts b/packages/backend/src/server/api/service/twitter.ts new file mode 100644 index 0000000000..8a6a58aeee --- /dev/null +++ b/packages/backend/src/server/api/service/twitter.ts @@ -0,0 +1,194 @@ +import * as Koa from 'koa'; +import * as Router from '@koa/router'; +import { v4 as uuid } from 'uuid'; +import autwh from 'autwh'; +import { redisClient } from '../../../db/redis'; +import { publishMainStream } from '@/services/stream'; +import config from '@/config/index'; +import signin from '../common/signin'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Users, UserProfiles } from '@/models/index'; +import { ILocalUser } from '@/models/entities/user'; + +function getUserToken(ctx: Koa.Context) { + return ((ctx.headers['cookie'] || '').match(/igi=(\w+)/) || [null, null])[1]; +} + +function compareOrigin(ctx: Koa.Context) { + function normalizeUrl(url: string) { + return url.endsWith('/') ? url.substr(0, url.length - 1) : url; + } + + const referer = ctx.headers['referer']; + + return (normalizeUrl(referer) == normalizeUrl(config.url)); +} + +// Init router +const router = new Router(); + +router.get('/disconnect/twitter', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (userToken == null) { + ctx.throw(400, 'signin required'); + return; + } + + const user = await Users.findOneOrFail({ + host: null, + token: userToken + }); + + const profile = await UserProfiles.findOneOrFail(user.id); + + delete profile.integrations.twitter; + + await UserProfiles.update(user.id, { + integrations: profile.integrations, + }); + + ctx.body = `Twitterの連携を解除しました :v:`; + + // Publish i updated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { + detail: true, + includeSecrets: true + })); +}); + +async function getTwAuth() { + const meta = await fetchMeta(true); + + if (meta.enableTwitterIntegration && meta.twitterConsumerKey && meta.twitterConsumerSecret) { + return autwh({ + consumerKey: meta.twitterConsumerKey, + consumerSecret: meta.twitterConsumerSecret, + callbackUrl: `${config.url}/api/tw/cb` + }); + } else { + return null; + } +} + +router.get('/connect/twitter', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (userToken == null) { + ctx.throw(400, 'signin required'); + return; + } + + const twAuth = await getTwAuth(); + const twCtx = await twAuth!.begin(); + redisClient.set(userToken, JSON.stringify(twCtx)); + ctx.redirect(twCtx.url); +}); + +router.get('/signin/twitter', async ctx => { + const twAuth = await getTwAuth(); + const twCtx = await twAuth!.begin(); + + const sessid = uuid(); + + redisClient.set(sessid, JSON.stringify(twCtx)); + + ctx.cookies.set('signin_with_twitter_sid', sessid, { + path: '/', + secure: config.url.startsWith('https'), + httpOnly: true + }); + + ctx.redirect(twCtx.url); +}); + +router.get('/tw/cb', async ctx => { + const userToken = getUserToken(ctx); + + const twAuth = await getTwAuth(); + + if (userToken == null) { + const sessid = ctx.cookies.get('signin_with_twitter_sid'); + + if (sessid == null) { + ctx.throw(400, 'invalid session'); + return; + } + + const get = new Promise<any>((res, rej) => { + redisClient.get(sessid, async (_, twCtx) => { + res(twCtx); + }); + }); + + const twCtx = await get; + + const result = await twAuth!.done(JSON.parse(twCtx), ctx.query.oauth_verifier); + + const link = await UserProfiles.createQueryBuilder() + .where(`"integrations"->'twitter'->>'userId' = :id`, { id: result.userId }) + .andWhere('"userHost" IS NULL') + .getOne(); + + if (link == null) { + ctx.throw(404, `@${result.screenName}と連携しているMisskeyアカウントはありませんでした...`); + return; + } + + signin(ctx, await Users.findOne(link.userId) as ILocalUser, true); + } else { + const verifier = ctx.query.oauth_verifier; + + if (verifier == null) { + ctx.throw(400, 'invalid session'); + return; + } + + const get = new Promise<any>((res, rej) => { + redisClient.get(userToken, async (_, twCtx) => { + res(twCtx); + }); + }); + + const twCtx = await get; + + const result = await twAuth!.done(JSON.parse(twCtx), verifier); + + const user = await Users.findOneOrFail({ + host: null, + token: userToken + }); + + const profile = await UserProfiles.findOneOrFail(user.id); + + await UserProfiles.update(user.id, { + integrations: { + ...profile.integrations, + twitter: { + accessToken: result.accessToken, + accessTokenSecret: result.accessTokenSecret, + userId: result.userId, + screenName: result.screenName, + } + }, + }); + + ctx.body = `Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`; + + // Publish i updated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { + detail: true, + includeSecrets: true + })); + } +}); + +export default router; diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts new file mode 100644 index 0000000000..2824d7d1b8 --- /dev/null +++ b/packages/backend/src/server/api/stream/channel.ts @@ -0,0 +1,62 @@ +import autobind from 'autobind-decorator'; +import Connection from '.'; + +/** + * Stream channel + */ +export default abstract class Channel { + protected connection: Connection; + public id: string; + public abstract readonly chName: string; + public static readonly shouldShare: boolean; + public static readonly requireCredential: boolean; + + protected get user() { + return this.connection.user; + } + + protected get userProfile() { + return this.connection.userProfile; + } + + protected get following() { + return this.connection.following; + } + + protected get muting() { + return this.connection.muting; + } + + protected get blocking() { + return this.connection.blocking; + } + + protected get followingChannels() { + return this.connection.followingChannels; + } + + protected get subscriber() { + return this.connection.subscriber; + } + + constructor(id: string, connection: Connection) { + this.id = id; + this.connection = connection; + } + + @autobind + public send(typeOrPayload: any, payload?: any) { + const type = payload === undefined ? typeOrPayload.type : typeOrPayload; + const body = payload === undefined ? typeOrPayload.body : payload; + + this.connection.sendMessageToWs('channel', { + id: this.id, + type: type, + body: body + }); + } + + public abstract init(params: any): void; + public dispose?(): void; + public onMessage?(type: string, body: any): void; +} diff --git a/packages/backend/src/server/api/stream/channels/admin.ts b/packages/backend/src/server/api/stream/channels/admin.ts new file mode 100644 index 0000000000..1ff932d1dd --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/admin.ts @@ -0,0 +1,16 @@ +import autobind from 'autobind-decorator'; +import Channel from '../channel'; + +export default class extends Channel { + public readonly chName = 'admin'; + public static shouldShare = true; + public static requireCredential = true; + + @autobind + public async init(params: any) { + // Subscribe admin stream + this.subscriber.on(`adminStream:${this.user!.id}`, data => { + this.send(data); + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts new file mode 100644 index 0000000000..3cbdfebb43 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -0,0 +1,45 @@ +import autobind from 'autobind-decorator'; +import Channel from '../channel'; +import { Notes } from '@/models/index'; +import { isMutedUserRelated } from '@/misc/is-muted-user-related'; +import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { StreamMessages } from '../types'; + +export default class extends Channel { + public readonly chName = 'antenna'; + public static shouldShare = false; + public static requireCredential = false; + private antennaId: string; + + @autobind + public async init(params: any) { + this.antennaId = params.antennaId as string; + + // Subscribe stream + this.subscriber.on(`antennaStream:${this.antennaId}`, this.onEvent); + } + + @autobind + private async onEvent(data: StreamMessages['antenna']['payload']) { + if (data.type === 'note') { + const note = await Notes.pack(data.body.id, this.user, { detail: true }); + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isMutedUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isBlockerUserRelated(note, this.blocking)) return; + + this.connection.cacheNote(note); + + this.send('note', note); + } else { + this.send(data.type, data.body); + } + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off(`antennaStream:${this.antennaId}`, this.onEvent); + } +} diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts new file mode 100644 index 0000000000..bf7942f522 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -0,0 +1,92 @@ +import autobind from 'autobind-decorator'; +import Channel from '../channel'; +import { Notes, Users } from '@/models/index'; +import { isMutedUserRelated } from '@/misc/is-muted-user-related'; +import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { User } from '@/models/entities/user'; +import { StreamMessages } from '../types'; +import { Packed } from '@/misc/schema'; + +export default class extends Channel { + public readonly chName = 'channel'; + public static shouldShare = false; + public static requireCredential = false; + private channelId: string; + private typers: Record<User['id'], Date> = {}; + private emitTypersIntervalId: ReturnType<typeof setInterval>; + + @autobind + public async init(params: any) { + this.channelId = params.channelId as string; + + // Subscribe stream + this.subscriber.on('notesStream', this.onNote); + this.subscriber.on(`channelStream:${this.channelId}`, this.onEvent); + this.emitTypersIntervalId = setInterval(this.emitTypers, 5000); + } + + @autobind + private async onNote(note: Packed<'Note'>) { + if (note.channelId !== this.channelId) return; + + // リプライなら再pack + if (note.replyId != null) { + note.reply = await Notes.pack(note.replyId, this.user, { + detail: true + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user, { + detail: true + }); + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isMutedUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isBlockerUserRelated(note, this.blocking)) return; + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @autobind + private onEvent(data: StreamMessages['channel']['payload']) { + if (data.type === 'typing') { + const id = data.body; + const begin = this.typers[id] == null; + this.typers[id] = new Date(); + if (begin) { + this.emitTypers(); + } + } + } + + @autobind + private async emitTypers() { + const now = new Date(); + + // Remove not typing users + for (const [userId, date] of Object.entries(this.typers)) { + if (now.getTime() - date.getTime() > 5000) delete this.typers[userId]; + } + + const users = await Users.packMany(Object.keys(this.typers), null, { detail: false }); + + this.send({ + type: 'typers', + body: users, + }); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + this.subscriber.off(`channelStream:${this.channelId}`, this.onEvent); + + clearInterval(this.emitTypersIntervalId); + } +} diff --git a/packages/backend/src/server/api/stream/channels/drive.ts b/packages/backend/src/server/api/stream/channels/drive.ts new file mode 100644 index 0000000000..4112dd9b04 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/drive.ts @@ -0,0 +1,16 @@ +import autobind from 'autobind-decorator'; +import Channel from '../channel'; + +export default class extends Channel { + public readonly chName = 'drive'; + public static shouldShare = true; + public static requireCredential = true; + + @autobind + public async init(params: any) { + // Subscribe drive stream + this.subscriber.on(`driveStream:${this.user!.id}`, data => { + this.send(data); + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/games/reversi-game.ts b/packages/backend/src/server/api/stream/channels/games/reversi-game.ts new file mode 100644 index 0000000000..bfdbf1d266 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/games/reversi-game.ts @@ -0,0 +1,372 @@ +import autobind from 'autobind-decorator'; +import * as CRC32 from 'crc-32'; +import { publishReversiGameStream } from '@/services/stream'; +import Reversi from '../../../../../games/reversi/core'; +import * as maps from '../../../../../games/reversi/maps'; +import Channel from '../../channel'; +import { ReversiGame } from '@/models/entities/games/reversi/game'; +import { ReversiGames, Users } from '@/models/index'; +import { User } from '@/models/entities/user'; + +export default class extends Channel { + public readonly chName = 'gamesReversiGame'; + public static shouldShare = false; + public static requireCredential = false; + + private gameId: ReversiGame['id'] | null = null; + private watchers: Record<User['id'], Date> = {}; + private emitWatchersIntervalId: ReturnType<typeof setInterval>; + + @autobind + public async init(params: any) { + this.gameId = params.gameId; + + // Subscribe game stream + this.subscriber.on(`reversiGameStream:${this.gameId}`, this.onEvent); + this.emitWatchersIntervalId = setInterval(this.emitWatchers, 5000); + + const game = await ReversiGames.findOne(this.gameId!); + if (game == null) throw new Error('game not found'); + + // 観戦者イベント + this.watch(game); + } + + @autobind + private onEvent(data: any) { + if (data.type === 'watching') { + const id = data.body; + this.watchers[id] = new Date(); + } else { + this.send(data); + } + } + + @autobind + private async emitWatchers() { + const now = new Date(); + + // Remove not watching users + for (const [userId, date] of Object.entries(this.watchers)) { + if (now.getTime() - date.getTime() > 5000) delete this.watchers[userId]; + } + + const users = await Users.packMany(Object.keys(this.watchers), null, { detail: false }); + + this.send({ + type: 'watchers', + body: users, + }); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off(`reversiGameStream:${this.gameId}`, this.onEvent); + clearInterval(this.emitWatchersIntervalId); + } + + @autobind + public onMessage(type: string, body: any) { + switch (type) { + case 'accept': this.accept(true); break; + case 'cancelAccept': this.accept(false); break; + case 'updateSettings': this.updateSettings(body.key, body.value); break; + case 'initForm': this.initForm(body); break; + case 'updateForm': this.updateForm(body.id, body.value); break; + case 'message': this.message(body); break; + case 'set': this.set(body.pos); break; + case 'check': this.check(body.crc32); break; + } + } + + @autobind + private async updateSettings(key: string, value: any) { + if (this.user == null) return; + + const game = await ReversiGames.findOne(this.gameId!); + if (game == null) throw new Error('game not found'); + + if (game.isStarted) return; + if ((game.user1Id !== this.user.id) && (game.user2Id !== this.user.id)) return; + if ((game.user1Id === this.user.id) && game.user1Accepted) return; + if ((game.user2Id === this.user.id) && game.user2Accepted) return; + + if (!['map', 'bw', 'isLlotheo', 'canPutEverywhere', 'loopedBoard'].includes(key)) return; + + await ReversiGames.update(this.gameId!, { + [key]: value + }); + + publishReversiGameStream(this.gameId!, 'updateSettings', { + key: key, + value: value + }); + } + + @autobind + private async initForm(form: any) { + if (this.user == null) return; + + const game = await ReversiGames.findOne(this.gameId!); + if (game == null) throw new Error('game not found'); + + if (game.isStarted) return; + if ((game.user1Id !== this.user.id) && (game.user2Id !== this.user.id)) return; + + const set = game.user1Id === this.user.id ? { + form1: form + } : { + form2: form + }; + + await ReversiGames.update(this.gameId!, set); + + publishReversiGameStream(this.gameId!, 'initForm', { + userId: this.user.id, + form + }); + } + + @autobind + private async updateForm(id: string, value: any) { + if (this.user == null) return; + + const game = await ReversiGames.findOne(this.gameId!); + if (game == null) throw new Error('game not found'); + + if (game.isStarted) return; + if ((game.user1Id !== this.user.id) && (game.user2Id !== this.user.id)) return; + + const form = game.user1Id === this.user.id ? game.form2 : game.form1; + + const item = form.find((i: any) => i.id == id); + + if (item == null) return; + + item.value = value; + + const set = game.user1Id === this.user.id ? { + form2: form + } : { + form1: form + }; + + await ReversiGames.update(this.gameId!, set); + + publishReversiGameStream(this.gameId!, 'updateForm', { + userId: this.user.id, + id, + value + }); + } + + @autobind + private async message(message: any) { + if (this.user == null) return; + + message.id = Math.random(); + publishReversiGameStream(this.gameId!, 'message', { + userId: this.user.id, + message + }); + } + + @autobind + private async accept(accept: boolean) { + if (this.user == null) return; + + const game = await ReversiGames.findOne(this.gameId!); + if (game == null) throw new Error('game not found'); + + if (game.isStarted) return; + + let bothAccepted = false; + + if (game.user1Id === this.user.id) { + await ReversiGames.update(this.gameId!, { + user1Accepted: accept + }); + + publishReversiGameStream(this.gameId!, 'changeAccepts', { + user1: accept, + user2: game.user2Accepted + }); + + if (accept && game.user2Accepted) bothAccepted = true; + } else if (game.user2Id === this.user.id) { + await ReversiGames.update(this.gameId!, { + user2Accepted: accept + }); + + publishReversiGameStream(this.gameId!, 'changeAccepts', { + user1: game.user1Accepted, + user2: accept + }); + + if (accept && game.user1Accepted) bothAccepted = true; + } else { + return; + } + + if (bothAccepted) { + // 3秒後、まだacceptされていたらゲーム開始 + setTimeout(async () => { + const freshGame = await ReversiGames.findOne(this.gameId!); + if (freshGame == null || freshGame.isStarted || freshGame.isEnded) return; + if (!freshGame.user1Accepted || !freshGame.user2Accepted) return; + + let bw: number; + if (freshGame.bw == 'random') { + bw = Math.random() > 0.5 ? 1 : 2; + } else { + bw = parseInt(freshGame.bw, 10); + } + + function getRandomMap() { + const mapCount = Object.entries(maps).length; + const rnd = Math.floor(Math.random() * mapCount); + return Object.values(maps)[rnd].data; + } + + const map = freshGame.map != null ? freshGame.map : getRandomMap(); + + await ReversiGames.update(this.gameId!, { + startedAt: new Date(), + isStarted: true, + black: bw, + map: map + }); + + //#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理 + const o = new Reversi(map, { + isLlotheo: freshGame.isLlotheo, + canPutEverywhere: freshGame.canPutEverywhere, + loopedBoard: freshGame.loopedBoard + }); + + if (o.isEnded) { + let winner; + if (o.winner === true) { + winner = freshGame.black == 1 ? freshGame.user1Id : freshGame.user2Id; + } else if (o.winner === false) { + winner = freshGame.black == 1 ? freshGame.user2Id : freshGame.user1Id; + } else { + winner = null; + } + + await ReversiGames.update(this.gameId!, { + isEnded: true, + winnerId: winner + }); + + publishReversiGameStream(this.gameId!, 'ended', { + winnerId: winner, + game: await ReversiGames.pack(this.gameId!, this.user) + }); + } + //#endregion + + publishReversiGameStream(this.gameId!, 'started', + await ReversiGames.pack(this.gameId!, this.user)); + }, 3000); + } + } + + // 石を打つ + @autobind + private async set(pos: number) { + if (this.user == null) return; + + const game = await ReversiGames.findOne(this.gameId!); + if (game == null) throw new Error('game not found'); + + if (!game.isStarted) return; + if (game.isEnded) return; + if ((game.user1Id !== this.user.id) && (game.user2Id !== this.user.id)) return; + + const myColor = + ((game.user1Id === this.user.id) && game.black == 1) || ((game.user2Id === this.user.id) && game.black == 2) + ? true + : false; + + const o = new Reversi(game.map, { + isLlotheo: game.isLlotheo, + canPutEverywhere: game.canPutEverywhere, + loopedBoard: game.loopedBoard + }); + + // 盤面の状態を再生 + for (const log of game.logs) { + o.put(log.color, log.pos); + } + + if (o.turn !== myColor) return; + + if (!o.canPut(myColor, pos)) return; + o.put(myColor, pos); + + let winner; + if (o.isEnded) { + if (o.winner === true) { + winner = game.black == 1 ? game.user1Id : game.user2Id; + } else if (o.winner === false) { + winner = game.black == 1 ? game.user2Id : game.user1Id; + } else { + winner = null; + } + } + + const log = { + at: new Date(), + color: myColor, + pos + }; + + const crc32 = CRC32.str(game.logs.map(x => x.pos.toString()).join('') + pos.toString()).toString(); + + game.logs.push(log); + + await ReversiGames.update(this.gameId!, { + crc32, + isEnded: o.isEnded, + winnerId: winner, + logs: game.logs + }); + + publishReversiGameStream(this.gameId!, 'set', Object.assign(log, { + next: o.turn + })); + + if (o.isEnded) { + publishReversiGameStream(this.gameId!, 'ended', { + winnerId: winner, + game: await ReversiGames.pack(this.gameId!, this.user) + }); + } + } + + @autobind + private async check(crc32: string | number) { + const game = await ReversiGames.findOne(this.gameId!); + if (game == null) throw new Error('game not found'); + + if (!game.isStarted) return; + + if (crc32.toString() !== game.crc32) { + this.send('rescue', await ReversiGames.pack(game, this.user)); + } + + // ついでに観戦者イベントを発行 + this.watch(game); + } + + @autobind + private watch(game: ReversiGame) { + if (this.user != null) { + if ((game.user1Id !== this.user.id) && (game.user2Id !== this.user.id)) { + publishReversiGameStream(this.gameId!, 'watching', this.user.id); + } + } + } +} diff --git a/packages/backend/src/server/api/stream/channels/games/reversi.ts b/packages/backend/src/server/api/stream/channels/games/reversi.ts new file mode 100644 index 0000000000..3b89aac35c --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/games/reversi.ts @@ -0,0 +1,33 @@ +import autobind from 'autobind-decorator'; +import { publishMainStream } from '@/services/stream'; +import Channel from '../../channel'; +import { ReversiMatchings } from '@/models/index'; + +export default class extends Channel { + public readonly chName = 'gamesReversi'; + public static shouldShare = true; + public static requireCredential = true; + + @autobind + public async init(params: any) { + // Subscribe reversi stream + this.subscriber.on(`reversiStream:${this.user!.id}`, data => { + this.send(data); + }); + } + + @autobind + public async onMessage(type: string, body: any) { + switch (type) { + case 'ping': + if (body.id == null) return; + const matching = await ReversiMatchings.findOne({ + parentId: this.user!.id, + childId: body.id + }); + if (matching == null) return; + publishMainStream(matching.childId, 'reversiInvited', await ReversiMatchings.pack(matching, { id: matching.childId })); + break; + } + } +} diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts new file mode 100644 index 0000000000..f5983ab472 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -0,0 +1,73 @@ +import autobind from 'autobind-decorator'; +import { isMutedUserRelated } from '@/misc/is-muted-user-related'; +import Channel from '../channel'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Notes } from '@/models/index'; +import { checkWordMute } from '@/misc/check-word-mute'; +import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { Packed } from '@/misc/schema'; + +export default class extends Channel { + public readonly chName = 'globalTimeline'; + public static shouldShare = true; + public static requireCredential = false; + + @autobind + public async init(params: any) { + const meta = await fetchMeta(); + if (meta.disableGlobalTimeline) { + if (this.user == null || (!this.user.isAdmin && !this.user.isModerator)) return; + } + + // Subscribe events + this.subscriber.on('notesStream', this.onNote); + } + + @autobind + private async onNote(note: Packed<'Note'>) { + if (note.visibility !== 'public') return; + if (note.channelId != null) return; + + // リプライなら再pack + if (note.replyId != null) { + note.reply = await Notes.pack(note.replyId, this.user, { + detail: true + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user, { + detail: true + }); + } + + // 関係ない返信は除外 + if (note.reply) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isMutedUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isBlockerUserRelated(note, this.blocking)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts new file mode 100644 index 0000000000..281be4f2eb --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -0,0 +1,53 @@ +import autobind from 'autobind-decorator'; +import { isMutedUserRelated } from '@/misc/is-muted-user-related'; +import Channel from '../channel'; +import { Notes } from '@/models/index'; +import { normalizeForSearch } from '@/misc/normalize-for-search'; +import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { Packed } from '@/misc/schema'; + +export default class extends Channel { + public readonly chName = 'hashtag'; + public static shouldShare = false; + public static requireCredential = false; + private q: string[][]; + + @autobind + public async init(params: any) { + this.q = params.q; + + if (this.q == null) return; + + // Subscribe stream + this.subscriber.on('notesStream', this.onNote); + } + + @autobind + private async onNote(note: Packed<'Note'>) { + const noteTags = note.tags ? note.tags.map((t: string) => t.toLowerCase()) : []; + const matched = this.q.some(tags => tags.every(tag => noteTags.includes(normalizeForSearch(tag)))); + if (!matched) return; + + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user, { + detail: true + }); + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isMutedUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isBlockerUserRelated(note, this.blocking)) return; + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts new file mode 100644 index 0000000000..52e9aec250 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -0,0 +1,81 @@ +import autobind from 'autobind-decorator'; +import { isMutedUserRelated } from '@/misc/is-muted-user-related'; +import Channel from '../channel'; +import { Notes } from '@/models/index'; +import { checkWordMute } from '@/misc/check-word-mute'; +import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { Packed } from '@/misc/schema'; + +export default class extends Channel { + public readonly chName = 'homeTimeline'; + public static shouldShare = true; + public static requireCredential = true; + + @autobind + public async init(params: any) { + // Subscribe events + this.subscriber.on('notesStream', this.onNote); + } + + @autobind + private async onNote(note: Packed<'Note'>) { + if (note.channelId) { + if (!this.followingChannels.has(note.channelId)) return; + } else { + // その投稿のユーザーをフォローしていなかったら弾く + if ((this.user!.id !== note.userId) && !this.following.has(note.userId)) return; + } + + if (['followers', 'specified'].includes(note.visibility)) { + note = await Notes.pack(note.id, this.user!, { + detail: true + }); + + if (note.isHidden) { + return; + } + } else { + // リプライなら再pack + if (note.replyId != null) { + note.reply = await Notes.pack(note.replyId, this.user!, { + detail: true + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user!, { + detail: true + }); + } + } + + // 関係ない返信は除外 + if (note.reply) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isMutedUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isBlockerUserRelated(note, this.blocking)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts new file mode 100644 index 0000000000..51f95fc0cd --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -0,0 +1,89 @@ +import autobind from 'autobind-decorator'; +import { isMutedUserRelated } from '@/misc/is-muted-user-related'; +import Channel from '../channel'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Notes } from '@/models/index'; +import { checkWordMute } from '@/misc/check-word-mute'; +import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { Packed } from '@/misc/schema'; + +export default class extends Channel { + public readonly chName = 'hybridTimeline'; + public static shouldShare = true; + public static requireCredential = true; + + @autobind + public async init(params: any) { + const meta = await fetchMeta(); + if (meta.disableLocalTimeline && !this.user!.isAdmin && !this.user!.isModerator) return; + + // Subscribe events + this.subscriber.on('notesStream', this.onNote); + } + + @autobind + private async onNote(note: Packed<'Note'>) { + // チャンネルの投稿ではなく、自分自身の投稿 または + // チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または + // チャンネルの投稿ではなく、全体公開のローカルの投稿 または + // フォローしているチャンネルの投稿 の場合だけ + if (!( + (note.channelId == null && this.user!.id === note.userId) || + (note.channelId == null && this.following.has(note.userId)) || + (note.channelId == null && (note.user.host == null && note.visibility === 'public')) || + (note.channelId != null && this.followingChannels.has(note.channelId)) + )) return; + + if (['followers', 'specified'].includes(note.visibility)) { + note = await Notes.pack(note.id, this.user!, { + detail: true + }); + + if (note.isHidden) { + return; + } + } else { + // リプライなら再pack + if (note.replyId != null) { + note.reply = await Notes.pack(note.replyId, this.user!, { + detail: true + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user!, { + detail: true + }); + } + } + + // 関係ない返信は除外 + if (note.reply) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isMutedUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isBlockerUserRelated(note, this.blocking)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} diff --git a/packages/backend/src/server/api/stream/channels/index.ts b/packages/backend/src/server/api/stream/channels/index.ts new file mode 100644 index 0000000000..1841573043 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/index.ts @@ -0,0 +1,37 @@ +import main from './main'; +import homeTimeline from './home-timeline'; +import localTimeline from './local-timeline'; +import hybridTimeline from './hybrid-timeline'; +import globalTimeline from './global-timeline'; +import serverStats from './server-stats'; +import queueStats from './queue-stats'; +import userList from './user-list'; +import antenna from './antenna'; +import messaging from './messaging'; +import messagingIndex from './messaging-index'; +import drive from './drive'; +import hashtag from './hashtag'; +import channel from './channel'; +import admin from './admin'; +import gamesReversi from './games/reversi'; +import gamesReversiGame from './games/reversi-game'; + +export default { + main, + homeTimeline, + localTimeline, + hybridTimeline, + globalTimeline, + serverStats, + queueStats, + userList, + antenna, + messaging, + messagingIndex, + drive, + hashtag, + channel, + admin, + gamesReversi, + gamesReversiGame +}; diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts new file mode 100644 index 0000000000..a6166c2be2 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -0,0 +1,74 @@ +import autobind from 'autobind-decorator'; +import { isMutedUserRelated } from '@/misc/is-muted-user-related'; +import Channel from '../channel'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Notes } from '@/models/index'; +import { checkWordMute } from '@/misc/check-word-mute'; +import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { Packed } from '@/misc/schema'; + +export default class extends Channel { + public readonly chName = 'localTimeline'; + public static shouldShare = true; + public static requireCredential = false; + + @autobind + public async init(params: any) { + const meta = await fetchMeta(); + if (meta.disableLocalTimeline) { + if (this.user == null || (!this.user.isAdmin && !this.user.isModerator)) return; + } + + // Subscribe events + this.subscriber.on('notesStream', this.onNote); + } + + @autobind + private async onNote(note: Packed<'Note'>) { + if (note.user.host !== null) return; + if (note.visibility !== 'public') return; + if (note.channelId != null && !this.followingChannels.has(note.channelId)) return; + + // リプライなら再pack + if (note.replyId != null) { + note.reply = await Notes.pack(note.replyId, this.user, { + detail: true + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user, { + detail: true + }); + } + + // 関係ない返信は除外 + if (note.reply) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isMutedUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isBlockerUserRelated(note, this.blocking)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} diff --git a/packages/backend/src/server/api/stream/channels/main.ts b/packages/backend/src/server/api/stream/channels/main.ts new file mode 100644 index 0000000000..131ac30472 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/main.ts @@ -0,0 +1,43 @@ +import autobind from 'autobind-decorator'; +import Channel from '../channel'; +import { Notes } from '@/models/index'; + +export default class extends Channel { + public readonly chName = 'main'; + public static shouldShare = true; + public static requireCredential = true; + + @autobind + public async init(params: any) { + // Subscribe main stream channel + this.subscriber.on(`mainStream:${this.user!.id}`, async data => { + switch (data.type) { + case 'notification': { + if (data.body.userId && this.muting.has(data.body.userId)) return; + + if (data.body.note && data.body.note.isHidden) { + const note = await Notes.pack(data.body.note.id, this.user, { + detail: true + }); + this.connection.cacheNote(note); + data.body.note = note; + } + break; + } + case 'mention': { + if (this.muting.has(data.body.userId)) return; + if (data.body.isHidden) { + const note = await Notes.pack(data.body.id, this.user, { + detail: true + }); + this.connection.cacheNote(note); + data.body = note; + } + break; + } + } + + this.send(data.type, data.body); + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/messaging-index.ts b/packages/backend/src/server/api/stream/channels/messaging-index.ts new file mode 100644 index 0000000000..0c495398ab --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/messaging-index.ts @@ -0,0 +1,16 @@ +import autobind from 'autobind-decorator'; +import Channel from '../channel'; + +export default class extends Channel { + public readonly chName = 'messagingIndex'; + public static shouldShare = true; + public static requireCredential = true; + + @autobind + public async init(params: any) { + // Subscribe messaging index stream + this.subscriber.on(`messagingIndexStream:${this.user!.id}`, data => { + this.send(data); + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/messaging.ts b/packages/backend/src/server/api/stream/channels/messaging.ts new file mode 100644 index 0000000000..c049e880b9 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/messaging.ts @@ -0,0 +1,106 @@ +import autobind from 'autobind-decorator'; +import { readUserMessagingMessage, readGroupMessagingMessage, deliverReadActivity } from '../../common/read-messaging-message'; +import Channel from '../channel'; +import { UserGroupJoinings, Users, MessagingMessages } from '@/models/index'; +import { User, ILocalUser, IRemoteUser } from '@/models/entities/user'; +import { UserGroup } from '@/models/entities/user-group'; +import { StreamMessages } from '../types'; + +export default class extends Channel { + public readonly chName = 'messaging'; + public static shouldShare = false; + public static requireCredential = true; + + private otherpartyId: string | null; + private otherparty: User | null; + private groupId: string | null; + private subCh: `messagingStream:${User['id']}-${User['id']}` | `messagingStream:${UserGroup['id']}`; + private typers: Record<User['id'], Date> = {}; + private emitTypersIntervalId: ReturnType<typeof setInterval>; + + @autobind + public async init(params: any) { + this.otherpartyId = params.otherparty; + this.otherparty = this.otherpartyId ? await Users.findOneOrFail({ id: this.otherpartyId }) : null; + this.groupId = params.group; + + // Check joining + if (this.groupId) { + const joining = await UserGroupJoinings.findOne({ + userId: this.user!.id, + userGroupId: this.groupId + }); + + if (joining == null) { + return; + } + } + + this.emitTypersIntervalId = setInterval(this.emitTypers, 5000); + + this.subCh = this.otherpartyId + ? `messagingStream:${this.user!.id}-${this.otherpartyId}` + : `messagingStream:${this.groupId}`; + + // Subscribe messaging stream + this.subscriber.on(this.subCh, this.onEvent); + } + + @autobind + private onEvent(data: StreamMessages['messaging']['payload'] | StreamMessages['groupMessaging']['payload']) { + if (data.type === 'typing') { + const id = data.body; + const begin = this.typers[id] == null; + this.typers[id] = new Date(); + if (begin) { + this.emitTypers(); + } + } else { + this.send(data); + } + } + + @autobind + public onMessage(type: string, body: any) { + switch (type) { + case 'read': + if (this.otherpartyId) { + readUserMessagingMessage(this.user!.id, this.otherpartyId, [body.id]); + + // リモートユーザーからのメッセージだったら既読配信 + if (Users.isLocalUser(this.user!) && Users.isRemoteUser(this.otherparty!)) { + MessagingMessages.findOne(body.id).then(message => { + if (message) deliverReadActivity(this.user as ILocalUser, this.otherparty as IRemoteUser, message); + }); + } + } else if (this.groupId) { + readGroupMessagingMessage(this.user!.id, this.groupId, [body.id]); + } + break; + } + } + + @autobind + private async emitTypers() { + const now = new Date(); + + // Remove not typing users + for (const [userId, date] of Object.entries(this.typers)) { + if (now.getTime() - date.getTime() > 5000) delete this.typers[userId]; + } + + const users = await Users.packMany(Object.keys(this.typers), null, { detail: false }); + + this.send({ + type: 'typers', + body: users, + }); + } + + @autobind + public dispose() { + this.subscriber.off(this.subCh, this.onEvent); + + clearInterval(this.emitTypersIntervalId); + } +} diff --git a/packages/backend/src/server/api/stream/channels/queue-stats.ts b/packages/backend/src/server/api/stream/channels/queue-stats.ts new file mode 100644 index 0000000000..0bda0cfcb9 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/queue-stats.ts @@ -0,0 +1,41 @@ +import autobind from 'autobind-decorator'; +import Xev from 'xev'; +import Channel from '../channel'; + +const ev = new Xev(); + +export default class extends Channel { + public readonly chName = 'queueStats'; + public static shouldShare = true; + public static requireCredential = false; + + @autobind + public async init(params: any) { + ev.addListener('queueStats', this.onStats); + } + + @autobind + private onStats(stats: any) { + this.send('stats', stats); + } + + @autobind + public onMessage(type: string, body: any) { + switch (type) { + case 'requestLog': + ev.once(`queueStatsLog:${body.id}`, statsLog => { + this.send('statsLog', statsLog); + }); + ev.emit('requestQueueStatsLog', { + id: body.id, + length: body.length + }); + break; + } + } + + @autobind + public dispose() { + ev.removeListener('queueStats', this.onStats); + } +} diff --git a/packages/backend/src/server/api/stream/channels/server-stats.ts b/packages/backend/src/server/api/stream/channels/server-stats.ts new file mode 100644 index 0000000000..d245a7f70c --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/server-stats.ts @@ -0,0 +1,41 @@ +import autobind from 'autobind-decorator'; +import Xev from 'xev'; +import Channel from '../channel'; + +const ev = new Xev(); + +export default class extends Channel { + public readonly chName = 'serverStats'; + public static shouldShare = true; + public static requireCredential = false; + + @autobind + public async init(params: any) { + ev.addListener('serverStats', this.onStats); + } + + @autobind + private onStats(stats: any) { + this.send('stats', stats); + } + + @autobind + public onMessage(type: string, body: any) { + switch (type) { + case 'requestLog': + ev.once(`serverStatsLog:${body.id}`, statsLog => { + this.send('statsLog', statsLog); + }); + ev.emit('requestServerStatsLog', { + id: body.id, + length: body.length + }); + break; + } + } + + @autobind + public dispose() { + ev.removeListener('serverStats', this.onStats); + } +} diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts new file mode 100644 index 0000000000..63b254605b --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -0,0 +1,92 @@ +import autobind from 'autobind-decorator'; +import Channel from '../channel'; +import { Notes, UserListJoinings, UserLists } from '@/models/index'; +import { isMutedUserRelated } from '@/misc/is-muted-user-related'; +import { User } from '@/models/entities/user'; +import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { Packed } from '@/misc/schema'; + +export default class extends Channel { + public readonly chName = 'userList'; + public static shouldShare = false; + public static requireCredential = false; + private listId: string; + public listUsers: User['id'][] = []; + private listUsersClock: NodeJS.Timer; + + @autobind + public async init(params: any) { + this.listId = params.listId as string; + + // Check existence and owner + const list = await UserLists.findOne({ + id: this.listId, + userId: this.user!.id + }); + if (!list) return; + + // Subscribe stream + this.subscriber.on(`userListStream:${this.listId}`, this.send); + + this.subscriber.on('notesStream', this.onNote); + + this.updateListUsers(); + this.listUsersClock = setInterval(this.updateListUsers, 5000); + } + + @autobind + private async updateListUsers() { + const users = await UserListJoinings.find({ + where: { + userListId: this.listId, + }, + select: ['userId'] + }); + + this.listUsers = users.map(x => x.userId); + } + + @autobind + private async onNote(note: Packed<'Note'>) { + if (!this.listUsers.includes(note.userId)) return; + + if (['followers', 'specified'].includes(note.visibility)) { + note = await Notes.pack(note.id, this.user, { + detail: true + }); + + if (note.isHidden) { + return; + } + } else { + // リプライなら再pack + if (note.replyId != null) { + note.reply = await Notes.pack(note.replyId, this.user, { + detail: true + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user, { + detail: true + }); + } + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isMutedUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isBlockerUserRelated(note, this.blocking)) return; + + this.send('note', note); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off(`userListStream:${this.listId}`, this.send); + this.subscriber.off('notesStream', this.onNote); + + clearInterval(this.listUsersClock); + } +} diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts new file mode 100644 index 0000000000..da4ea5ec99 --- /dev/null +++ b/packages/backend/src/server/api/stream/index.ts @@ -0,0 +1,421 @@ +import autobind from 'autobind-decorator'; +import * as websocket from 'websocket'; +import { readNotification } from '../common/read-notification'; +import call from '../call'; +import readNote from '@/services/note/read'; +import Channel from './channel'; +import channels from './channels/index'; +import { EventEmitter } from 'events'; +import { User } from '@/models/entities/user'; +import { Channel as ChannelModel } from '@/models/entities/channel'; +import { Users, Followings, Mutings, UserProfiles, ChannelFollowings, Blockings } from '@/models/index'; +import { ApiError } from '../error'; +import { AccessToken } from '@/models/entities/access-token'; +import { UserProfile } from '@/models/entities/user-profile'; +import { publishChannelStream, publishGroupMessagingStream, publishMessagingStream } from '@/services/stream'; +import { UserGroup } from '@/models/entities/user-group'; +import { StreamEventEmitter, StreamMessages } from './types'; +import { Packed } from '@/misc/schema'; + +/** + * Main stream connection + */ +export default class Connection { + public user?: User; + public userProfile?: UserProfile; + public following: Set<User['id']> = new Set(); + public muting: Set<User['id']> = new Set(); + public blocking: Set<User['id']> = new Set(); // "被"blocking + public followingChannels: Set<ChannelModel['id']> = new Set(); + public token?: AccessToken; + private wsConnection: websocket.connection; + public subscriber: StreamEventEmitter; + private channels: Channel[] = []; + private subscribingNotes: any = {}; + private cachedNotes: Packed<'Note'>[] = []; + + constructor( + wsConnection: websocket.connection, + subscriber: EventEmitter, + user: User | null | undefined, + token: AccessToken | null | undefined + ) { + this.wsConnection = wsConnection; + this.subscriber = subscriber; + if (user) this.user = user; + if (token) this.token = token; + + this.wsConnection.on('message', this.onWsConnectionMessage); + + this.subscriber.on('broadcast', data => { + this.onBroadcastMessage(data); + }); + + if (this.user) { + this.updateFollowing(); + this.updateMuting(); + this.updateBlocking(); + this.updateFollowingChannels(); + this.updateUserProfile(); + + this.subscriber.on(`user:${this.user.id}`, this.onUserEvent); + } + } + + @autobind + private onUserEvent(data: StreamMessages['user']['payload']) { // { type, body }と展開するとそれぞれ型が分離してしまう + switch (data.type) { + case 'follow': + this.following.add(data.body.id); + break; + + case 'unfollow': + this.following.delete(data.body.id); + break; + + case 'mute': + this.muting.add(data.body.id); + break; + + case 'unmute': + this.muting.delete(data.body.id); + break; + + // TODO: block events + + case 'followChannel': + this.followingChannels.add(data.body.id); + break; + + case 'unfollowChannel': + this.followingChannels.delete(data.body.id); + break; + + case 'updateUserProfile': + this.userProfile = data.body; + break; + + case 'terminate': + this.wsConnection.close(); + this.dispose(); + break; + + default: + break; + } + } + + /** + * クライアントからメッセージ受信時 + */ + @autobind + private async onWsConnectionMessage(data: websocket.IMessage) { + if (data.utf8Data == null) return; + + let obj: Record<string, any>; + + try { + obj = JSON.parse(data.utf8Data); + } catch (e) { + return; + } + + const { type, body } = obj; + + switch (type) { + case 'api': this.onApiRequest(body); break; + case 'readNotification': this.onReadNotification(body); break; + case 'subNote': this.onSubscribeNote(body); break; + case 's': this.onSubscribeNote(body); break; // alias + case 'sr': this.onSubscribeNote(body); this.readNote(body); break; + case 'unsubNote': this.onUnsubscribeNote(body); break; + case 'un': this.onUnsubscribeNote(body); break; // alias + case 'connect': this.onChannelConnectRequested(body); break; + case 'disconnect': this.onChannelDisconnectRequested(body); break; + case 'channel': this.onChannelMessageRequested(body); break; + case 'ch': this.onChannelMessageRequested(body); break; // alias + + // 個々のチャンネルではなくルートレベルでこれらのメッセージを受け取る理由は、 + // クライアントの事情を考慮したとき、入力フォームはノートチャンネルやメッセージのメインコンポーネントとは別 + // なこともあるため、それらのコンポーネントがそれぞれ各チャンネルに接続するようにするのは面倒なため。 + case 'typingOnChannel': this.typingOnChannel(body.channel); break; + case 'typingOnMessaging': this.typingOnMessaging(body); break; + } + } + + @autobind + private onBroadcastMessage(data: StreamMessages['broadcast']['payload']) { + this.sendMessageToWs(data.type, data.body); + } + + @autobind + public cacheNote(note: Packed<'Note'>) { + const add = (note: Packed<'Note'>) => { + const existIndex = this.cachedNotes.findIndex(n => n.id === note.id); + if (existIndex > -1) { + this.cachedNotes[existIndex] = note; + return; + } + + this.cachedNotes.unshift(note); + if (this.cachedNotes.length > 32) { + this.cachedNotes.splice(32); + } + }; + + add(note); + if (note.reply) add(note.reply); + if (note.renote) add(note.renote); + } + + @autobind + private readNote(body: any) { + const id = body.id; + + const note = this.cachedNotes.find(n => n.id === id); + if (note == null) return; + + if (this.user && (note.userId !== this.user.id)) { + readNote(this.user.id, [note], { + following: this.following, + followingChannels: this.followingChannels, + }); + } + } + + /** + * APIリクエスト要求時 + */ + @autobind + private async onApiRequest(payload: any) { + // 新鮮なデータを利用するためにユーザーをフェッチ + const user = this.user ? await Users.findOne(this.user.id) : null; + + const endpoint = payload.endpoint || payload.ep; // alias + + // 呼び出し + call(endpoint, user, this.token, payload.data).then(res => { + this.sendMessageToWs(`api:${payload.id}`, { res }); + }).catch((e: ApiError) => { + this.sendMessageToWs(`api:${payload.id}`, { + error: { + message: e.message, + code: e.code, + id: e.id, + kind: e.kind, + ...(e.info ? { info: e.info } : {}) + } + }); + }); + } + + @autobind + private onReadNotification(payload: any) { + if (!payload.id) return; + readNotification(this.user!.id, [payload.id]); + } + + /** + * 投稿購読要求時 + */ + @autobind + private onSubscribeNote(payload: any) { + if (!payload.id) return; + + if (this.subscribingNotes[payload.id] == null) { + this.subscribingNotes[payload.id] = 0; + } + + this.subscribingNotes[payload.id]++; + + if (this.subscribingNotes[payload.id] === 1) { + this.subscriber.on(`noteStream:${payload.id}`, this.onNoteStreamMessage); + } + } + + /** + * 投稿購読解除要求時 + */ + @autobind + private onUnsubscribeNote(payload: any) { + if (!payload.id) return; + + this.subscribingNotes[payload.id]--; + if (this.subscribingNotes[payload.id] <= 0) { + delete this.subscribingNotes[payload.id]; + this.subscriber.off(`noteStream:${payload.id}`, this.onNoteStreamMessage); + } + } + + @autobind + private async onNoteStreamMessage(data: StreamMessages['note']['payload']) { + this.sendMessageToWs('noteUpdated', { + id: data.body.id, + type: data.type, + body: data.body.body, + }); + } + + /** + * チャンネル接続要求時 + */ + @autobind + private onChannelConnectRequested(payload: any) { + const { channel, id, params, pong } = payload; + this.connectChannel(id, params, channel, pong); + } + + /** + * チャンネル切断要求時 + */ + @autobind + private onChannelDisconnectRequested(payload: any) { + const { id } = payload; + this.disconnectChannel(id); + } + + /** + * クライアントにメッセージ送信 + */ + @autobind + public sendMessageToWs(type: string, payload: any) { + this.wsConnection.send(JSON.stringify({ + type: type, + body: payload + })); + } + + /** + * チャンネルに接続 + */ + @autobind + public connectChannel(id: string, params: any, channel: string, pong = false) { + if ((channels as any)[channel].requireCredential && this.user == null) { + return; + } + + // 共有可能チャンネルに接続しようとしていて、かつそのチャンネルに既に接続していたら無意味なので無視 + if ((channels as any)[channel].shouldShare && this.channels.some(c => c.chName === channel)) { + return; + } + + const ch: Channel = new (channels as any)[channel](id, this); + this.channels.push(ch); + ch.init(params); + + if (pong) { + this.sendMessageToWs('connected', { + id: id + }); + } + } + + /** + * チャンネルから切断 + * @param id チャンネルコネクションID + */ + @autobind + public disconnectChannel(id: string) { + const channel = this.channels.find(c => c.id === id); + + if (channel) { + if (channel.dispose) channel.dispose(); + this.channels = this.channels.filter(c => c.id !== id); + } + } + + /** + * チャンネルへメッセージ送信要求時 + * @param data メッセージ + */ + @autobind + private onChannelMessageRequested(data: any) { + const channel = this.channels.find(c => c.id === data.id); + if (channel != null && channel.onMessage != null) { + channel.onMessage(data.type, data.body); + } + } + + @autobind + private typingOnChannel(channel: ChannelModel['id']) { + if (this.user) { + publishChannelStream(channel, 'typing', this.user.id); + } + } + + @autobind + private typingOnMessaging(param: { partner?: User['id']; group?: UserGroup['id']; }) { + if (this.user) { + if (param.partner) { + publishMessagingStream(param.partner, this.user.id, 'typing', this.user.id); + } else if (param.group) { + publishGroupMessagingStream(param.group, 'typing', this.user.id); + } + } + } + + @autobind + private async updateFollowing() { + const followings = await Followings.find({ + where: { + followerId: this.user!.id + }, + select: ['followeeId'] + }); + + this.following = new Set<string>(followings.map(x => x.followeeId)); + } + + @autobind + private async updateMuting() { + const mutings = await Mutings.find({ + where: { + muterId: this.user!.id + }, + select: ['muteeId'] + }); + + this.muting = new Set<string>(mutings.map(x => x.muteeId)); + } + + @autobind + private async updateBlocking() { // ここでいうBlockingは被Blockingの意 + const blockings = await Blockings.find({ + where: { + blockeeId: this.user!.id + }, + select: ['blockerId'] + }); + + this.blocking = new Set<string>(blockings.map(x => x.blockerId)); + } + + @autobind + private async updateFollowingChannels() { + const followings = await ChannelFollowings.find({ + where: { + followerId: this.user!.id + }, + select: ['followeeId'] + }); + + this.followingChannels = new Set<string>(followings.map(x => x.followeeId)); + } + + @autobind + private async updateUserProfile() { + this.userProfile = await UserProfiles.findOne({ + userId: this.user!.id + }); + } + + /** + * ストリームが切れたとき + */ + @autobind + public dispose() { + for (const c of this.channels.filter(c => c.dispose)) { + if (c.dispose) c.dispose(); + } + } +} diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts new file mode 100644 index 0000000000..70eb5c5ce5 --- /dev/null +++ b/packages/backend/src/server/api/stream/types.ts @@ -0,0 +1,299 @@ +import { EventEmitter } from 'events'; +import Emitter from 'strict-event-emitter-types'; +import { Channel } from '@/models/entities/channel'; +import { User } from '@/models/entities/user'; +import { UserProfile } from '@/models/entities/user-profile'; +import { Note } from '@/models/entities/note'; +import { Antenna } from '@/models/entities/antenna'; +import { DriveFile } from '@/models/entities/drive-file'; +import { DriveFolder } from '@/models/entities/drive-folder'; +import { Emoji } from '@/models/entities/emoji'; +import { UserList } from '@/models/entities/user-list'; +import { MessagingMessage } from '@/models/entities/messaging-message'; +import { UserGroup } from '@/models/entities/user-group'; +import { ReversiGame } from '@/models/entities/games/reversi/game'; +import { AbuseUserReport } from '@/models/entities/abuse-user-report'; +import { Signin } from '@/models/entities/signin'; +import { Page } from '@/models/entities/page'; +import { Packed } from '@/misc/schema'; + +//#region Stream type-body definitions +export interface InternalStreamTypes { + antennaCreated: Antenna; + antennaDeleted: Antenna; + antennaUpdated: Antenna; +} + +export interface BroadcastTypes { + emojiAdded: { + emoji: Packed<'Emoji'>; + }; +} + +export interface UserStreamTypes { + terminate: {}; + followChannel: Channel; + unfollowChannel: Channel; + updateUserProfile: UserProfile; + mute: User; + unmute: User; + follow: Packed<'User'>; + unfollow: Packed<'User'>; + userAdded: Packed<'User'>; +} + +export interface MainStreamTypes { + notification: Packed<'Notification'>; + mention: Packed<'Note'>; + reply: Packed<'Note'>; + renote: Packed<'Note'>; + follow: Packed<'User'>; + followed: Packed<'User'>; + unfollow: Packed<'User'>; + meUpdated: Packed<'User'>; + pageEvent: { + pageId: Page['id']; + event: string; + var: any; + userId: User['id']; + user: Packed<'User'>; + }; + urlUploadFinished: { + marker?: string | null; + file: Packed<'DriveFile'>; + }; + readAllNotifications: undefined; + unreadNotification: Packed<'Notification'>; + unreadMention: Note['id']; + readAllUnreadMentions: undefined; + unreadSpecifiedNote: Note['id']; + readAllUnreadSpecifiedNotes: undefined; + readAllMessagingMessages: undefined; + messagingMessage: Packed<'MessagingMessage'>; + unreadMessagingMessage: Packed<'MessagingMessage'>; + readAllAntennas: undefined; + unreadAntenna: Antenna; + readAllAnnouncements: undefined; + readAllChannels: undefined; + unreadChannel: Note['id']; + myTokenRegenerated: undefined; + reversiNoInvites: undefined; + reversiInvited: Packed<'ReversiMatching'>; + signin: Signin; + registryUpdated: { + scope?: string[]; + key: string; + value: any | null; + }; + driveFileCreated: Packed<'DriveFile'>; + readAntenna: Antenna; +} + +export interface DriveStreamTypes { + fileCreated: Packed<'DriveFile'>; + fileDeleted: DriveFile['id']; + fileUpdated: Packed<'DriveFile'>; + folderCreated: Packed<'DriveFolder'>; + folderDeleted: DriveFolder['id']; + folderUpdated: Packed<'DriveFolder'>; +} + +export interface NoteStreamTypes { + pollVoted: { + choice: number; + userId: User['id']; + }; + deleted: { + deletedAt: Date; + }; + reacted: { + reaction: string; + emoji?: Emoji; + userId: User['id']; + }; + unreacted: { + reaction: string; + userId: User['id']; + }; +} +type NoteStreamEventTypes = { + [key in keyof NoteStreamTypes]: { + id: Note['id']; + body: NoteStreamTypes[key]; + }; +}; + +export interface ChannelStreamTypes { + typing: User['id']; +} + +export interface UserListStreamTypes { + userAdded: Packed<'User'>; + userRemoved: Packed<'User'>; +} + +export interface AntennaStreamTypes { + note: Note; +} + +export interface MessagingStreamTypes { + read: MessagingMessage['id'][]; + typing: User['id']; + message: Packed<'MessagingMessage'>; + deleted: MessagingMessage['id']; +} + +export interface GroupMessagingStreamTypes { + read: { + ids: MessagingMessage['id'][]; + userId: User['id']; + }; + typing: User['id']; + message: Packed<'MessagingMessage'>; + deleted: MessagingMessage['id']; +} + +export interface MessagingIndexStreamTypes { + read: MessagingMessage['id'][]; + message: Packed<'MessagingMessage'>; +} + +export interface ReversiStreamTypes { + matched: Packed<'ReversiGame'>; + invited: Packed<'ReversiMatching'>; +} + +export interface ReversiGameStreamTypes { + started: Packed<'ReversiGame'>; + ended: { + winnerId?: User['id'] | null, + game: Packed<'ReversiGame'>; + }; + updateSettings: { + key: string; + value: FIXME; + }; + initForm: { + userId: User['id']; + form: FIXME; + }; + updateForm: { + userId: User['id']; + id: string; + value: FIXME; + }; + message: { + userId: User['id']; + message: FIXME; + }; + changeAccepts: { + user1: boolean; + user2: boolean; + }; + set: { + at: Date; + color: boolean; + pos: number; + next: boolean; + }; + watching: User['id']; +} + +export interface AdminStreamTypes { + newAbuseUserReport: { + id: AbuseUserReport['id']; + targetUserId: User['id'], + reporterId: User['id'], + comment: string; + }; +} +//#endregion + +// 辞書(interface or type)から{ type, body }ユニオンを定義 +// https://stackoverflow.com/questions/49311989/can-i-infer-the-type-of-a-value-using-extends-keyof-type +// VS Codeの展開を防止するためにEvents型を定義 +type Events<T extends object> = { [K in keyof T]: { type: K; body: T[K]; } }; +type EventUnionFromDictionary< + T extends object, + U = Events<T> +> = U[keyof U]; + +// name/messages(spec) pairs dictionary +export type StreamMessages = { + internal: { + name: 'internal'; + payload: EventUnionFromDictionary<InternalStreamTypes>; + }; + broadcast: { + name: 'broadcast'; + payload: EventUnionFromDictionary<BroadcastTypes>; + }; + user: { + name: `user:${User['id']}`; + payload: EventUnionFromDictionary<UserStreamTypes>; + }; + main: { + name: `mainStream:${User['id']}`; + payload: EventUnionFromDictionary<MainStreamTypes>; + }; + drive: { + name: `driveStream:${User['id']}`; + payload: EventUnionFromDictionary<DriveStreamTypes>; + }; + note: { + name: `noteStream:${Note['id']}`; + payload: EventUnionFromDictionary<NoteStreamEventTypes>; + }; + channel: { + name: `channelStream:${Channel['id']}`; + payload: EventUnionFromDictionary<ChannelStreamTypes>; + }; + userList: { + name: `userListStream:${UserList['id']}`; + payload: EventUnionFromDictionary<UserListStreamTypes>; + }; + antenna: { + name: `antennaStream:${Antenna['id']}`; + payload: EventUnionFromDictionary<AntennaStreamTypes>; + }; + messaging: { + name: `messagingStream:${User['id']}-${User['id']}`; + payload: EventUnionFromDictionary<MessagingStreamTypes>; + }; + groupMessaging: { + name: `messagingStream:${UserGroup['id']}`; + payload: EventUnionFromDictionary<GroupMessagingStreamTypes>; + }; + messagingIndex: { + name: `messagingIndexStream:${User['id']}`; + payload: EventUnionFromDictionary<MessagingIndexStreamTypes>; + }; + reversi: { + name: `reversiStream:${User['id']}`; + payload: EventUnionFromDictionary<ReversiStreamTypes>; + }; + reversiGame: { + name: `reversiGameStream:${ReversiGame['id']}`; + payload: EventUnionFromDictionary<ReversiGameStreamTypes>; + }; + admin: { + name: `adminStream:${User['id']}`; + payload: EventUnionFromDictionary<AdminStreamTypes>; + }; + notes: { + name: 'notesStream'; + payload: Packed<'Note'>; + }; +}; + +// API event definitions +// ストリームごとのEmitterの辞書を用意 +type EventEmitterDictionary = { [x in keyof StreamMessages]: Emitter<EventEmitter, { [y in StreamMessages[x]['name']]: (e: StreamMessages[x]['payload']) => void }> }; +// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection +type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; +// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする +export type StreamEventEmitter = UnionToIntersection<EventEmitterDictionary[keyof StreamMessages]>; +// { [y in name]: (e: spec) => void }をまとめてその交差型をEmitterにかけるとts(2590)にひっかかる + +// provide stream channels union +export type StreamChannels = StreamMessages[keyof StreamMessages]['name']; diff --git a/packages/backend/src/server/api/streaming.ts b/packages/backend/src/server/api/streaming.ts new file mode 100644 index 0000000000..8808bc9860 --- /dev/null +++ b/packages/backend/src/server/api/streaming.ts @@ -0,0 +1,67 @@ +import * as http from 'http'; +import * as websocket from 'websocket'; + +import MainStreamConnection from './stream/index'; +import { ParsedUrlQuery } from 'querystring'; +import authenticate from './authenticate'; +import { EventEmitter } from 'events'; +import { subsdcriber as redisClient } from '../../db/redis'; +import { Users } from '@/models/index'; + +module.exports = (server: http.Server) => { + // Init websocket server + const ws = new websocket.server({ + httpServer: server + }); + + ws.on('request', async (request) => { + const q = request.resourceURL.query as ParsedUrlQuery; + + // TODO: トークンが間違ってるなどしてauthenticateに失敗したら + // コネクション切断するなりエラーメッセージ返すなりする + // (現状はエラーがキャッチされておらずサーバーのログに流れて邪魔なので) + const [user, app] = await authenticate(q.i as string); + + if (user?.isSuspended) { + request.reject(400); + return; + } + + const connection = request.accept(); + + const ev = new EventEmitter(); + + async function onRedisMessage(_: string, data: string) { + const parsed = JSON.parse(data); + ev.emit(parsed.channel, parsed.message); + } + + redisClient.on('message', onRedisMessage); + + const main = new MainStreamConnection(connection, ev, user, app); + + const intervalId = user ? setInterval(() => { + Users.update(user.id, { + lastActiveDate: new Date(), + }); + }, 1000 * 60 * 5) : null; + if (user) { + Users.update(user.id, { + lastActiveDate: new Date(), + }); + } + + connection.once('close', () => { + ev.removeAllListeners(); + main.dispose(); + redisClient.off('message', onRedisMessage); + if (intervalId) clearInterval(intervalId); + }); + + connection.on('message', async (data) => { + if (data.utf8Data === 'ping') { + connection.send('pong'); + } + }); + }); +}; |