refactoring

Resolve #7779
This commit is contained in:
syuilo
2021-11-12 02:02:25 +09:00
parent 037837b551
commit 0e4a111f81
1714 changed files with 20803 additions and 11751 deletions

View File

@ -0,0 +1,227 @@
import * as Router from '@koa/router';
import * as json from 'koa-json-body';
import * as httpSignature from 'http-signature';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import renderNote from '@/remote/activitypub/renderer/note';
import renderKey from '@/remote/activitypub/renderer/key';
import { renderPerson } from '@/remote/activitypub/renderer/person';
import renderEmoji from '@/remote/activitypub/renderer/emoji';
import Outbox, { packActivity } from './activitypub/outbox';
import Followers from './activitypub/followers';
import Following from './activitypub/following';
import Featured from './activitypub/featured';
import { inbox as processInbox } from '@/queue/index';
import { isSelfHost } from '@/misc/convert-host';
import { Notes, Users, Emojis, NoteReactions } from '@/models/index';
import { ILocalUser, User } from '@/models/entities/user';
import { In } from 'typeorm';
import { renderLike } from '@/remote/activitypub/renderer/like';
import { getUserKeypair } from '@/misc/keypair-store';
// Init router
const router = new Router();
//#region Routing
function inbox(ctx: Router.RouterContext) {
let signature;
try {
signature = httpSignature.parseRequest(ctx.req, { 'headers': [] });
} catch (e) {
ctx.status = 401;
return;
}
processInbox(ctx.request.body, signature);
ctx.status = 202;
}
const ACTIVITY_JSON = 'application/activity+json; charset=utf-8';
const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8';
function isActivityPubReq(ctx: Router.RouterContext) {
ctx.response.vary('Accept');
const accepted = ctx.accepts('html', ACTIVITY_JSON, LD_JSON);
return typeof accepted === 'string' && !accepted.match(/html/);
}
export function setResponseType(ctx: Router.RouterContext) {
const accept = ctx.accepts(ACTIVITY_JSON, LD_JSON);
if (accept === LD_JSON) {
ctx.response.type = LD_JSON;
} else {
ctx.response.type = ACTIVITY_JSON;
}
}
// inbox
router.post('/inbox', json(), inbox);
router.post('/users/:user/inbox', json(), inbox);
// note
router.get('/notes/:note', async (ctx, next) => {
if (!isActivityPubReq(ctx)) return await next();
const note = await Notes.findOne({
id: ctx.params.note,
visibility: In(['public', 'home']),
localOnly: false
});
if (note == null) {
ctx.status = 404;
return;
}
// リモートだったらリダイレクト
if (note.userHost != null) {
if (note.uri == null || isSelfHost(note.userHost)) {
ctx.status = 500;
return;
}
ctx.redirect(note.uri);
return;
}
ctx.body = renderActivity(await renderNote(note, false));
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
});
// note activity
router.get('/notes/:note/activity', async ctx => {
const note = await Notes.findOne({
id: ctx.params.note,
userHost: null,
visibility: In(['public', 'home']),
localOnly: false
});
if (note == null) {
ctx.status = 404;
return;
}
ctx.body = renderActivity(await packActivity(note));
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
});
// outbox
router.get('/users/:user/outbox', Outbox);
// followers
router.get('/users/:user/followers', Followers);
// following
router.get('/users/:user/following', Following);
// featured
router.get('/users/:user/collections/featured', Featured);
// publickey
router.get('/users/:user/publickey', async ctx => {
const userId = ctx.params.user;
const user = await Users.findOne({
id: userId,
host: null
});
if (user == null) {
ctx.status = 404;
return;
}
const keypair = await getUserKeypair(user.id);
if (Users.isLocalUser(user)) {
ctx.body = renderActivity(renderKey(user, keypair));
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
} else {
ctx.status = 400;
}
});
// user
async function userInfo(ctx: Router.RouterContext, user: User | undefined) {
if (user == null) {
ctx.status = 404;
return;
}
ctx.body = renderActivity(await renderPerson(user as ILocalUser));
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
}
router.get('/users/:user', async (ctx, next) => {
if (!isActivityPubReq(ctx)) return await next();
const userId = ctx.params.user;
const user = await Users.findOne({
id: userId,
host: null,
isSuspended: false
});
await userInfo(ctx, user);
});
router.get('/@:user', async (ctx, next) => {
if (!isActivityPubReq(ctx)) return await next();
const user = await Users.findOne({
usernameLower: ctx.params.user.toLowerCase(),
host: null,
isSuspended: false
});
await userInfo(ctx, user);
});
//#endregion
// emoji
router.get('/emojis/:emoji', async ctx => {
const emoji = await Emojis.findOne({
host: null,
name: ctx.params.emoji
});
if (emoji == null) {
ctx.status = 404;
return;
}
ctx.body = renderActivity(await renderEmoji(emoji));
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
});
// like
router.get('/likes/:like', async ctx => {
const reaction = await NoteReactions.findOne(ctx.params.like);
if (reaction == null) {
ctx.status = 404;
return;
}
const note = await Notes.findOne(reaction.noteId);
if (note == null) {
ctx.status = 404;
return;
}
ctx.body = renderActivity(await renderLike(reaction, note));
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
});
export default router;

View File

@ -0,0 +1,41 @@
import * as Router from '@koa/router';
import config from '@/config/index';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection';
import { setResponseType } from '../activitypub';
import renderNote from '@/remote/activitypub/renderer/note';
import { Users, Notes, UserNotePinings } from '@/models/index';
export default async (ctx: Router.RouterContext) => {
const userId = ctx.params.user;
// Verify user
const user = await Users.findOne({
id: userId,
host: null
});
if (user == null) {
ctx.status = 404;
return;
}
const pinings = await UserNotePinings.find({
where: { userId: user.id },
order: { id: 'DESC' }
});
const pinnedNotes = await Promise.all(pinings.map(pining =>
Notes.findOneOrFail(pining.noteId)));
const renderedNotes = await Promise.all(pinnedNotes.map(note => renderNote(note)));
const rendered = renderOrderedCollection(
`${config.url}/users/${userId}/collections/featured`,
renderedNotes.length, undefined, undefined, renderedNotes
);
ctx.body = renderActivity(rendered);
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
};

View File

@ -0,0 +1,102 @@
import * as Router from '@koa/router';
import config from '@/config/index';
import $ from 'cafy';
import { ID } from '@/misc/cafy-id';
import * as url from '@/prelude/url';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection';
import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-collection-page';
import renderFollowUser from '@/remote/activitypub/renderer/follow-user';
import { setResponseType } from '../activitypub';
import { Users, Followings, UserProfiles } from '@/models/index';
import { LessThan } from 'typeorm';
export default async (ctx: Router.RouterContext) => {
const userId = ctx.params.user;
// Get 'cursor' parameter
const [cursor, cursorErr] = $.optional.type(ID).get(ctx.request.query.cursor);
// Get 'page' parameter
const pageErr = !$.optional.str.or(['true', 'false']).ok(ctx.request.query.page);
const page: boolean = ctx.request.query.page === 'true';
// Validate parameters
if (cursorErr || pageErr) {
ctx.status = 400;
return;
}
// Verify user
const user = await Users.findOne({
id: userId,
host: null
});
if (user == null) {
ctx.status = 404;
return;
}
//#region Check ff visibility
const profile = await UserProfiles.findOneOrFail(user.id);
if (profile.ffVisibility === 'private') {
ctx.status = 403;
ctx.set('Cache-Control', 'public, max-age=30');
return;
} else if (profile.ffVisibility === 'followers') {
ctx.status = 403;
ctx.set('Cache-Control', 'public, max-age=30');
return;
}
//#endregion
const limit = 10;
const partOf = `${config.url}/users/${userId}/followers`;
if (page) {
const query = {
followeeId: user.id
} as any;
// カーソルが指定されている場合
if (cursor) {
query.id = LessThan(cursor);
}
// Get followers
const followings = await Followings.find({
where: query,
take: limit + 1,
order: { id: -1 }
});
// 「次のページ」があるかどうか
const inStock = followings.length === limit + 1;
if (inStock) followings.pop();
const renderedFollowers = await Promise.all(followings.map(following => renderFollowUser(following.followerId)));
const rendered = renderOrderedCollectionPage(
`${partOf}?${url.query({
page: 'true',
cursor
})}`,
user.followersCount, renderedFollowers, partOf,
undefined,
inStock ? `${partOf}?${url.query({
page: 'true',
cursor: followings[followings.length - 1].id
})}` : undefined
);
ctx.body = renderActivity(rendered);
setResponseType(ctx);
} else {
// index page
const rendered = renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`);
ctx.body = renderActivity(rendered);
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
}
};

View File

@ -0,0 +1,103 @@
import * as Router from '@koa/router';
import config from '@/config/index';
import $ from 'cafy';
import { ID } from '@/misc/cafy-id';
import * as url from '@/prelude/url';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection';
import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-collection-page';
import renderFollowUser from '@/remote/activitypub/renderer/follow-user';
import { setResponseType } from '../activitypub';
import { Users, Followings, UserProfiles } from '@/models/index';
import { LessThan, FindConditions } from 'typeorm';
import { Following } from '@/models/entities/following';
export default async (ctx: Router.RouterContext) => {
const userId = ctx.params.user;
// Get 'cursor' parameter
const [cursor, cursorErr] = $.optional.type(ID).get(ctx.request.query.cursor);
// Get 'page' parameter
const pageErr = !$.optional.str.or(['true', 'false']).ok(ctx.request.query.page);
const page: boolean = ctx.request.query.page === 'true';
// Validate parameters
if (cursorErr || pageErr) {
ctx.status = 400;
return;
}
// Verify user
const user = await Users.findOne({
id: userId,
host: null
});
if (user == null) {
ctx.status = 404;
return;
}
//#region Check ff visibility
const profile = await UserProfiles.findOneOrFail(user.id);
if (profile.ffVisibility === 'private') {
ctx.status = 403;
ctx.set('Cache-Control', 'public, max-age=30');
return;
} else if (profile.ffVisibility === 'followers') {
ctx.status = 403;
ctx.set('Cache-Control', 'public, max-age=30');
return;
}
//#endregion
const limit = 10;
const partOf = `${config.url}/users/${userId}/following`;
if (page) {
const query = {
followerId: user.id
} as FindConditions<Following>;
// カーソルが指定されている場合
if (cursor) {
query.id = LessThan(cursor);
}
// Get followings
const followings = await Followings.find({
where: query,
take: limit + 1,
order: { id: -1 }
});
// 「次のページ」があるかどうか
const inStock = followings.length === limit + 1;
if (inStock) followings.pop();
const renderedFollowees = await Promise.all(followings.map(following => renderFollowUser(following.followeeId)));
const rendered = renderOrderedCollectionPage(
`${partOf}?${url.query({
page: 'true',
cursor
})}`,
user.followingCount, renderedFollowees, partOf,
undefined,
inStock ? `${partOf}?${url.query({
page: 'true',
cursor: followings[followings.length - 1].id
})}` : undefined
);
ctx.body = renderActivity(rendered);
setResponseType(ctx);
} else {
// index page
const rendered = renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`);
ctx.body = renderActivity(rendered);
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
}
};

View File

@ -0,0 +1,108 @@
import * as Router from '@koa/router';
import config from '@/config/index';
import $ from 'cafy';
import { ID } from '@/misc/cafy-id';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection';
import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-collection-page';
import { setResponseType } from '../activitypub';
import renderNote from '@/remote/activitypub/renderer/note';
import renderCreate from '@/remote/activitypub/renderer/create';
import renderAnnounce from '@/remote/activitypub/renderer/announce';
import { countIf } from '@/prelude/array';
import * as url from '@/prelude/url';
import { Users, Notes } from '@/models/index';
import { makePaginationQuery } from '../api/common/make-pagination-query';
import { Brackets } from 'typeorm';
import { Note } from '@/models/entities/note';
export default async (ctx: Router.RouterContext) => {
const userId = ctx.params.user;
// Get 'sinceId' parameter
const [sinceId, sinceIdErr] = $.optional.type(ID).get(ctx.request.query.since_id);
// Get 'untilId' parameter
const [untilId, untilIdErr] = $.optional.type(ID).get(ctx.request.query.until_id);
// Get 'page' parameter
const pageErr = !$.optional.str.or(['true', 'false']).ok(ctx.request.query.page);
const page: boolean = ctx.request.query.page === 'true';
// Validate parameters
if (sinceIdErr || untilIdErr || pageErr || countIf(x => x != null, [sinceId, untilId]) > 1) {
ctx.status = 400;
return;
}
// Verify user
const user = await Users.findOne({
id: userId,
host: null
});
if (user == null) {
ctx.status = 404;
return;
}
const limit = 20;
const partOf = `${config.url}/users/${userId}/outbox`;
if (page) {
const query = makePaginationQuery(Notes.createQueryBuilder('note'), sinceId, untilId)
.andWhere('note.userId = :userId', { userId: user.id })
.andWhere(new Brackets(qb => { qb
.where(`note.visibility = 'public'`)
.orWhere(`note.visibility = 'home'`);
}))
.andWhere('note.localOnly = FALSE');
const notes = await query.take(limit).getMany();
if (sinceId) notes.reverse();
const activities = await Promise.all(notes.map(note => packActivity(note)));
const rendered = renderOrderedCollectionPage(
`${partOf}?${url.query({
page: 'true',
since_id: sinceId,
until_id: untilId
})}`,
user.notesCount, activities, partOf,
notes.length ? `${partOf}?${url.query({
page: 'true',
since_id: notes[0].id
})}` : undefined,
notes.length ? `${partOf}?${url.query({
page: 'true',
until_id: notes[notes.length - 1].id
})}` : undefined
);
ctx.body = renderActivity(rendered);
setResponseType(ctx);
} else {
// index page
const rendered = renderOrderedCollection(partOf, user.notesCount,
`${partOf}?page=true`,
`${partOf}?page=true&since_id=000000000000000000000000`
);
ctx.body = renderActivity(rendered);
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
}
};
/**
* Pack Create<Note> or Announce Activity
* @param note Note
*/
export async function packActivity(note: Note): Promise<any> {
if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) {
const renote = await Notes.findOneOrFail(note.renoteId);
return renderAnnounce(renote.uri ? renote.uri : `${config.url}/notes/${renote.id}`, note);
}
return renderCreate(await renderNote(note, false), note);
}

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

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

View 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];
}
}
};

View 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)`);
}
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
import { secureRndstr } from '@/misc/secure-rndstr';
export default () => secureRndstr(16, true);

View File

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

View File

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

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

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

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

View File

@ -0,0 +1 @@
export default (token: string) => token.length === 16;

View File

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

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

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

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

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

View 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];
}

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: インスタンスごとのチャートもキューに入れて更新する
});

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

Some files were not shown because too many files have changed in this diff Show More