422
									
								
								packages/backend/src/server/api/2fa.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										422
									
								
								packages/backend/src/server/api/2fa.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| 			}; | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
							
								
								
									
										51
									
								
								packages/backend/src/server/api/api-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								packages/backend/src/server/api/api-handler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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()); | ||||
| 		} | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										62
									
								
								packages/backend/src/server/api/authenticate.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								packages/backend/src/server/api/authenticate.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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]; | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
							
								
								
									
										109
									
								
								packages/backend/src/server/api/call.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								packages/backend/src/server/api/call.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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)`); | ||||
| 		} | ||||
| 	}); | ||||
| }; | ||||
| @@ -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()); | ||||
| } | ||||
| @@ -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()); | ||||
| 	} | ||||
| } | ||||
| @@ -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()); | ||||
| } | ||||
| @@ -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()); | ||||
| } | ||||
| @@ -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()); | ||||
| } | ||||
| @@ -0,0 +1,3 @@ | ||||
| import { secureRndstr } from '@/misc/secure-rndstr'; | ||||
|  | ||||
| export default () => secureRndstr(16, true); | ||||
| @@ -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'); | ||||
| 			})); | ||||
| 		})); | ||||
| 	} | ||||
| } | ||||
| @@ -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()); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										56
									
								
								packages/backend/src/server/api/common/getters.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								packages/backend/src/server/api/common/getters.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
| } | ||||
							
								
								
									
										56
									
								
								packages/backend/src/server/api/common/inject-featured.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								packages/backend/src/server/api/common/inject-featured.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| } | ||||
							
								
								
									
										34
									
								
								packages/backend/src/server/api/common/inject-promo.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								packages/backend/src/server/api/common/inject-promo.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| } | ||||
| @@ -0,0 +1 @@ | ||||
| export default (token: string) => token.length === 16; | ||||
| @@ -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; | ||||
| } | ||||
							
								
								
									
										122
									
								
								packages/backend/src/server/api/common/read-messaging-message.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								packages/backend/src/server/api/common/read-messaging-message.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										43
									
								
								packages/backend/src/server/api/common/read-notification.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								packages/backend/src/server/api/common/read-notification.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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'); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										44
									
								
								packages/backend/src/server/api/common/signin.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								packages/backend/src/server/api/common/signin.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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)); | ||||
| 	})(); | ||||
| } | ||||
							
								
								
									
										113
									
								
								packages/backend/src/server/api/common/signup.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								packages/backend/src/server/api/common/signup.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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 }; | ||||
| } | ||||
							
								
								
									
										87
									
								
								packages/backend/src/server/api/define.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								packages/backend/src/server/api/define.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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]; | ||||
| } | ||||
							
								
								
									
										124
									
								
								packages/backend/src/server/api/endpoints.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								packages/backend/src/server/api/endpoints.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
| @@ -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); | ||||
| }); | ||||
| @@ -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; | ||||
| }); | ||||
| @@ -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', {}); | ||||
| 	} | ||||
| }); | ||||
							
								
								
									
										49
									
								
								packages/backend/src/server/api/endpoints/admin/ad/create.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								packages/backend/src/server/api/endpoints/admin/ad/create.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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, | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										34
									
								
								packages/backend/src/server/api/endpoints/admin/ad/delete.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								packages/backend/src/server/api/endpoints/admin/ad/delete.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| }); | ||||
							
								
								
									
										36
									
								
								packages/backend/src/server/api/endpoints/admin/ad/list.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								packages/backend/src/server/api/endpoints/admin/ad/list.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
| }); | ||||
							
								
								
									
										63
									
								
								packages/backend/src/server/api/endpoints/admin/ad/update.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								packages/backend/src/server/api/endpoints/admin/ad/update.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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), | ||||
| 	}); | ||||
| }); | ||||
| @@ -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; | ||||
| }); | ||||
| @@ -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); | ||||
| }); | ||||
| @@ -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; | ||||
| }); | ||||
| @@ -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, | ||||
| 	}); | ||||
| }); | ||||
| @@ -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); | ||||
| 	} | ||||
| }); | ||||
| @@ -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 | ||||
| }); | ||||
| @@ -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(); | ||||
| }); | ||||
| @@ -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); | ||||
| 	} | ||||
| }); | ||||
| @@ -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 }); | ||||
| }); | ||||
| @@ -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; | ||||
| }); | ||||
							
								
								
									
										64
									
								
								packages/backend/src/server/api/endpoints/admin/emoji/add.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								packages/backend/src/server/api/endpoints/admin/emoji/add.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| 	}; | ||||
| }); | ||||
| @@ -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 | ||||
| 	}; | ||||
| }); | ||||
| @@ -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); | ||||
| }); | ||||
| @@ -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); | ||||
| }); | ||||
| @@ -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 | ||||
| 	}); | ||||
| }); | ||||
| @@ -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']); | ||||
| }); | ||||
| @@ -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); | ||||
| 	} | ||||
| }); | ||||
| @@ -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); | ||||
| }); | ||||
| @@ -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]); | ||||
| 	} | ||||
| }); | ||||
| @@ -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 | ||||
| 	}); | ||||
| }); | ||||
| @@ -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; | ||||
| }); | ||||
| @@ -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; | ||||
| }); | ||||
							
								
								
									
										44
									
								
								packages/backend/src/server/api/endpoints/admin/invite.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								packages/backend/src/server/api/endpoints/admin/invite.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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, | ||||
| 	}; | ||||
| }); | ||||
| @@ -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 | ||||
| 	}); | ||||
| }); | ||||
| @@ -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 | ||||
| 	}); | ||||
| }); | ||||
| @@ -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, | ||||
| 	}); | ||||
| }); | ||||
| @@ -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'); | ||||
| }); | ||||
| @@ -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; | ||||
| }); | ||||
| @@ -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; | ||||
| }); | ||||
| @@ -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, | ||||
| 		}; | ||||
| 	}); | ||||
| }); | ||||
| @@ -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, | ||||
| 	}; | ||||
| }); | ||||
| @@ -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); | ||||
| }); | ||||
| @@ -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(); | ||||
| }); | ||||
| @@ -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); | ||||
| }); | ||||
| @@ -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 | ||||
| 	}; | ||||
| }); | ||||
| @@ -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, | ||||
| 	}); | ||||
| }); | ||||
| @@ -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: インスタンスごとのチャートもキューに入れて更新する | ||||
| }); | ||||
| @@ -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); | ||||
| }); | ||||
							
								
								
									
										119
									
								
								packages/backend/src/server/api/endpoints/admin/server-info.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								packages/backend/src/server/api/endpoints/admin/server-info.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| 		} | ||||
| 	}; | ||||
| }); | ||||
| @@ -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); | ||||
| }); | ||||
							
								
								
									
										177
									
								
								packages/backend/src/server/api/endpoints/admin/show-user.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										177
									
								
								packages/backend/src/server/api/endpoints/admin/show-user.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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, | ||||
| 	}; | ||||
| }); | ||||
							
								
								
									
										119
									
								
								packages/backend/src/server/api/endpoints/admin/show-users.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								packages/backend/src/server/api/endpoints/admin/show-users.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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 }); | ||||
| }); | ||||
| @@ -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, | ||||
| 	}); | ||||
| }); | ||||
| @@ -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 | ||||
| 	}); | ||||
| } | ||||
| @@ -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, | ||||
| 	}); | ||||
| }); | ||||
| @@ -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); | ||||
| }); | ||||
							
								
								
									
										608
									
								
								packages/backend/src/server/api/endpoints/admin/update-meta.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										608
									
								
								packages/backend/src/server/api/endpoints/admin/update-meta.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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'); | ||||
| }); | ||||
							
								
								
									
										36
									
								
								packages/backend/src/server/api/endpoints/admin/vacuum.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								packages/backend/src/server/api/endpoints/admin/vacuum.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| }); | ||||
							
								
								
									
										92
									
								
								packages/backend/src/server/api/endpoints/announcements.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								packages/backend/src/server/api/endpoints/announcements.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
| }); | ||||
							
								
								
									
										127
									
								
								packages/backend/src/server/api/endpoints/antennas/create.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								packages/backend/src/server/api/endpoints/antennas/create.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| }); | ||||
							
								
								
									
										43
									
								
								packages/backend/src/server/api/endpoints/antennas/delete.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								packages/backend/src/server/api/endpoints/antennas/delete.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| }); | ||||
							
								
								
									
										28
									
								
								packages/backend/src/server/api/endpoints/antennas/list.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								packages/backend/src/server/api/endpoints/antennas/list.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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))); | ||||
| }); | ||||
							
								
								
									
										93
									
								
								packages/backend/src/server/api/endpoints/antennas/notes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								packages/backend/src/server/api/endpoints/antennas/notes.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| }); | ||||
							
								
								
									
										47
									
								
								packages/backend/src/server/api/endpoints/antennas/show.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								packages/backend/src/server/api/endpoints/antennas/show.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| }); | ||||
							
								
								
									
										143
									
								
								packages/backend/src/server/api/endpoints/antennas/update.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								packages/backend/src/server/api/endpoints/antennas/update.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| }); | ||||
							
								
								
									
										36
									
								
								packages/backend/src/server/api/endpoints/ap/get.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								packages/backend/src/server/api/endpoints/ap/get.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
| }); | ||||
							
								
								
									
										190
									
								
								packages/backend/src/server/api/endpoints/ap/show.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								packages/backend/src/server/api/endpoints/ap/show.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
| } | ||||
							
								
								
									
										63
									
								
								packages/backend/src/server/api/endpoints/app/create.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								packages/backend/src/server/api/endpoints/app/create.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										51
									
								
								packages/backend/src/server/api/endpoints/app/show.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								packages/backend/src/server/api/endpoints/app/show.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										76
									
								
								packages/backend/src/server/api/endpoints/auth/accept.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								packages/backend/src/server/api/endpoints/auth/accept.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| 	}); | ||||
| }); | ||||
| @@ -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}` | ||||
| 	}; | ||||
| }); | ||||
| @@ -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); | ||||
| }); | ||||
| @@ -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 | ||||
| 		}) | ||||
| 	}; | ||||
| }); | ||||
							
								
								
									
										89
									
								
								packages/backend/src/server/api/endpoints/blocking/create.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								packages/backend/src/server/api/endpoints/blocking/create.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										85
									
								
								packages/backend/src/server/api/endpoints/blocking/delete.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								packages/backend/src/server/api/endpoints/blocking/delete.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										49
									
								
								packages/backend/src/server/api/endpoints/blocking/list.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								packages/backend/src/server/api/endpoints/blocking/list.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| }); | ||||
							
								
								
									
										68
									
								
								packages/backend/src/server/api/endpoints/channels/create.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								packages/backend/src/server/api/endpoints/channels/create.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| }); | ||||
| @@ -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))); | ||||
| }); | ||||
							
								
								
									
										48
									
								
								packages/backend/src/server/api/endpoints/channels/follow.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								packages/backend/src/server/api/endpoints/channels/follow.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| }); | ||||
| @@ -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))); | ||||
| }); | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	 syuilo
					syuilo