Fixed 267U.pre2

This commit is contained in:
2026-07-26 18:25:37 +09:00
parent 50bfaeafdf
commit 317d00a284
1286 changed files with 80222 additions and 1 deletions
@@ -0,0 +1,482 @@
import Router from "@koa/router";
import bodyParser from "koa-bodyparser";
import httpSignature from "@peertube/http-signature";
import { In, IsNull, Not } from "typeorm";
import { renderActivity } from "../remote/activitypub/renderer/index.js";
import renderNote from "../remote/activitypub/renderer/note.js";
import renderKey from "../remote/activitypub/renderer/key.js";
import { renderPerson } from "../remote/activitypub/renderer/person.js";
import renderEmoji from "../remote/activitypub/renderer/emoji.js";
import { inbox as processInbox } from "../queue/index.js";
import { isSelfHost } from "../misc/convert-host.js";
import { Notes, Users, Emojis, NoteReactions, FollowRequests, Bites, InteractionStamps } from "../models/index.js";
import { renderLike } from "../remote/activitypub/renderer/like.js";
import { getUserKeypair } from "../misc/keypair-store.js";
import { checkFetch, getSignatureUser, verifyDigest } from "../remote/activitypub/check-fetch.js";
import { getInstanceActor } from "../services/instance-actor.js";
import { fetchMeta } from "../misc/fetch-meta.js";
import renderFollow from "../remote/activitypub/renderer/follow.js";
import Featured from "./activitypub/featured.js";
import Following from "./activitypub/following.js";
import Followers from "./activitypub/followers.js";
import Outbox, { packActivity } from "./activitypub/outbox.js";
import { serverLogger } from "./index.js";
import config from "../config/index.js";
import { tickFetch } from "../metrics.js";
import renderBite from "../remote/activitypub/renderer/bite.js";
import renderQuoteAuthorization from "../remote/activitypub/renderer/quote-authorization.js";
// Init router
const router = new Router();
//#region Routing
function inbox(ctx) {
if (ctx.req.headers.host !== config.host) {
ctx.status = 400;
return;
}
let signature;
try {
signature = httpSignature.parseRequest(ctx.req, {
headers: [
'(request-target)',
'digest',
'host',
'date'
],
authorizationHeaderName: 'signature'
});
} catch (e) {
ctx.status = 401;
return;
}
if (!verifyDigest(ctx.request.rawBody, ctx.headers.digest)) {
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) {
ctx.response.vary("Accept");
const accepted = ctx.accepts("html", ACTIVITY_JSON, LD_JSON);
return typeof accepted === "string" && !accepted.match(/html/);
}
export function setResponseType(ctx) {
const accept = ctx.accepts(ACTIVITY_JSON, LD_JSON);
if (accept === LD_JSON) {
ctx.response.type = LD_JSON;
} else {
ctx.response.type = ACTIVITY_JSON;
}
}
async function parseJsonBodyOrFail(ctx, next) {
const koaBodyParser = bodyParser({
enableTypes: [
"json"
],
detectJSON: ()=>true
});
try {
await koaBodyParser(ctx, next);
} catch {
ctx.status = 400;
return;
}
}
// inbox
router.post("/inbox", parseJsonBodyOrFail, inbox);
router.post("/users/:user/inbox", parseJsonBodyOrFail, inbox);
// note
router.get("/notes/:note", async (ctx, next)=>{
if (!isActivityPubReq(ctx)) return await next();
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const note = await Notes.findOneBy({
id: ctx.params.note,
visibility: In([
"public",
"home",
"followers"
]),
localOnly: false
});
if (note == null) {
ctx.status = 404;
return;
}
// redirect if remote
if (note.userHost !== null) {
if (note.uri == null || isSelfHost(note.userHost)) {
ctx.status = 500;
return;
}
ctx.redirect(note.uri);
return;
}
if (note.visibility === "followers") {
serverLogger.debug("Responding to request for follower-only note, validating access...");
const remoteUser = await getSignatureUser(ctx.req);
serverLogger.debug("Local note author user:");
serverLogger.debug(JSON.stringify(note, null, 2));
serverLogger.debug("Authenticated remote user:");
serverLogger.debug(JSON.stringify(remoteUser, null, 2));
if (remoteUser == null) {
serverLogger.debug("Rejecting: no user");
ctx.status = 401;
return;
}
const relation = await Users.getRelation(remoteUser.user.id, note.userId);
serverLogger.debug("Relation:");
serverLogger.debug(JSON.stringify(relation, null, 2));
if (!relation.isFollowing || relation.isBlocked) {
serverLogger.debug("Rejecting: authenticated user is not following us or was blocked by us");
ctx.status = 403;
return;
}
serverLogger.debug("Accepting: access criteria met");
}
ctx.body = renderActivity(await renderNote(note, false));
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
} else {
ctx.set("Cache-Control", "public, max-age=180");
}
setResponseType(ctx);
});
// note activity
router.get("/notes/:note/activity", async (ctx)=>{
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const note = await Notes.findOneBy({
id: ctx.params.note,
userHost: IsNull(),
visibility: In([
"public",
"home"
]),
localOnly: false
});
if (note == null) {
ctx.status = 404;
return;
}
ctx.body = renderActivity(await packActivity(note));
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
} else {
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 instanceActor = await getInstanceActor();
if (ctx.params.user === instanceActor.id) {
tickFetch();
ctx.body = renderActivity(renderKey(instanceActor, await getUserKeypair(instanceActor.id)));
ctx.set("Cache-Control", "public, max-age=180");
setResponseType(ctx);
return;
}
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const userId = ctx.params.user;
const user = await Users.findOneBy({
id: userId,
host: IsNull()
});
if (user == null) {
ctx.status = 404;
return;
}
const keypair = await getUserKeypair(user.id);
if (Users.isLocalUser(user)) {
ctx.body = renderActivity(renderKey(user, keypair));
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
} else {
ctx.set("Cache-Control", "public, max-age=180");
}
setResponseType(ctx);
} else {
ctx.status = 400;
}
});
// user
async function userInfo(ctx, user) {
if (user == null) {
ctx.status = 404;
return;
}
ctx.body = renderActivity(await renderPerson(user));
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
} else {
ctx.set("Cache-Control", "public, max-age=180");
}
setResponseType(ctx);
}
router.get("/users/:user", async (ctx, next)=>{
if (!isActivityPubReq(ctx)) return await next();
const instanceActor = await getInstanceActor();
if (ctx.params.user === instanceActor.id) {
tickFetch();
await userInfo(ctx, instanceActor);
return;
}
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const userId = ctx.params.user;
const user = await Users.findOneBy({
id: userId,
host: IsNull(),
isSuspended: false
});
await userInfo(ctx, user);
});
router.get("/@:user", async (ctx, next)=>{
if (!isActivityPubReq(ctx)) return await next();
if (ctx.params.user === "instance.actor") {
tickFetch();
const instanceActor = await getInstanceActor();
await userInfo(ctx, instanceActor);
return;
}
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const user = await Users.findOneBy({
usernameLower: ctx.params.user.toLowerCase(),
host: IsNull(),
isSuspended: false
});
await userInfo(ctx, user);
});
router.get("/actor", async (ctx, next)=>{
tickFetch();
const instanceActor = await getInstanceActor();
await userInfo(ctx, instanceActor);
});
//#endregion
// emoji
router.get("/emojis/:emoji", async (ctx)=>{
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const emoji = await Emojis.findOneBy({
host: IsNull(),
name: ctx.params.emoji
});
if (emoji == null) {
ctx.status = 404;
return;
}
ctx.body = renderActivity(await renderEmoji(emoji));
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
} else {
ctx.set("Cache-Control", "public, max-age=180");
}
setResponseType(ctx);
});
// like
router.get("/likes/:like", async (ctx)=>{
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const reaction = await NoteReactions.findOneBy({
id: ctx.params.like
});
if (reaction == null) {
ctx.status = 404;
return;
}
const note = await Notes.findOneBy({
id: reaction.noteId
});
if (note == null) {
ctx.status = 404;
return;
}
ctx.body = renderActivity(await renderLike(reaction, note));
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
} else {
ctx.set("Cache-Control", "public, max-age=180");
}
setResponseType(ctx);
});
// follow
router.get("/follows/:follower/:followee", async (ctx)=>{
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
// This may be used before the follow is completed, so we do not
// check if the following exists.
const [follower, followee] = await Promise.all([
Users.findOneBy({
id: ctx.params.follower,
host: IsNull()
}),
Users.findOneBy({
id: ctx.params.followee,
host: Not(IsNull())
})
]);
if (follower == null || followee == null) {
ctx.status = 404;
return;
}
ctx.body = renderActivity(renderFollow(follower, followee));
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
} else {
ctx.set("Cache-Control", "public, max-age=180");
}
setResponseType(ctx);
});
// follow request
router.get("/follows/:followRequestId", async (ctx)=>{
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const followRequest = await FollowRequests.findOneBy({
id: ctx.params.followRequestId
});
if (followRequest == null) {
ctx.status = 404;
return;
}
const [follower, followee] = await Promise.all([
Users.findOneBy({
id: followRequest.followerId,
host: IsNull()
}),
Users.findOneBy({
id: followRequest.followeeId,
host: Not(IsNull())
})
]);
if (follower == null || followee == null) {
ctx.status = 404;
return;
}
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
} else {
ctx.set("Cache-Control", "public, max-age=180");
}
ctx.body = renderActivity(renderFollow(follower, followee));
setResponseType(ctx);
});
// bite
router.get("/bites/:biteId", async (ctx)=>{
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const bite = await Bites.findOne({
where: {
id: ctx.params.biteId
},
relations: [
"targetUser",
"targetBite"
]
});
if (bite === null) {
ctx.status = 404;
return;
}
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
} else {
ctx.set("Cache-Control", "public, max-age=180");
}
ctx.body = renderActivity(await renderBite(bite));
setResponseType(ctx);
});
// mastodon-style quote authorizations
router.get("/stamp/:id", async (ctx)=>{
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const stamp = await InteractionStamps.findOne({
where: {
id: ctx.params.id
},
relations: [
"note",
"targetNote"
]
});
if (stamp === null) {
ctx.status = 404;
return;
}
// make sure the requester has access to the note, otherwise this endpoint can leak the existence of a private note.
// we can't directly validate that the requester can view the quoting post, but servers should only get this uri
// from the metadata on the quote's note object, so the possibility and scope of leakage is minor
const requester = await getSignatureUser(ctx.req);
if (requester === null) {
ctx.status = 404;
return;
}
const targetNoteVisibile = await Notes.isVisibleForMe(stamp.targetNote, requester.user.id);
if (!targetNoteVisibile) {
ctx.status = 404;
return;
}
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
} else {
ctx.set("Cache-Control", "public, max-age=180");
}
ctx.body = renderActivity(renderQuoteAuthorization(stamp));
setResponseType(ctx);
});
export default router;
@@ -0,0 +1,45 @@
import { IsNull } from "typeorm";
import config from "../../config/index.js";
import { renderActivity } from "../../remote/activitypub/renderer/index.js";
import renderOrderedCollection from "../../remote/activitypub/renderer/ordered-collection.js";
import { Users, Notes, UserNotePinings } from "../../models/index.js";
import { checkFetch } from "../../remote/activitypub/check-fetch.js";
import { fetchMeta } from "../../misc/fetch-meta.js";
import { setResponseType } from "../activitypub.js";
export default (async (ctx)=>{
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const userId = ctx.params.user;
const user = await Users.findOneBy({
id: userId,
host: IsNull()
});
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.findOneByOrFail({
id: pining.noteId
})));
const renderedNotes = pinnedNotes.map((note)=>`${config.url}/notes/${note.id}`);
const rendered = renderOrderedCollection(`${config.url}/users/${userId}/collections/featured`, renderedNotes.length, undefined, undefined, renderedNotes);
ctx.body = renderActivity(rendered);
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
} else {
ctx.set("Cache-Control", "public, max-age=180");
}
setResponseType(ctx);
});
@@ -0,0 +1,90 @@
import { IsNull, LessThan } from "typeorm";
import config from "../../config/index.js";
import * as url from "../../prelude/url.js";
import { renderActivity } from "../../remote/activitypub/renderer/index.js";
import renderOrderedCollection from "../../remote/activitypub/renderer/ordered-collection.js";
import renderOrderedCollectionPage from "../../remote/activitypub/renderer/ordered-collection-page.js";
import renderFollowUser from "../../remote/activitypub/renderer/follow-user.js";
import { Users, Followings, UserProfiles } from "../../models/index.js";
import { checkFetch } from "../../remote/activitypub/check-fetch.js";
import { fetchMeta } from "../../misc/fetch-meta.js";
import { setResponseType } from "../activitypub.js";
export default (async (ctx)=>{
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const userId = ctx.params.user;
const cursor = ctx.request.query.cursor;
if (cursor != null && typeof cursor !== "string") {
ctx.status = 400;
return;
}
const page = ctx.request.query.page === "true";
const user = await Users.findOneBy({
id: userId,
host: IsNull()
});
if (user == null) {
ctx.status = 404;
return;
}
//#region Check ff visibility
const profile = await UserProfiles.findOneByOrFail({
userId: 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
};
// カーソルが指定されている場合
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);
setResponseType(ctx);
}
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
} else {
ctx.set("Cache-Control", "public, max-age=180");
}
});
@@ -0,0 +1,90 @@
import { LessThan, IsNull } from "typeorm";
import config from "../../config/index.js";
import * as url from "../../prelude/url.js";
import { renderActivity } from "../../remote/activitypub/renderer/index.js";
import renderOrderedCollection from "../../remote/activitypub/renderer/ordered-collection.js";
import renderOrderedCollectionPage from "../../remote/activitypub/renderer/ordered-collection-page.js";
import renderFollowUser from "../../remote/activitypub/renderer/follow-user.js";
import { Users, Followings, UserProfiles } from "../../models/index.js";
import { checkFetch } from "../../remote/activitypub/check-fetch.js";
import { fetchMeta } from "../../misc/fetch-meta.js";
import { setResponseType } from "../activitypub.js";
export default (async (ctx)=>{
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const userId = ctx.params.user;
const cursor = ctx.request.query.cursor;
if (cursor != null && typeof cursor !== "string") {
ctx.status = 400;
return;
}
const page = ctx.request.query.page === "true";
const user = await Users.findOneBy({
id: userId,
host: IsNull()
});
if (user == null) {
ctx.status = 404;
return;
}
//#region Check ff visibility
const profile = await UserProfiles.findOneByOrFail({
userId: 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
};
// If a cursor is specified
if (cursor) {
query.id = LessThan(cursor);
}
// Get followings
const followings = await Followings.find({
where: query,
take: limit + 1,
order: {
id: -1
}
});
// Whether there is a "next page" or not
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);
setResponseType(ctx);
}
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
} else {
ctx.set("Cache-Control", "public, max-age=180");
}
});
@@ -0,0 +1,97 @@
import { Brackets, IsNull } from "typeorm";
import config from "../../config/index.js";
import { renderActivity } from "../../remote/activitypub/renderer/index.js";
import renderOrderedCollection from "../../remote/activitypub/renderer/ordered-collection.js";
import renderOrderedCollectionPage from "../../remote/activitypub/renderer/ordered-collection-page.js";
import renderNote from "../../remote/activitypub/renderer/note.js";
import renderCreate from "../../remote/activitypub/renderer/create.js";
import renderAnnounce from "../../remote/activitypub/renderer/announce.js";
import { countIf } from "../../prelude/array.js";
import * as url from "../../prelude/url.js";
import { Users, Notes } from "../../models/index.js";
import { checkFetch } from "../../remote/activitypub/check-fetch.js";
import { fetchMeta } from "../../misc/fetch-meta.js";
import { makePaginationQuery } from "../api/common/make-pagination-query.js";
import { setResponseType } from "../activitypub.js";
export default (async (ctx)=>{
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const userId = ctx.params.user;
const sinceId = ctx.request.query.since_id;
if (sinceId != null && typeof sinceId !== "string") {
ctx.status = 400;
return;
}
const untilId = ctx.request.query.until_id;
if (untilId != null && typeof untilId !== "string") {
ctx.status = 400;
return;
}
const page = ctx.request.query.page === "true";
if (countIf((x)=>x != null, [
sinceId,
untilId
]) > 1) {
ctx.status = 400;
return;
}
const user = await Users.findOneBy({
id: userId,
host: IsNull()
});
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);
setResponseType(ctx);
}
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
} else {
ctx.set("Cache-Control", "public, max-age=180");
}
});
/**
* Pack Create<Note> or Announce Activity
* @param note Note
*/ export async function packActivity(note) {
if (note.renoteId && note.text == null && note.cw == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) {
const renote = await Notes.findOneByOrFail({
id: note.renoteId
});
return renderAnnounce(renote.uri ?? `${config.url}/notes/${renote.id}`, note);
}
return renderCreate(await renderNote(note, false), note);
}
+278
View File
@@ -0,0 +1,278 @@
import * as crypto from "node:crypto";
import * as jsrsasign from "jsrsasign";
import config from "../../config/index.js";
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) {
return Buffer.from(source.replace(/\-/g, "+").replace(/_/g, "/"), "base64");
}
function getCertSubject(certificate) {
const subjectCert = new jsrsasign.X509();
subjectCert.readCertPEM(certificate);
const subjectString = subjectCert.getSubjectString();
const subjectFields = subjectString.slice(1).split("/");
const fields = {};
for (const field of subjectFields){
const eqIndex = field.indexOf("=");
fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1);
}
return fields;
}
function verifyCertificateChain(certificates) {
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, 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) {
return crypto.createHash("sha256").update(data).digest();
}
export function verifyLogin({ publicKey, authenticatorData, clientDataJSON, clientData, signature, challenge }) {
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 }) {
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 }) {
if (attStmt.alg !== -7) {
throw new Error("alg mismatch");
}
const verificationData = Buffer.concat([
authenticatorData,
clientDataHash
]);
const attCert = 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 }) {
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)=>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 }) {
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 }) {
const x5c = 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 = 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);
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
};
}
}
};
@@ -0,0 +1,85 @@
import { UserIps } from "../../models/index.js";
import { fetchMeta } from "../../misc/fetch-meta.js";
import authenticate, { AuthenticationError } from "./authenticate.js";
import call from "./call.js";
import { ApiError } from "./error.js";
const userIpHistories = new Map();
setInterval(()=>{
userIpHistories.clear();
}, 1000 * 60 * 60);
export default ((endpoint, ctx)=>new Promise((res)=>{
const body = ctx.is("multipart/form-data") ? ctx.request.body : ctx.method === "GET" ? ctx.query : ctx.request.body;
const reply = (x, y)=>{
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
// for GET requests, do not even pass on the body parameter as it is considered unsafe
authenticate(ctx.headers.authorization, ctx.method === "GET" ? null : body["i"]).then(([user, app])=>{
// API invoking
call(endpoint.name, user, app, body, ctx).then((res)=>{
if (ctx.method === "GET" && endpoint.meta.cacheSec && !body["i"] && !user) {
ctx.set("Cache-Control", `public, max-age=${endpoint.meta.cacheSec}`);
}
reply(res);
}).catch((e)=>{
reply(e.httpStatusCode ? e.httpStatusCode : e.kind === "client" ? 400 : 500, e);
});
// Log IP
if (user) {
fetchMeta().then((meta)=>{
if (!meta.enableIpLogging) return;
const ip = ctx.ip;
const ips = userIpHistories.get(user.id);
if (ips == null || !ips.has(ip)) {
if (ips == null) {
userIpHistories.set(user.id, new Set([
ip
]));
} else {
ips.add(ip);
}
try {
UserIps.createQueryBuilder().insert().values({
createdAt: new Date(),
userId: user.id,
ip: ip
}).orIgnore(true).execute();
} catch {}
}
});
}
}).catch((e)=>{
if (e instanceof AuthenticationError) {
ctx.response.status = 403;
ctx.response.set("WWW-Authenticate", "Bearer");
ctx.response.body = {
message: `Authentication failed: ${e.message}`,
code: "AUTHENTICATION_FAILED",
id: "b0a7f5f8-dc2f-4171-b91f-de88ad238e14",
kind: "client"
};
res();
} else {
reply(500, new ApiError());
}
});
}));
@@ -0,0 +1,87 @@
import isNativeToken from "./common/is-native-token.js";
import { Users, AccessTokens, Apps } from "../../models/index.js";
import { Cache } from "../../misc/cache.js";
import { localUserByIdCache, localUserByNativeTokenCache } from "../../services/user-cache.js";
const appCache = new Cache("app", 60 * 30);
export class AuthenticationError extends Error {
constructor(message){
super(message);
this.name = "AuthenticationError";
}
}
export default (async (authorization, bodyToken, bypassUserCache = false)=>{
let token = null;
// check if there is an authorization header set
if (authorization != null) {
if (bodyToken != null) {
throw new AuthenticationError("using multiple authorization schemes");
}
// check if OAuth 2.0 Bearer tokens are being used
// Authorization schemes are case insensitive
if (authorization.substring(0, 7).toLowerCase() === "bearer ") {
token = authorization.substring(7);
} else {
throw new AuthenticationError("unsupported authentication scheme");
}
} else if (bodyToken != null) {
token = bodyToken;
} else {
return [
null,
null
];
}
if (isNativeToken(token)) {
const user = bypassUserCache ? await Users.findOneBy({
token
}) : await localUserByNativeTokenCache.fetch(token, ()=>Users.findOneBy({
token: token ?? undefined
}), true);
if (user == null) {
throw new AuthenticationError("unknown token");
}
return [
user,
null
];
} else {
const accessToken = await AccessTokens.findOne({
where: [
{
hash: token.toLowerCase()
},
{
token: token
}
]
});
if (accessToken == null) {
throw new AuthenticationError("unknown token");
}
AccessTokens.update(accessToken.id, {
lastUsedAt: new Date()
});
const user = bypassUserCache ? await Users.findOneBy({
id: accessToken.userId
}) : await localUserByIdCache.fetch(accessToken.userId, ()=>Users.findOneBy({
id: accessToken.userId
}), true);
if (accessToken.appId) {
const app = await appCache.fetch(accessToken.appId, ()=>Apps.findOneByOrFail({
id: accessToken.appId
}), true);
return [
user,
{
id: accessToken.id,
permission: app.permission
}
];
} else {
return [
user,
accessToken
];
}
}
});
+149
View File
@@ -0,0 +1,149 @@
import { performance } from "perf_hooks";
import { getIpHash } from "../../misc/get-ip-hash.js";
import { limiter } from "./limiter.js";
import endpoints from "./endpoints.js";
import compatibility from "./compatibility.js";
import { ApiError } from "./error.js";
import { apiLogger } from "./logger.js";
import { fetchMeta } from "../../misc/fetch-meta.js";
const accessDenied = {
message: "Access denied.",
code: "ACCESS_DENIED",
id: "56f35758-7dd5-468b-8439-5d6fb8ec9b8e"
};
export default (async (endpoint, user, token, data, ctx)=>{
const isSecure = user != null && token == null;
const isModerator = user != null && (user.isModerator || user.isAdmin);
const ep = endpoints.find((e)=>e.name === endpoint) || compatibility.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.limit) {
// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
let limitActor;
if (user) {
limitActor = user.id;
} else {
limitActor = getIpHash(ctx.ip);
}
const limit = Object.assign({}, ep.meta.limit);
if (!limit.key) {
limit.key = ep.name;
}
// Rate limit
await limiter(limit, limitActor).catch((e)=>{
const remainingTime = e.remainingTime ? `Please try again in ${e.remainingTime}.` : "Please try again later.";
throw new ApiError({
message: `Rate limit exceeded. ${remainingTime}`,
code: "RATE_LIMIT_EXCEEDED",
id: "d5826d14-3982-4d2e-8011-b9e9f02499ef",
httpStatusCode: 429
});
});
}
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 an admin."
});
}
if (ep.meta.requireModerator && !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"
});
}
// private mode
const meta = await fetchMeta();
if (meta.privateMode && ep.meta.requireCredentialPrivateMode && user == null) {
throw new ApiError({
message: "Credential required.",
code: "CREDENTIAL_REQUIRED",
id: "1384574d-a912-4b81-8601-c7b1c4085df1",
httpStatusCode: 401
});
}
// Cast non JSON input
if ((ep.meta.requireFile || ctx?.method === "GET") && ep.params.properties) {
for (const k of Object.keys(ep.params.properties)){
const param = ep.params.properties[k];
if ([
"boolean",
"number",
"integer"
].includes(param.type ?? "") && typeof data[k] === "string") {
try {
data[k] = JSON.parse(data[k]);
} catch (e) {
throw new ApiError({
message: "Invalid param.",
code: "INVALID_PARAM",
id: "0b5f1631-7c1a-41a6-b399-cce335f34d85"
}, {
param: k,
reason: `cannot cast to ${param.type}`
});
}
}
}
}
// API invoking
const before = performance.now();
return await ep.exec(data, user, token, ctx?.file, ctx?.ip, ctx?.headers).catch((e)=>{
if (e instanceof ApiError) {
throw e;
} else {
apiLogger.error(`Internal error occurred in ${ep.name}: ${e.message}`, {
ep: ep.name,
ps: data,
e: {
message: e.message,
code: e.name,
stack: e.stack
}
});
throw new ApiError(null, {
e: {
message: e.message,
code: e.name,
stack: e.stack
}
});
}
}).finally(()=>{
const after = performance.now();
const time = after - before;
if (time > 1000) {
apiLogger.warn(`SLOW API CALL DETECTED: ${ep.name} (${time}ms)`);
}
});
});
@@ -0,0 +1,35 @@
import { Blockings } from "../../../models/index.js";
import { Brackets } from "typeorm";
// ここでいうBlockedは被Blockedの意
export function generateBlockedUserQuery(q, me) {
const blockingQuery = Blockings.createQueryBuilder("blocking").select("blocking.blockerId").where("blocking.blockeeId = :blockeeId", {
blockeeId: me.id
}).andWhere("blocking.groupId IS NULL");
const groupBlockingQuery = Blockings.createQueryBuilder("blocking").select("blocking.groupId").where("blocking.blockeeId = :groupBlockeeId", {
groupBlockeeId: me.id
}).andWhere("blocking.groupId IS NOT NULL");
// 投稿の作者にブロックされていない かつ
// 投稿の返信先の作者にブロックされていない かつ
// 投稿の引用元の作者にブロックされていない
q.andWhere(`note.userId NOT IN (${blockingQuery.getQuery()})`).andWhere(new Brackets((qb)=>{
qb.where("note.groupId IS NULL").orWhere(`note.groupId NOT IN (${groupBlockingQuery.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());
q.setParameters(groupBlockingQuery.getParameters());
}
export function generateBlockQueryForUsers(q, me) {
const blockingQuery = Blockings.createQueryBuilder("blocking").select("blocking.blockeeId").where("blocking.blockerId = :blockerId", {
blockerId: me.id
});
const blockedQuery = Blockings.createQueryBuilder("blocking").select("blocking.blockerId").where("blocking.blockeeId = :blockeeId", {
blockeeId: me.id
});
q.andWhere(`user.id NOT IN (${blockingQuery.getQuery()})`);
q.setParameters(blockingQuery.getParameters());
q.andWhere(`user.id NOT IN (${blockedQuery.getQuery()})`);
q.setParameters(blockedQuery.getParameters());
}
@@ -0,0 +1,18 @@
import { ChannelFollowings } from "../../../models/index.js";
import { Brackets } from "typeorm";
export function generateChannelQuery(q, me) {
if (me == null) {
q.andWhere("note.channelId IS NULL");
} else {
q.leftJoinAndSelect("note.channel", "channel");
const channelFollowingQuery = ChannelFollowings.createQueryBuilder("channelFollowing").select("channelFollowing.followeeId").where("channelFollowing.followerId = :followerId", {
followerId: me.id
});
q.andWhere(new Brackets((qb)=>{
qb// チャンネルのノートではない
.where("note.channelId IS NULL")// または自分がフォローしているチャンネルのノート
.orWhere(`note.channelId IN (${channelFollowingQuery.getQuery()})`);
}));
q.setParameters(channelFollowingQuery.getParameters());
}
}
@@ -0,0 +1,3 @@
export function generateExcludeMemorietQuery(query) {
query.andWhere(`NOT EXISTS (SELECT 1 FROM "memoriet" "memoriet_exclude" WHERE "memoriet_exclude"."noteId" = note.id)`);
}
@@ -0,0 +1,40 @@
import { Brackets } from "typeorm";
import { Followings, Notes } from "../../../models/index.js";
import { Cache } from "../../../misc/cache.js";
import { apiLogger } from "../logger.js";
export const cache = new Cache("homeTlQueryData", 60 * 60 * 24);
const cutoff = 250; // 250 posts in the last 7 days, constant determined by comparing benchmarks for cutoff values between 100 and 2500
const logger = apiLogger.createSubLogger("heuristics");
export async function generateFollowingQuery(q, me) {
const followingQuery = Followings.createQueryBuilder("following").select("following.followeeId").where("following.followerId = :meId");
const heuristic = await cache.fetch(me.id, async ()=>{
let curr = new Date();
let prev = new Date();
prev.setDate(prev.getDate() - 7);
return Notes.createQueryBuilder('note').where(`note.createdAt > :prev`, {
prev
}).andWhere(`note.createdAt < :curr`, {
curr
}).andWhere(new Brackets((qb)=>{
qb.where(`note.userId IN (${followingQuery.getQuery()})`);
qb.orWhere(`note.userId = :meId`, {
meId: me.id
});
})).getCount().then((res)=>{
logger.info(`Calculating heuristics for user ${me.id} took ${new Date().getTime() - curr.getTime()}ms`);
return res;
});
});
const shouldUseUnion = heuristic < cutoff;
q.andWhere(new Brackets((qb)=>{
if (shouldUseUnion) {
qb.where(`note.userId = ANY(array(${followingQuery.getQuery()} UNION ALL VALUES (:meId)))`);
} else {
qb.where(`note.userId = :meId`);
qb.orWhere(`note.userId IN (${followingQuery.getQuery()})`);
}
}));
q.setParameters({
meId: me.id
});
}
@@ -0,0 +1,234 @@
import { Brackets } from "typeorm";
import { sqlLikeEscape } from "../../../misc/sql-like-escape.js";
import { sqlRegexEscape } from "../../../misc/sql-regex-escape.js";
import { Followings, NoteFavorites, NoteReactions, Users } from "../../../models/index.js";
const filters = {
"from": fromFilter,
"-from": fromFilterInverse,
"mention": mentionFilter,
"-mention": mentionFilterInverse,
"reply": replyFilter,
"-reply": replyFilterInverse,
"to": replyFilter,
"-to": replyFilterInverse,
"before": beforeFilter,
"until": beforeFilter,
"after": afterFilter,
"since": afterFilter,
"instance": instanceFilter,
"-instance": instanceFilterInverse,
"domain": instanceFilter,
"-domain": instanceFilterInverse,
"host": instanceFilter,
"-host": instanceFilterInverse,
"filter": miscFilter,
"-filter": miscFilterInverse,
"in": inFilter,
"-in": inFilterInverse,
"has": attachmentFilter
};
export function generateFtsQuery(query, q) {
const components = q.trim().split(" ");
const terms = [];
let finalTerms = [];
let counter = 0;
let caseSensitive = false;
let matchWords = false;
for (const component of components){
const split = component.split(":");
if (split.length > 1 && filters[split[0]] !== undefined) filters[split[0]](query, split.slice(1).join(":"), counter++);
else if (split.length > 1 && (split[0] === "search" || split[0] === "match")) matchWords = split[1] === 'word' || split[1] === 'words';
else if (split.length > 1 && split[0] === "case") caseSensitive = split[1] === 'sensitive';
else terms.push(component);
}
let idx = 0;
let state = 'idle';
for(let i = 0; i < terms.length; i++){
if (state === 'idle') {
if (terms[i].startsWith('"') && terms[i].endsWith('"') || terms[i].startsWith('(') && terms[i].endsWith(')')) {
finalTerms.push(trimStartAndEnd(terms[i]));
} else if (terms[i].startsWith('"')) {
idx = i;
state = 'quote';
} else if (terms[i].startsWith('(')) {
idx = i;
state = 'parenthesis';
} else {
finalTerms.push(terms[i]);
}
} else if (state === 'quote' && terms[i].endsWith('"')) {
finalTerms.push(extractToken(terms, idx, i));
state = 'idle';
} else if (state === 'parenthesis' && terms[i].endsWith(')')) {
query.andWhere(new Brackets((qb)=>{
for (const term of extractToken(terms, idx, i).split(' OR ')){
const id = counter++;
appendSearchQuery(term, 'or', query, qb, id, term.startsWith('-'), matchWords, caseSensitive);
}
}));
state = 'idle';
}
}
if (state != "idle") {
finalTerms.push(...extractToken(terms, idx, terms.length - 1, false).substring(1).split(' '));
}
for (const term of finalTerms){
const id = counter++;
appendSearchQuery(term, 'and', query, query, id, term.startsWith('-'), matchWords, caseSensitive);
}
}
function fromFilter(query, filter, id) {
const userQuery = generateUserSubquery(filter, id);
query.andWhere(`note.userId = (${userQuery.getQuery()})`);
query.setParameters(userQuery.getParameters());
}
function fromFilterInverse(query, filter, id) {
const userQuery = generateUserSubquery(filter, id);
query.andWhere(`note.userId <> (${userQuery.getQuery()})`);
query.setParameters(userQuery.getParameters());
}
function mentionFilter(query, filter, id) {
const userQuery = generateUserSubquery(filter, id);
query.addCommonTableExpression(userQuery.getQuery(), `cte_${id}`, {
materialized: true
});
query.andWhere(`note.mentions @> array[(SELECT * FROM cte_${id})]::varchar[]`);
query.setParameters(userQuery.getParameters());
}
function mentionFilterInverse(query, filter, id) {
const userQuery = generateUserSubquery(filter, id);
query.addCommonTableExpression(userQuery.getQuery(), `cte_${id}`, {
materialized: true
});
query.andWhere(`NOT (note.mentions @> array[(SELECT * FROM cte_${id})]::varchar[])`);
query.setParameters(userQuery.getParameters());
}
function replyFilter(query, filter, id) {
const userQuery = generateUserSubquery(filter, id);
query.andWhere(`note.replyUserId = (${userQuery.getQuery()})`);
query.setParameters(userQuery.getParameters());
}
function replyFilterInverse(query, filter, id) {
const userQuery = generateUserSubquery(filter, id);
query.andWhere(`note.replyUserId <> (${userQuery.getQuery()})`);
query.setParameters(userQuery.getParameters());
}
function beforeFilter(query, filter) {
query.andWhere('note.createdAt < :before', {
before: filter
});
}
function afterFilter(query, filter) {
query.andWhere('note.createdAt > :after', {
after: filter
});
}
function instanceFilter(query, filter, id) {
if (filter === 'local') {
query.andWhere(`note.userHost IS NULL`);
} else {
query.andWhere(`note.userHost = :instance_${id}`);
query.setParameter(`instance_${id}`, filter);
}
}
function instanceFilterInverse(query, filter, id) {
if (filter === 'local') {
query.andWhere(`note.userHost IS NOT NULL`);
} else {
query.andWhere(`note.userHost <> :instance_${id}`);
query.setParameter(`instance_${id}`, filter);
}
}
function miscFilter(query, filter) {
let subQuery = null;
if (filter === 'followers') {
subQuery = Followings.createQueryBuilder('following').select('following.followerId').where('following.followeeId = :meId');
} else if (filter === 'following') {
subQuery = Followings.createQueryBuilder('following').select('following.followeeId').where('following.followerId = :meId');
} else if (filter === 'replies' || filter === 'reply') {
query.andWhere('note.replyId IS NOT NULL');
} else if (filter === 'boosts' || filter === 'boost' || filter === 'renotes' || filter === 'renote') {
query.andWhere('note.renoteId IS NOT NULL');
}
if (subQuery !== null) query.andWhere(`note.userId IN (${subQuery.getQuery()})`);
}
function miscFilterInverse(query, filter) {
let subQuery = null;
if (filter === 'followers') {
subQuery = Followings.createQueryBuilder('following').select('following.followerId').where('following.followeeId = :meId');
} else if (filter === 'following') {
subQuery = Followings.createQueryBuilder('following').select('following.followeeId').where('following.followerId = :meId');
} else if (filter === 'replies' || filter === 'reply') {
query.andWhere('note.replyId IS NULL');
} else if (filter === 'boosts' || filter === 'boost' || filter === 'renotes' || filter === 'renote') {
query.andWhere('note.renoteId IS NULL');
}
if (subQuery !== null) query.andWhere(`note.userId NOT IN (${subQuery.getQuery()})`);
}
function inFilter(query, filter) {
let subQuery = null;
if (filter === 'bookmarks') {
subQuery = NoteFavorites.createQueryBuilder('bookmark').select('bookmark.noteId').where('bookmark.userId = :meId');
} else if (filter === 'favorites' || filter === 'favourites' || filter === 'reactions' || filter === 'likes') {
subQuery = NoteReactions.createQueryBuilder('react').select('react.noteId').where('react.userId = :meId');
}
if (subQuery !== null) query.andWhere(`note.id IN (${subQuery.getQuery()})`);
}
function inFilterInverse(query, filter) {
let subQuery = null;
if (filter === 'bookmarks') {
subQuery = NoteFavorites.createQueryBuilder('bookmark').select('bookmark.noteId').where('bookmark.userId = :meId');
} else if (filter === 'favorites' || filter === 'favourites' || filter === 'reactions' || filter === 'likes') {
subQuery = NoteReactions.createQueryBuilder('react').select('react.noteId').where('react.userId = :meId');
}
if (subQuery !== null) query.andWhere(`note.id NOT IN (${subQuery.getQuery()})`);
}
function attachmentFilter(query, filter) {
switch(filter){
case 'image':
query.andWhere(`note."attachedFileTypes"::varchar ILIKE '%image/%'`);
break;
case 'video':
query.andWhere(`note."attachedFileTypes"::varchar ILIKE '%video/%'`);
break;
case 'audio':
query.andWhere(`note."attachedFileTypes"::varchar ILIKE '%audio/%'`);
break;
case 'file':
query.andWhere(`note."attachedFileTypes" <> '{}'`);
query.andWhere(`NOT (note."attachedFileTypes"::varchar ILIKE '%image/%')`);
query.andWhere(`NOT (note."attachedFileTypes"::varchar ILIKE '%video/%')`);
query.andWhere(`NOT (note."attachedFileTypes"::varchar ILIKE '%audio/%')`);
break;
default:
break;
}
}
function generateUserSubquery(filter, id) {
if (filter.startsWith('@')) filter = filter.substring(1);
const split = filter.split('@');
const query = Users.createQueryBuilder('user').select('user.id').where(`user.usernameLower = :user_${id}`).andWhere(`user.host ${split[1] !== undefined ? `= :host_${id}` : 'IS NULL'}`);
query.setParameter(`user_${id}`, split[0].toLowerCase());
if (split[1] !== undefined) query.setParameter(`host_${id}`, split[1].toLowerCase());
return query;
}
function extractToken(array, start, end, trim = true) {
const slice = array.slice(start, end + 1).join(" ");
return trim ? trimStartAndEnd(slice) : slice;
}
function trimStartAndEnd(str) {
return str.substring(1, str.length - 1);
}
function appendSearchQuery(term, mode, query, qb, id, negate, matchWords, caseSensitive) {
const sql = `note.text ${getSearchMatchOperator(negate, matchWords, caseSensitive)} :q_${id}`;
if (mode === 'and') qb.andWhere(sql);
else if (mode === 'or') qb.orWhere(sql);
query.setParameter(`q_${id}`, escapeSqlSearchParam(term.substring(negate ? 1 : 0), matchWords));
}
function getSearchMatchOperator(negate, matchWords, caseSensitive) {
const negatePrefix = matchWords ? '!' : 'NOT ';
return `${negate ? negatePrefix : ''}${matchWords ? caseSensitive ? '~' : '~*' : caseSensitive ? 'LIKE' : 'ILIKE'}`;
}
function escapeSqlSearchParam(param, matchWords) {
return matchWords ? `\\y${sqlRegexEscape(param)}\\y` : `%${sqlLikeEscape(param)}%`;
}
@@ -0,0 +1,13 @@
import { Brackets } from "typeorm";
import { UserListJoinings, UserLists } from "../../../models/index.js";
export function generateListQuery(q, me) {
const listQuery = UserLists.createQueryBuilder("list").select("list.id").where("list.hideFromHomeTl = TRUE").andWhere("list.userId = :meId");
const memberQuery = UserListJoinings.createQueryBuilder("member").select("member.userId").where(`member.userListId IN (${listQuery.getQuery()})`);
q.andWhere(new Brackets((qb)=>{
qb.where(`note.userId = :meId`);
qb.orWhere(`note.userId NOT IN (${memberQuery.getQuery()})`);
}));
q.setParameters({
meId: me.id
});
}
@@ -0,0 +1,18 @@
import { Brackets } from "typeorm";
export function shouldHideEUsersFor(viewer) {
return !!viewer && !viewer.isAdmin && !viewer.isModerator && (viewer.minorBadges ?? []).some((badge)=>badge === "K" || badge === "T");
}
export function generateMinorBadgeUserVisibilityQuery(q, viewer, alias = "user") {
if (!shouldHideEUsersFor(viewer)) return;
q.andWhere(new Brackets((qb)=>{
qb.where(`${alias}.id = :minorBadgeViewerId`).orWhere(`NOT ('E' = ANY(${alias}."minorBadges"))`);
}));
q.setParameter("minorBadgeViewerId", viewer.id);
}
export function generateMinorBadgeNoteVisibilityQuery(q, viewer, noteAlias = "note") {
if (!shouldHideEUsersFor(viewer)) return;
q.andWhere(new Brackets((qb)=>{
qb.where(`${noteAlias}."userId" = :minorBadgeViewerId`).orWhere(`${noteAlias}."userId" NOT IN (` + `SELECT "id" FROM "user" WHERE 'E' = ANY("minorBadges")` + `)`);
}));
q.setParameter("minorBadgeViewerId", viewer.id);
}
@@ -0,0 +1,12 @@
import { NoteThreadMutings } from "../../../models/index.js";
import { Brackets } from "typeorm";
export function generateMutedNoteThreadQuery(q, me) {
const mutedQuery = NoteThreadMutings.createQueryBuilder("threadMuted").select("threadMuted.threadId").where("threadMuted.userId = :userId", {
userId: me.id
});
q.andWhere(`note.id NOT IN (${mutedQuery.getQuery()})`);
q.andWhere(new Brackets((qb)=>{
qb.where("note.threadId IS NULL").orWhere(`note.threadId NOT IN (${mutedQuery.getQuery()})`);
}));
q.setParameters(mutedQuery.getParameters());
}
@@ -0,0 +1,39 @@
import { Brackets } from "typeorm";
import { Mutings, UserProfiles } from "../../../models/index.js";
export function generateMutedUserQuery(q, me, exclude) {
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
});
}
const mutingInstanceQuery = UserProfiles.createQueryBuilder("user_profile").select("user_profile.mutedInstances").where("user_profile.userId = :muterId", {
muterId: me.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()})`);
}))// mute instances
.andWhere(new Brackets((qb)=>{
qb.andWhere("note.userHost IS NULL").orWhere(`NOT ((${mutingInstanceQuery.getQuery()})::jsonb ? note.userHost)`);
})).andWhere(new Brackets((qb)=>{
qb.where("note.replyUserHost IS NULL").orWhere(`NOT ((${mutingInstanceQuery.getQuery()})::jsonb ? note.replyUserHost)`);
})).andWhere(new Brackets((qb)=>{
qb.where("note.renoteUserHost IS NULL").orWhere(`NOT ((${mutingInstanceQuery.getQuery()})::jsonb ? note.renoteUserHost)`);
}));
q.setParameters(mutingQuery.getParameters());
q.setParameters(mutingInstanceQuery.getParameters());
}
export function generateMutedUserQueryForUsers(q, me) {
const mutingQuery = Mutings.createQueryBuilder("muting").select("muting.muteeId").where("muting.muterId = :muterId", {
muterId: me.id
});
q.andWhere(`user.id NOT IN (${mutingQuery.getQuery()})`);
q.setParameters(mutingQuery.getParameters());
}
@@ -0,0 +1,2 @@
import { secureRndstr } from "../../../misc/secure-rndstr.js";
export default (()=>secureRndstr(16));
@@ -0,0 +1,28 @@
import { Brackets } from "typeorm";
export function generateRepliesQuery(q, withReplies, me) {
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 if (!withReplies) {
q.andWhere(new Brackets((qb)=>{
qb.where("note.replyId IS NULL") // 返信ではない
.orWhere("note.replyUserId = :meId", {
meId: me.id
}) // 返信だけど自分のノートへの返信
.orWhere(new Brackets((qb)=>{
qb.where("note.replyId IS NOT NULL") // 返信だけど自分の行った返信
.andWhere("note.userId = :meId", {
meId: me.id
});
})).orWhere(new Brackets((qb)=>{
qb.where("note.replyId IS NOT NULL") // 返信だけど投稿者自身への返信
.andWhere("note.replyUserId = note.userId");
}));
}));
}
}
@@ -0,0 +1,43 @@
import { Followings } from "../../../models/index.js";
import { Brackets } from "typeorm";
import { generateMinorBadgeNoteVisibilityQuery } from "./generate-minor-badge-visibility-query.js";
export function generateVisibilityQuery(q, me, options) {
// This code must always be synchronized with the checks in Notes.isVisibleForMe.
if (me == null) {
q.andWhere(new Brackets((qb)=>{
qb.where(`note.visibility = 'public'`).orWhere(`note.visibility = 'home'`);
})).andWhere('note.localOnly = FALSE');
} else {
const followingQuery = Followings.createQueryBuilder("following").select("following.followeeId").where("following.followerId = :meId");
q.andWhere(new Brackets((qb)=>{
qb// 公開投稿である
.where(new Brackets((qb)=>{
qb.where(`note.visibility = 'public'`).orWhere(`note.visibility = 'home'`);
}))// または 自分自身
.orWhere("note.userId = :meId")// または 自分宛て
.orWhere(":meId = ANY(note.visibleUserIds)").orWhere(":meId = ANY(note.mentions)").orWhere(new Brackets((qb)=>{
qb// または フォロワー宛ての投稿であり、
.where(`note.visibility = 'followers'`).andWhere(new Brackets((qb)=>{
qb// 自分がフォロワーである
.where(`note.userId IN (${followingQuery.getQuery()})`)// または 自分の投稿へのリプライ
.orWhere("note.replyUserId = :meId");
}));
}));
}));
q.andWhere(new Brackets((qb)=>{
qb.where(`note.visibility != 'hidden'`).orWhere(`note.userId = :meId`);
}));
q.setParameters({
meId: me.id
});
}
if (!options?.allowAdservice) {
q.andWhere(new Brackets((qb)=>{
qb.where(`NOT ('adservice' = ANY(note.tags))`);
if (me) {
qb.orWhere("note.userId = :meId");
}
}));
}
generateMinorBadgeNoteVisibilityQuery(q, me);
}
@@ -0,0 +1,15 @@
import { Brackets } from "typeorm";
import { RenoteMutings } from "../../../models/index.js";
export function generateMutedUserRenotesQueryForNotes(q, me) {
const mutingQuery = RenoteMutings.createQueryBuilder("renote_muting").select("renote_muting.muteeId").where("renote_muting.muterId = :muterId", {
muterId: me.id
});
q.andWhere(new Brackets((qb)=>{
qb.where(new Brackets((qb)=>{
qb.where("note.renoteId IS NOT NULL");
qb.andWhere("note.text IS NULL");
qb.andWhere(`note.userId NOT IN (${mutingQuery.getQuery()})`);
})).orWhere("note.renoteId IS NULL").orWhere("note.text IS NOT NULL");
}));
q.setParameters(mutingQuery.getParameters());
}
@@ -0,0 +1,14 @@
import { UserGroupJoinings, UserGroups } from "../../../models/index.js";
export async function getGroupActor(groupId, user) {
if (groupId == null) return null;
const group = await UserGroups.findOneBy({
id: groupId
});
if (group == null) return null;
if (group.userId === user.id) return group;
const joining = await UserGroupJoinings.findOneBy({
userId: user.id,
userGroupId: group.id
});
return joining == null ? null : group;
}
@@ -0,0 +1,45 @@
import { IdentifiableError } from "../../../misc/identifiable-error.js";
import { Notes, Users } from "../../../models/index.js";
import { generateVisibilityQuery } from "./generate-visibility-query.js";
/**
* Get note for API processing, taking into account visibility.
*/ export async function getNote(noteId, me, options) {
const query = Notes.createQueryBuilder("note").where("note.id = :id", {
id: noteId
});
generateVisibilityQuery(query, me, options);
const note = await query.getOne();
if (note == null || me == null && note.localOnly) {
throw new IdentifiableError("9725d0ce-ba28-4dde-95a7-2cbb2c15de24", "No such note.");
}
return note;
}
/**
* Get user for API processing
*/ export async function getUser(userId) {
const user = await Users.findOneBy({
id: 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) {
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) {
const user = await getUser(userId);
if (!Users.isLocalUser(user)) {
throw new Error("user is not a local user");
}
return user;
}
@@ -0,0 +1,37 @@
import rndstr from "rndstr";
import { Notes, UserProfiles, NoteReactions } from "../../../models/index.js";
import { generateMutedUserQuery } from "./generate-muted-user-query.js";
import { generateBlockedUserQuery } from "./generate-block-query.js";
// TODO: リアクション、Renote、返信などをしたノートは除外する
export async function injectFeatured(timeline, user) {
if (timeline.length < 5) return;
if (user) {
const profile = await UserProfiles.findOneByOrFail({
userId: 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._featuredId_ = rndstr("a-z0-9", 8);
// Inject featured
timeline.splice(3, 0, featured);
}
@@ -0,0 +1,53 @@
import rndstr from "rndstr";
import { PromoReads, PromoNotes, Notes, Users } from "../../../models/index.js";
import { shouldHideEUsersFor } from "./generate-minor-badge-visibility-query.js";
import { readPromo } from "./read-promo.js";
const systemTags = new Set([
"adservice",
"videoservice",
"audioservice",
"imageservice",
"karaokeservice",
"lua4frozen"
]);
function isExplicitAd(note) {
return note.tags.includes("explicit") || note.user?.minorBadges?.includes("E") === true;
}
export async function injectPromo(timeline, user, preferredTag) {
// TODO: readやexpireフィルタはクエリ側でやる
const readDay = new Date().toISOString().slice(0, 10);
const reads = user ? await PromoReads.findBy({
userId: user.id,
readDay
}) : [];
let promos = await PromoNotes.find();
promos = promos.filter((n)=>n.expiresAt.getTime() > Date.now());
promos = promos.filter((n)=>n.remainingCredits > 0);
promos = promos.filter((n)=>!reads.map((r)=>r.noteId).includes(n.noteId));
if (promos.length === 0) return;
const candidates = [];
for (const promo of promos){
const note = await Notes.findOneBy({
id: promo.noteId
});
if (!note?.tags.includes("adservice")) continue;
note.user = await Users.findOneByOrFail({
id: note.userId
});
if (user && shouldHideEUsersFor(user) && isExplicitAd(note)) continue;
candidates.push(note);
}
if (candidates.length === 0) return;
const normalizedPreferredTag = preferredTag?.trim().toLowerCase().replace(/^#/, "");
const priority = normalizedPreferredTag && !systemTags.has(normalizedPreferredTag) ? candidates.filter((note)=>note.tags.includes(normalizedPreferredTag)) : [];
const pool = priority.length > 0 ? priority : candidates;
// Pick random promo
const note = pool[Math.floor(Math.random() * pool.length)];
const promo = promos.find((promo)=>promo.noteId === note.id);
note._prId_ = rndstr("a-z0-9", 8);
if (user && promo) {
await readPromo(note, promo, user);
}
// Inject promo
timeline.splice(Math.min(3, timeline.length), 0, note);
}
@@ -0,0 +1 @@
export default ((token)=>token.length === 16);
@@ -0,0 +1,42 @@
export function makePaginationQuery(q, sinceId, untilId, sinceDate, untilDate) {
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;
}
@@ -0,0 +1,125 @@
import { publishMainStream, publishGroupMessagingStream } from "../../../services/stream.js";
import { publishMessagingStream } from "../../../services/stream.js";
import { publishMessagingIndexStream } from "../../../services/stream.js";
import { pushNotification } from "../../../services/push-notification.js";
import { MessagingMessages, UserGroupJoinings, Users } from "../../../models/index.js";
import { In } from "typeorm";
import { IdentifiableError } from "../../../misc/identifiable-error.js";
import { toArray } from "../../../prelude/array.js";
import { renderReadActivity } from "../../../remote/activitypub/renderer/read.js";
import { renderActivity } from "../../../remote/activitypub/renderer/index.js";
import { deliver } from "../../../queue/index.js";
import orderedCollection from "../../../remote/activitypub/renderer/ordered-collection.js";
/**
* Mark messages as read
*/ export async function readUserMessagingMessage(userId, otherpartyId, messageIds) {
if (messageIds.length === 0) return;
const messages = await MessagingMessages.findBy({
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");
pushNotification(userId, "readAllMessagingMessages", undefined);
} else {
// そのユーザーとのメッセージで未読がなければイベント発行
const count = await MessagingMessages.count({
where: {
userId: otherpartyId,
recipientId: userId,
isRead: false
},
take: 1
});
if (!count) {
pushNotification(userId, "readAllMessagingMessagesOfARoom", {
userId: otherpartyId
});
}
}
}
/**
* Mark messages as read
*/ export async function readGroupMessagingMessage(userId, groupId, messageIds) {
if (messageIds.length === 0) return;
// check joined
const joining = await UserGroupJoinings.findOneBy({
userId: userId,
userGroupId: groupId
});
if (joining == null) {
throw new IdentifiableError("930a270c-714a-46b2-b776-ad27276dc569", "Access denied (group).");
}
const messages = await MessagingMessages.findBy({
id: In(messageIds)
});
const reads = [];
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}')`
}).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");
pushNotification(userId, "readAllMessagingMessages", undefined);
} else {
// そのグループにおいて未読がなければイベント発行
const unreadExist = await MessagingMessages.createQueryBuilder("message").where("message.groupId = :groupId", {
groupId: groupId
}).andWhere("message.userId != :userId", {
userId: userId
}).andWhere("NOT (:userId = ANY(message.reads))", {
userId: userId
}).andWhere("message.createdAt > :joinedAt", {
joinedAt: joining.createdAt
}) // 自分が加入する前の会話については、未読扱いしない
.getOne().then((x)=>x != null);
if (!unreadExist) {
pushNotification(userId, "readAllMessagingMessagesOfARoom", {
groupId
});
}
}
}
export async function deliverReadActivity(user, recipient, messages) {
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);
}
}
}
@@ -0,0 +1,36 @@
import { In } from "typeorm";
import { publishMainStream } from "../../../services/stream.js";
import { pushNotification } from "../../../services/push-notification.js";
import { Notifications, Users } from "../../../models/index.js";
export async function readNotification(userId, notificationIds) {
if (notificationIds.length === 0) return;
// Update documents
const result = await Notifications.update({
notifieeId: userId,
id: In(notificationIds),
isRead: false
}, {
isRead: true
});
if (result.affected === 0) return;
if (!await Users.getHasUnreadNotification(userId)) return postReadAllNotifications(userId);
else return postReadNotifications(userId, notificationIds);
}
export async function readNotificationByQuery(userId, query) {
const notificationIds = await Notifications.findBy({
...query,
notifieeId: userId,
isRead: false
}).then((notifications)=>notifications.map((notification)=>notification.id));
return readNotification(userId, notificationIds);
}
function postReadAllNotifications(userId) {
publishMainStream(userId, "readAllNotifications");
return pushNotification(userId, "readAllNotifications", undefined);
}
function postReadNotifications(userId, notificationIds) {
publishMainStream(userId, "readNotifications", notificationIds);
return pushNotification(userId, "readNotifications", {
notificationIds
});
}
@@ -0,0 +1,23 @@
import { PromoNotes, PromoReads } from "../../../models/index.js";
import { genId } from "../../../misc/gen-id.js";
export async function readPromo(note, promo, user) {
if (promo.expiresAt.getTime() <= Date.now() || promo.remainingCredits <= 0 || promo.userId === user.id || note.userId === user.id || user.isAdmin || user.isModerator || user.isBot) {
return;
}
const readDay = new Date().toISOString().slice(0, 10);
const result = await PromoReads.createQueryBuilder().insert().values({
id: genId(),
createdAt: new Date(),
noteId: note.id,
userId: user.id,
readDay
}).orIgnore().returning("id").execute();
if (result.raw.length === 0) {
return;
}
await PromoNotes.createQueryBuilder().update().set({
remainingCredits: ()=>`"remainingCredits" - 1`
}).where(`"noteId" = :noteId`, {
noteId: note.id
}).andWhere(`"remainingCredits" > 0`).execute();
}
@@ -0,0 +1,37 @@
import config from "../../../config/index.js";
import { Signins } from "../../../models/index.js";
import { genId } from "../../../misc/gen-id.js";
import { publishMainStream } from "../../../services/stream.js";
export default function(ctx, user, 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.insert({
id: genId(),
createdAt: new Date(),
userId: user.id,
ip: ctx.ip,
headers: ctx.headers,
success: true
}).then((x)=>Signins.findOneByOrFail(x.identifiers[0]));
// Publish signin event
publishMainStream(user.id, "signin", await Signins.pack(record));
})();
}
@@ -0,0 +1,128 @@
import { generateKeyPair } from "node:crypto";
import generateUserToken from "./generate-native-user-token.js";
import { User } from "../../../models/entities/user.js";
import { Users, UsedUsernames } from "../../../models/index.js";
import { UserProfile } from "../../../models/entities/user-profile.js";
import { IsNull } from "typeorm";
import { genId } from "../../../misc/gen-id.js";
import { toPunyNullable } from "../../../misc/convert-host.js";
import { UserKeypair } from "../../../models/entities/user-keypair.js";
import { usersChart } from "../../../services/chart/index.js";
import { UsedUsername } from "../../../models/entities/used-username.js";
import { db } from "../../../db/postgre.js";
import config from "../../../config/index.js";
import { hashPassword } from "../../../misc/password.js";
import { fetchMeta } from "../../../misc/fetch-meta.js";
import follow from "../../../services/following/create.js";
export async function signup(opts) {
const { username, password, passwordHash, host } = opts;
let hash = passwordHash;
const userCount = await Users.countBy({
host: IsNull()
});
if (config.maxUserSignups != null && userCount > config.maxUserSignups) {
throw new Error("MAX_USERS_REACHED");
}
// Validate username
if (!Users.validateLocalUsername(username)) {
throw new Error("INVALID_USERNAME");
}
if (password != null && passwordHash == null) {
// Validate password
if (!Users.validatePassword(password)) {
throw new Error("INVALID_PASSWORD");
}
// Generate hash of password
hash = await hashPassword(password);
}
// Generate secret
const secret = generateUserToken();
// Check username duplication
if (await Users.findOneBy({
usernameLower: username.toLowerCase(),
host: IsNull()
})) {
throw new Error("DUPLICATED_USERNAME");
}
// Check deleted username duplication
if (await UsedUsernames.findOneBy({
username: username.toLowerCase()
})) {
throw new Error("USED_USERNAME");
}
const keyPair = await new Promise((res, rej)=>generateKeyPair("rsa", {
modulusLength: 4096,
publicKeyEncoding: {
type: "spki",
format: "pem"
},
privateKeyEncoding: {
type: "pkcs8",
format: "pem",
cipher: undefined,
passphrase: undefined
}
}, (err, publicKey, privateKey)=>err ? rej(err) : res([
publicKey,
privateKey
])));
const exist = await Users.findOneBy({
usernameLower: username.toLowerCase(),
host: IsNull()
});
if (exist) throw new Error("The username is already in use");
// Prepare objects
const user = new User({
id: genId(),
createdAt: new Date(),
username: username,
usernameLower: username.toLowerCase(),
host: toPunyNullable(host),
token: secret,
isAdmin: await Users.countBy({
host: IsNull(),
isAdmin: true
}) === 0
});
const userKeypair = new UserKeypair({
publicKey: keyPair[0],
privateKey: keyPair[1],
userId: user.id
});
const userProfile = new UserProfile({
userId: user.id,
autoAcceptFollowed: true,
allowCalls: false,
password: hash
});
const usedUsername = new UsedUsername({
createdAt: new Date(),
username: username.toLowerCase()
});
// Save the objects atomically using a db transaction, note that we should never run any code in a transaction block directly
await db.transaction(async (transactionalEntityManager)=>{
await transactionalEntityManager.save(user);
await transactionalEntityManager.save(userKeypair);
await transactionalEntityManager.save(userProfile);
await transactionalEntityManager.save(usedUsername);
});
const account = await Users.findOneByOrFail({
id: user.id
});
const meta = await fetchMeta();
// If an autofollow account exists, follow it
if (meta.autofollowedAccount) {
const autofollowedAccount = await Users.findOneByOrFail({
usernameLower: meta.autofollowedAccount.toLowerCase(),
host: IsNull()
});
if (autofollowedAccount) {
await follow(account, autofollowedAccount);
}
}
usersChart.update(account, true);
return {
account,
secret
};
}
@@ -0,0 +1,21 @@
import * as cp___custom_emojis from "./endpoints/compatibility/custom-emojis.js";
import * as ep___instance_peers from "./endpoints/compatibility/peers.js";
const cps = [
[
"v1/custom_emojis",
cp___custom_emojis
],
[
"v1/instance/peers",
ep___instance_peers
]
];
const compatibility = cps.map(([name, cp])=>{
return {
name: name,
exec: cp.default,
meta: cp.meta || {},
params: cp.paramDef
};
});
export default compatibility;
@@ -0,0 +1,38 @@
import * as fs from "node:fs";
import Ajv from "ajv";
import { ApiError } from "./error.js";
const ajv = new Ajv({
useDefaults: true
});
ajv.addFormat("misskey:id", /^[a-zA-Z0-9]+$/);
export default function(meta, paramDef, cb) {
const validate = ajv.compile(paramDef);
return (params, user, token, file, ip, headers)=>{
let cleanup = undefined;
if (meta.requireFile) {
cleanup = ()=>{
fs.unlink(file.path, ()=>{});
};
if (file == null) return Promise.reject(new ApiError({
message: "File required.",
code: "FILE_REQUIRED",
id: "4267801e-70d1-416a-b011-4ee502885d8b"
}));
}
const valid = validate(params);
if (!valid) {
if (file) cleanup();
const errors = validate.errors;
const err = new ApiError({
message: "Invalid param.",
code: "INVALID_PARAM",
id: "3d81ceae-475f-4600-b2a8-2bc116157532"
}, {
param: errors[0].schemaPath,
reason: errors[0].message
});
return Promise.reject(err);
}
return cb(params, user, token, file, cleanup, ip, headers);
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,158 @@
import define from "../../define.js";
import { AbuseUserReports } from "../../../../models/index.js";
import { makePaginationQuery } from "../../common/make-pagination-query.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
res: {
type: "array",
optional: false,
nullable: false,
items: {
type: "object",
optional: false,
nullable: false,
properties: {
id: {
type: "string",
nullable: false,
optional: false,
format: "id",
example: "xxxxxxxxxx"
},
createdAt: {
type: "string",
nullable: false,
optional: false,
format: "date-time"
},
comment: {
type: "string",
nullable: false,
optional: false
},
resolved: {
type: "boolean",
nullable: false,
optional: false,
example: false
},
reporterId: {
type: "string",
nullable: false,
optional: false,
format: "id"
},
targetUserId: {
type: "string",
nullable: false,
optional: false,
format: "id"
},
assigneeId: {
type: "string",
nullable: true,
optional: false,
format: "id"
},
reporter: {
type: "object",
nullable: false,
optional: false,
ref: "User"
},
targetUser: {
type: "object",
nullable: false,
optional: false,
ref: "User"
},
assignee: {
type: "object",
nullable: true,
optional: true,
ref: "User"
}
}
}
}
};
export const paramDef = {
type: "object",
properties: {
limit: {
type: "integer",
minimum: 1,
maximum: 100,
default: 10
},
sinceId: {
type: "string",
format: "misskey:id"
},
untilId: {
type: "string",
format: "misskey:id"
},
state: {
type: "string",
nullable: true,
default: null
},
reporterOrigin: {
type: "string",
enum: [
"combined",
"local",
"remote"
],
default: "combined"
},
targetUserOrigin: {
type: "string",
enum: [
"combined",
"local",
"remote"
],
default: "combined"
},
forwarded: {
type: "boolean",
default: false
}
},
required: []
};
export default define(meta, paramDef, async (ps)=>{
const query = makePaginationQuery(AbuseUserReports.createQueryBuilder("report"), ps.sinceId, ps.untilId);
switch(ps.state){
case "resolved":
query.andWhere("report.resolved = TRUE");
break;
case "unresolved":
query.andWhere("report.resolved = FALSE");
break;
}
switch(ps.reporterOrigin){
case "local":
query.andWhere("report.reporterHost IS NULL");
break;
case "remote":
query.andWhere("report.reporterHost IS NOT NULL");
break;
}
switch(ps.targetUserOrigin){
case "local":
query.andWhere("report.targetUserHost IS NULL");
break;
case "remote":
query.andWhere("report.targetUserHost IS NOT NULL");
break;
}
const reports = await query.take(ps.limit).getMany();
return await AbuseUserReports.packMany(reports);
});
@@ -0,0 +1,53 @@
import define from "../../../define.js";
import { Users } from "../../../../../models/index.js";
import { signup } from "../../../common/signup.js";
import { IsNull } from "typeorm";
export const meta = {
tags: [
"admin"
],
res: {
type: "object",
optional: false,
nullable: false,
ref: "User",
properties: {
token: {
type: "string",
optional: false,
nullable: false
}
}
}
};
export const paramDef = {
type: "object",
properties: {
username: Users.localUsernameSchema,
password: Users.passwordSchema
},
required: [
"username",
"password"
]
};
export default define(meta, paramDef, async (ps, _me)=>{
const me = _me ? await Users.findOneByOrFail({
id: _me.id
}) : null;
const noUsers = await Users.countBy({
host: IsNull(),
isAdmin: true
}) === 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.token = secret;
return res;
});
@@ -0,0 +1,56 @@
import define from "../../../define.js";
import { Users } from "../../../../../models/index.js";
import { doPostSuspend } from "../../../../../services/suspend-user.js";
import { publishUserEvent } from "../../../../../services/stream.js";
import { createDeleteAccountJob } from "../../../../../queue/index.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
userId: {
type: "string",
format: "misskey:id"
}
},
required: [
"userId"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const user = await Users.findOneBy({
id: ps.userId
});
if (user == null) {
throw new Error("user not found");
}
if (user.isAdmin) {
throw new Error("cannot suspend admin");
}
if (user.isModerator) {
throw new Error("cannot suspend moderator");
}
if (Users.isLocalUser(user)) {
// 物理削除する前にDelete activityを送信する
await doPostSuspend(user).catch((e)=>{});
createDeleteAccountJob(user, {
soft: false
});
} else {
createDeleteAccountJob(user, {
soft: true
});
}
await Users.update(user.id, {
isDeleted: true
});
if (Users.isLocalUser(user)) {
// Terminate streaming
publishUserEvent(user.id, "terminate", {});
}
});
@@ -0,0 +1,109 @@
import config from "../../../../../config/index.js";
import { insertModerationLog } from "../../../../../services/insert-moderation-log.js";
import define from "../../../define.js";
import { Metas } from "../../../../../models/index.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireAdmin: true
};
export const paramDef = {
type: "object",
properties: {},
required: []
};
export default define(meta, paramDef, async (ps, me)=>{
const hostedConfig = config.isManagedHosting;
const hosted = hostedConfig != null && hostedConfig === true;
if (hosted) {
const set = {};
if (config.deepl.managed != null && config.deepl.managed === true) {
if (typeof config.deepl.authKey === "boolean") {
set.deeplAuthKey = config.deepl.authKey;
}
if (typeof config.deepl.isPro === "boolean") {
set.deeplIsPro = config.deepl.isPro;
}
}
if (config.libreTranslate.managed != null && config.libreTranslate.managed === true) {
if (typeof config.libreTranslate.apiUrl === "string") {
set.libreTranslateApiUrl = config.libreTranslate.apiUrl;
}
if (typeof config.libreTranslate.apiKey === "string") {
set.libreTranslateApiKey = config.libreTranslate.apiKey;
}
}
if (config.email.managed != null && config.email.managed === true) {
set.enableEmail = true;
if (typeof config.email.address === "string") {
set.email = config.email.address;
}
if (typeof config.email.host === "string") {
set.smtpHost = config.email.host;
}
if (typeof config.email.port === "number") {
set.smtpPort = config.email.port;
}
if (typeof config.email.user === "string") {
set.smtpUser = config.email.user;
}
if (typeof config.email.pass === "string") {
set.smtpPass = config.email.pass;
}
if (typeof config.email.useImplicitSslTls === "boolean") {
set.smtpSecure = config.email.useImplicitSslTls;
}
}
if (config.objectStorage.managed != null && config.objectStorage.managed === true) {
set.useObjectStorage = true;
if (typeof config.objectStorage.baseUrl === "string") {
set.objectStorageBaseUrl = config.objectStorage.baseUrl;
}
if (typeof config.objectStorage.bucket === "string") {
set.objectStorageBucket = config.objectStorage.bucket;
}
if (typeof config.objectStorage.prefix === "string") {
set.objectStoragePrefix = config.objectStorage.prefix;
}
if (typeof config.objectStorage.endpoint === "string") {
set.objectStorageEndpoint = config.objectStorage.endpoint;
}
if (typeof config.objectStorage.region === "string") {
set.objectStorageRegion = config.objectStorage.region;
}
if (typeof config.objectStorage.accessKey === "string") {
set.objectStorageAccessKey = config.objectStorage.accessKey;
}
if (typeof config.objectStorage.secretKey === "string") {
set.objectStorageSecretKey = config.objectStorage.secretKey;
}
if (typeof config.objectStorage.useSsl === "boolean") {
set.objectStorageUseSSL = config.objectStorage.useSsl;
}
if (typeof config.objectStorage.connnectOverProxy === "boolean") {
set.objectStorageUseProxy = config.objectStorage.connnectOverProxy;
}
if (typeof config.objectStorage.setPublicReadOnUpload === "boolean") {
set.objectStorageSetPublicRead = config.objectStorage.setPublicReadOnUpload;
}
if (typeof config.objectStorage.s3ForcePathStyle === "boolean") {
set.objectStorageS3ForcePathStyle = config.objectStorage.s3ForcePathStyle;
}
}
if (config.summalyProxyUrl !== undefined) {
set.summalyProxy = config.summalyProxyUrl;
}
const meta = await Metas.findOne({
where: {},
order: {
id: "DESC"
}
});
if (meta) await Metas.update(meta.id, set);
else await Metas.save(set);
insertModerationLog(me, "updateMeta");
}
return hosted;
});
@@ -0,0 +1,108 @@
import define from "../../../define.js";
import { Announcements } from "../../../../../models/index.js";
import { genId } from "../../../../../misc/gen-id.js";
import { publishBroadcastStream } from "../../../../../services/stream.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
res: {
type: "object",
optional: false,
nullable: false,
properties: {
id: {
type: "string",
optional: false,
nullable: false,
format: "id",
example: "xxxxxxxxxx"
},
createdAt: {
type: "string",
optional: false,
nullable: false,
format: "date-time"
},
updatedAt: {
type: "string",
optional: false,
nullable: true,
format: "date-time"
},
title: {
type: "string",
optional: false,
nullable: false
},
text: {
type: "string",
optional: false,
nullable: false
},
imageUrl: {
type: "string",
optional: false,
nullable: true
},
showPopup: {
type: "boolean",
optional: true,
nullable: false
},
isGoodNews: {
type: "boolean",
optional: true,
nullable: false
}
}
}
};
export const paramDef = {
type: "object",
properties: {
title: {
type: "string",
minLength: 1
},
text: {
type: "string",
minLength: 1
},
imageUrl: {
type: "string",
nullable: true,
minLength: 1
},
showPopup: {
type: "boolean"
},
isGoodNews: {
type: "boolean"
}
},
required: [
"title",
"text",
"imageUrl"
]
};
export default define(meta, paramDef, async (ps)=>{
const announcement = await Announcements.insert({
id: genId(),
createdAt: new Date(),
updatedAt: null,
title: ps.title,
text: ps.text,
imageUrl: ps.imageUrl,
showPopup: ps.showPopup ?? false,
isGoodNews: ps.isGoodNews ?? false
}).then((x)=>Announcements.findOneByOrFail(x.identifiers[0]));
publishBroadcastStream("announcementAdded", announcement);
return Object.assign({}, announcement, {
createdAt: announcement.createdAt.toISOString(),
updatedAt: null
});
});
@@ -0,0 +1,38 @@
import define from "../../../define.js";
import { Announcements } from "../../../../../models/index.js";
import { ApiError } from "../../../error.js";
import { publishBroadcastStream } from "../../../../../services/stream.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
errors: {
noSuchAnnouncement: {
message: "No such announcement.",
code: "NO_SUCH_ANNOUNCEMENT",
id: "ecad8040-a276-4e85-bda9-015a708d291e"
}
}
};
export const paramDef = {
type: "object",
properties: {
id: {
type: "string",
format: "misskey:id"
}
},
required: [
"id"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const announcement = await Announcements.findOneBy({
id: ps.id
});
if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement);
publishBroadcastStream("announcementDeleted", announcement.id);
await Announcements.delete(announcement.id);
});
@@ -0,0 +1,112 @@
import { Announcements, AnnouncementReads } from "../../../../../models/index.js";
import define from "../../../define.js";
import { makePaginationQuery } from "../../../common/make-pagination-query.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
res: {
type: "array",
optional: false,
nullable: false,
items: {
type: "object",
optional: false,
nullable: false,
properties: {
id: {
type: "string",
optional: false,
nullable: false,
format: "id",
example: "xxxxxxxxxx"
},
createdAt: {
type: "string",
optional: false,
nullable: false,
format: "date-time"
},
updatedAt: {
type: "string",
optional: false,
nullable: true,
format: "date-time"
},
text: {
type: "string",
optional: false,
nullable: false
},
title: {
type: "string",
optional: false,
nullable: false
},
imageUrl: {
type: "string",
optional: false,
nullable: true
},
reads: {
type: "number",
optional: false,
nullable: false
},
showPopup: {
type: "boolean",
optional: true,
nullable: false
},
isGoodNews: {
type: "boolean",
optional: true,
nullable: false
}
}
}
}
};
export const paramDef = {
type: "object",
properties: {
limit: {
type: "integer",
minimum: 1,
maximum: 100,
default: 10
},
sinceId: {
type: "string",
format: "misskey:id"
},
untilId: {
type: "string",
format: "misskey:id"
}
},
required: []
};
export default define(meta, paramDef, async (ps)=>{
const query = makePaginationQuery(Announcements.createQueryBuilder("announcement"), ps.sinceId, ps.untilId);
const announcements = await query.take(ps.limit).getMany();
const reads = new Map();
for (const announcement of announcements){
reads.set(announcement, await AnnouncementReads.countBy({
announcementId: announcement.id
}));
}
return announcements.map((announcement)=>({
id: announcement.id,
createdAt: announcement.createdAt.toISOString(),
updatedAt: announcement.updatedAt?.toISOString() ?? null,
title: announcement.title,
text: announcement.text,
imageUrl: announcement.imageUrl,
reads: reads.get(announcement),
showPopup: announcement.showPopup,
isGoodNews: announcement.isGoodNews
}));
});
@@ -0,0 +1,65 @@
import define from "../../../define.js";
import { Announcements } from "../../../../../models/index.js";
import { ApiError } from "../../../error.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
errors: {
noSuchAnnouncement: {
message: "No such announcement.",
code: "NO_SUCH_ANNOUNCEMENT",
id: "d3aae5a7-6372-4cb4-b61c-f511ffc2d7cc"
}
}
};
export const paramDef = {
type: "object",
properties: {
id: {
type: "string",
format: "misskey:id"
},
title: {
type: "string",
minLength: 1
},
text: {
type: "string",
minLength: 1
},
imageUrl: {
type: "string",
nullable: true,
minLength: 1
},
showPopup: {
type: "boolean"
},
isGoodNews: {
type: "boolean"
}
},
required: [
"id",
"title",
"text",
"imageUrl"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const announcement = await Announcements.findOneBy({
id: 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,
showPopup: ps.showPopup ?? false,
isGoodNews: ps.isGoodNews ?? false
});
});
@@ -0,0 +1,194 @@
import * as fs from "node:fs";
import { mkdir, rm, stat, writeFile } from "node:fs/promises";
import { spawn } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import archiver from "archiver";
import config from "../../../../config/index.js";
import define from "../../define.js";
import { ApiError } from "../../error.js";
import { insertModerationLog } from "../../../../services/insert-moderation-log.js";
import { createTempDir } from "../../../../misc/create-temp.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireAdmin: true,
res: {
type: "object",
optional: false,
nullable: false,
properties: {
path: {
type: "string",
optional: false,
nullable: false
},
fileName: {
type: "string",
optional: false,
nullable: false
},
size: {
type: "number",
optional: false,
nullable: false
},
includedMedia: {
type: "boolean",
optional: false,
nullable: false
}
}
},
errors: {
backupFailed: {
message: "Failed to create backup.",
code: "BACKUP_FAILED",
id: "7498ab9f-4e1d-40d0-96d8-d8d1d82bd621"
}
}
};
export const paramDef = {
type: "object",
properties: {},
required: []
};
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../../../..");
function timestamp() {
const now = new Date();
const pad = (value)=>value.toString().padStart(2, "0");
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
}
function run(command, args, env) {
return new Promise((resolvePromise, reject)=>{
const child = spawn(command, args, {
stdio: [
"ignore",
"ignore",
"pipe"
],
env
});
let stderr = "";
child.stderr.on("data", (chunk)=>{
stderr += chunk.toString();
});
child.on("error", reject);
child.on("exit", (code)=>{
if (code === 0) {
resolvePromise();
} else {
reject(new Error(`${command} exited with code ${code}: ${stderr.trim()}`));
}
});
});
}
function archiveDirectory(sourceDir, outFile) {
return new Promise((resolvePromise, reject)=>{
const output = fs.createWriteStream(outFile);
const archive = archiver("tar", {
gzip: true,
gzipOptions: {
level: 6
}
});
output.on("close", ()=>resolvePromise());
archive.on("error", reject);
archive.pipe(output);
archive.directory(sourceDir, false);
archive.finalize();
});
}
async function pathExists(path) {
return stat(path).then(()=>true).catch(()=>false);
}
export default define(meta, paramDef, async (_ps, me)=>{
const [workDir, cleanup] = await createTempDir();
const fileName = `iceshrimp-full-${timestamp()}.tar.gz`;
const outDir = resolve(process.env.ICESHRIMP_BACKUP_DIR ?? `${rootDir}/backups`);
const outFile = resolve(outDir, fileName);
const dbDumpPath = resolve(workDir, "database.dump");
const mediaDir = resolve(config.mediaDir);
let includedMedia = false;
try {
await mkdir(outDir, {
recursive: true
});
const env = {
...process.env,
PGHOST: config.db.host,
PGPORT: String(config.db.port),
PGDATABASE: config.db.db,
PGUSER: config.db.user,
PGPASSWORD: config.db.pass
};
await run("pg_dump", [
"--format=custom",
"--blobs",
"--no-owner",
"--file",
dbDumpPath,
config.db.db
], env);
const configDir = resolve(workDir, "config");
await mkdir(configDir, {
recursive: true
});
const configFiles = [
process.env.ICESHRIMP_CONFIG ? resolve(process.env.ICESHRIMP_CONFIG) : resolve(rootDir, ".config/default.yml"),
...process.env.ICESHRIMP_SECRETS ? [
resolve(process.env.ICESHRIMP_SECRETS)
] : []
];
for (const configFile of configFiles){
if (await pathExists(configFile)) {
fs.copyFileSync(configFile, resolve(configDir, configFile.split("/").pop()));
}
}
if (await pathExists(mediaDir) && !outDir.startsWith(mediaDir + "/") && outDir !== mediaDir) {
fs.cpSync(mediaDir, resolve(workDir, "files"), {
recursive: true,
dereference: false,
errorOnExist: false
});
includedMedia = true;
}
await writeFile(resolve(workDir, "manifest.json"), `${JSON.stringify({
type: "iceshrimp-full-backup",
version: config.version,
createdAt: new Date().toISOString(),
host: config.host,
database: config.db.db,
included: {
database: true,
config: true,
media: includedMedia
},
restore: "yarn full:restore <this archive>"
}, null, 2)}\n`, "utf8");
await archiveDirectory(workDir, outFile);
const outStat = await stat(outFile);
await insertModerationLog(me, "createBackup", {
path: outFile,
size: outStat.size,
includedMedia
});
return {
path: outFile,
fileName,
size: outStat.size,
includedMedia
};
} catch (e) {
await rm(outFile, {
force: true
}).catch(()=>{});
throw new ApiError(meta.errors.backupFailed, {
message: e instanceof Error ? e.message : String(e)
});
} finally{
cleanup();
}
});
@@ -0,0 +1,32 @@
import { Users } from "../../../../models/index.js";
import { deleteAccount } from "../../../../services/delete-account.js";
import define from "../../define.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireAdmin: true,
res: {}
};
export const paramDef = {
type: "object",
properties: {
userId: {
type: "string",
format: "misskey:id"
}
},
required: [
"userId"
]
};
export default define(meta, paramDef, async (ps)=>{
const user = await Users.findOneByOrFail({
id: ps.userId
});
if (user.isDeleted) {
return;
}
await deleteAccount(user);
});
@@ -0,0 +1,30 @@
import define from "../../define.js";
import { deleteFile } from "../../../../services/drive/delete-file.js";
import { DriveFiles } from "../../../../models/index.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
userId: {
type: "string",
format: "misskey:id"
}
},
required: [
"userId"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const files = await DriveFiles.findBy({
userId: ps.userId
});
for (const file of files){
deleteFile(file);
}
});
@@ -0,0 +1,48 @@
import define from "../../define.js";
import { Users } from "../../../../models/index.js";
import { insertModerationLog } from "../../../../services/insert-moderation-log.js";
import { publishInternalEvent } from "../../../../services/stream.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
userId: {
type: "string",
format: "misskey:id"
},
overrideMb: {
type: "number",
nullable: true
}
},
required: [
"userId",
"overrideMb"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const user = await Users.findOneBy({
id: ps.userId
});
if (user == null) {
throw new Error("user not found");
}
if (!Users.isLocalUser(user)) {
throw new Error("user is not local user");
}
await Users.update(user.id, {
driveCapacityOverrideMb: ps.overrideMb
});
publishInternalEvent("localUserUpdated", {
id: user.id
});
insertModerationLog(me, "change-drive-capacity-override", {
targetId: user.id
});
});
@@ -0,0 +1,17 @@
import define from "../../../define.js";
import { createCleanRemoteFilesJob } from "../../../../../queue/index.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {},
required: []
};
export default define(meta, paramDef, async (ps, me)=>{
createCleanRemoteFilesJob();
});
@@ -0,0 +1,24 @@
import { IsNull } from "typeorm";
import define from "../../../define.js";
import { deleteFile } from "../../../../../services/drive/delete-file.js";
import { DriveFiles } from "../../../../../models/index.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {},
required: []
};
export default define(meta, paramDef, async (ps, me)=>{
const files = await DriveFiles.findBy({
userId: IsNull()
});
for (const file of files){
deleteFile(file);
}
});
@@ -0,0 +1,102 @@
import { DriveFiles } from "../../../../../models/index.js";
import define from "../../../define.js";
import { makePaginationQuery } from "../../../common/make-pagination-query.js";
export const meta = {
tags: [
"admin"
],
requireCredential: false,
requireModerator: true,
res: {
type: "array",
optional: false,
nullable: false,
items: {
type: "object",
optional: false,
nullable: false,
ref: "DriveFile"
}
}
};
export const paramDef = {
type: "object",
properties: {
limit: {
type: "integer",
minimum: 1,
maximum: 100,
default: 10
},
sinceId: {
type: "string",
format: "misskey:id"
},
untilId: {
type: "string",
format: "misskey:id"
},
userId: {
type: "string",
format: "misskey:id",
nullable: true
},
type: {
type: "string",
nullable: true,
pattern: /^[a-zA-Z0-9\/\-*]+$/.toString().slice(1, -1)
},
origin: {
type: "string",
enum: [
"combined",
"local",
"remote"
],
default: "local"
},
hostname: {
type: "string",
nullable: true,
default: null,
description: "The local host is represented with `null`."
}
},
required: []
};
export default define(meta, paramDef, async (ps, me)=>{
const query = makePaginationQuery(DriveFiles.createQueryBuilder("file"), ps.sinceId, ps.untilId);
if (ps.userId) {
query.andWhere("file.userId = :userId", {
userId: ps.userId
});
} else {
if (ps.origin === "local") {
query.andWhere("file.userHost IS NULL");
} else if (ps.origin === "remote") {
query.andWhere("file.userHost IS NOT NULL");
}
if (ps.hostname) {
query.andWhere("file.userHost = :hostname", {
hostname: ps.hostname
});
}
}
if (ps.type) {
if (ps.type.endsWith("/*")) {
query.andWhere("file.type like :type", {
type: `${ps.type.replace("/*", "/")}%`
});
} else {
query.andWhere("file.type = :type", {
type: ps.type
});
}
}
const files = await query.take(ps.limit).getMany();
return await DriveFiles.packMany(files, {
detail: true,
withUser: true,
self: true
});
});
@@ -0,0 +1,227 @@
import { DriveFiles } from "../../../../../models/index.js";
import define from "../../../define.js";
import { ApiError } from "../../../error.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
errors: {
noSuchFile: {
message: "No such file.",
code: "NO_SUCH_FILE",
id: "caf3ca38-c6e5-472e-a30c-b05377dcc240"
}
},
res: {
type: "object",
optional: false,
nullable: false,
properties: {
id: {
type: "string",
optional: false,
nullable: false,
format: "id",
example: "xxxxxxxxxx"
},
createdAt: {
type: "string",
optional: false,
nullable: false,
format: "date-time"
},
userId: {
type: "string",
optional: false,
nullable: true,
format: "id",
example: "xxxxxxxxxx"
},
userHost: {
type: "string",
optional: false,
nullable: true,
description: "The local host is represented with `null`."
},
md5: {
type: "string",
optional: false,
nullable: false,
format: "md5",
example: "15eca7fba0480996e2245f5185bf39f2"
},
name: {
type: "string",
optional: false,
nullable: false,
example: "lenna.jpg"
},
type: {
type: "string",
optional: false,
nullable: false,
example: "image/jpeg"
},
size: {
type: "number",
optional: false,
nullable: false,
example: 51469
},
comment: {
type: "string",
optional: false,
nullable: true
},
blurhash: {
type: "string",
optional: false,
nullable: true
},
properties: {
type: "object",
optional: false,
nullable: false,
properties: {
width: {
type: "number",
optional: false,
nullable: false,
example: 1280
},
height: {
type: "number",
optional: false,
nullable: false,
example: 720
},
avgColor: {
type: "string",
optional: true,
nullable: false,
example: "rgb(40,65,87)"
}
}
},
storedInternal: {
type: "boolean",
optional: false,
nullable: true,
example: true
},
url: {
type: "string",
optional: false,
nullable: true,
format: "url"
},
thumbnailUrl: {
type: "string",
optional: false,
nullable: true,
format: "url"
},
webpublicUrl: {
type: "string",
optional: false,
nullable: true,
format: "url"
},
accessKey: {
type: "string",
optional: false,
nullable: false
},
thumbnailAccessKey: {
type: "string",
optional: false,
nullable: false
},
webpublicAccessKey: {
type: "string",
optional: false,
nullable: false
},
uri: {
type: "string",
optional: false,
nullable: true
},
src: {
type: "string",
optional: false,
nullable: true
},
folderId: {
type: "string",
optional: false,
nullable: true,
format: "id",
example: "xxxxxxxxxx"
},
isSensitive: {
type: "boolean",
optional: false,
nullable: false
},
isLink: {
type: "boolean",
optional: false,
nullable: false
}
}
}
};
export const paramDef = {
type: "object",
anyOf: [
{
properties: {
fileId: {
type: "string",
format: "misskey:id"
}
},
required: [
"fileId"
]
},
{
properties: {
url: {
type: "string"
}
},
required: [
"url"
]
}
]
};
export default define(meta, paramDef, async (ps, me)=>{
const file = ps.fileId ? await DriveFiles.findOneBy({
id: ps.fileId
}) : await DriveFiles.findOne({
where: [
{
url: ps.url
},
{
thumbnailUrl: ps.url
},
{
webpublicUrl: ps.url
}
]
});
if (file == null) {
throw new ApiError(meta.errors.noSuchFile);
}
if (!me.isAdmin) {
file.requestIp = undefined;
file.requestHeaders = undefined;
}
return file;
});
@@ -0,0 +1,49 @@
import define from "../../../define.js";
import { Emojis } from "../../../../../models/index.js";
import { In } from "typeorm";
import { db } from "../../../../../db/postgre.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
ids: {
type: "array",
items: {
type: "string",
format: "misskey:id"
}
},
aliases: {
type: "array",
items: {
type: "string"
}
}
},
required: [
"ids",
"aliases"
]
};
export default define(meta, paramDef, async (ps)=>{
const emojis = await Emojis.findBy({
id: In(ps.ids)
});
for (const emoji of emojis){
await Emojis.update(emoji.id, {
updatedAt: new Date(),
aliases: [
...new Set(emoji.aliases.concat(ps.aliases))
]
});
}
await db.queryResultCache.remove([
"meta_emojis"
]);
});
@@ -0,0 +1,70 @@
import define from "../../../define.js";
import { Emojis, DriveFiles } from "../../../../../models/index.js";
import { genId } from "../../../../../misc/gen-id.js";
import { insertModerationLog } from "../../../../../services/insert-moderation-log.js";
import { ApiError } from "../../../error.js";
import rndstr from "rndstr";
import { publishBroadcastStream } from "../../../../../services/stream.js";
import { db } from "../../../../../db/postgre.js";
import { getEmojiSize } from "../../../../../misc/emoji-meta.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
errors: {
noSuchFile: {
message: "No such file.",
code: "MO_SUCH_FILE",
id: "fc46b5a4-6b92-4c33-ac66-b806659bb5cf"
}
}
};
export const paramDef = {
type: "object",
properties: {
fileId: {
type: "string",
format: "misskey:id"
}
},
required: [
"fileId"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const file = await DriveFiles.findOneBy({
id: 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 size = await getEmojiSize(file.url);
const emoji = await Emojis.insert({
id: genId(),
updatedAt: new Date(),
name: name,
category: null,
host: null,
aliases: [],
originalUrl: file.url,
publicUrl: file.webpublicUrl ?? file.url,
type: file.webpublicType ?? file.type,
license: null,
glyph: file.type === "image/svg+xml",
width: size.width || null,
height: size.height || null
}).then((x)=>Emojis.findOneByOrFail(x.identifiers[0]));
await db.queryResultCache.remove([
"meta_emojis"
]);
publishBroadcastStream("emojiAdded", {
emoji: await Emojis.pack(emoji.id)
});
insertModerationLog(me, "addEmoji", {
emojiId: emoji.id
});
return {
id: emoji.id
};
});
@@ -0,0 +1,90 @@
import define from "../../../define.js";
import { Emojis } from "../../../../../models/index.js";
import { genId } from "../../../../../misc/gen-id.js";
import { ApiError } from "../../../error.js";
import { uploadFromUrl } from "../../../../../services/drive/upload-from-url.js";
import { publishBroadcastStream } from "../../../../../services/stream.js";
import { db } from "../../../../../db/postgre.js";
import { getEmojiSize } from "../../../../../misc/emoji-meta.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
errors: {
noSuchEmoji: {
message: "No such emoji.",
code: "NO_SUCH_EMOJI",
id: "e2785b66-dca3-4087-9cac-b93c541cc425"
}
},
res: {
type: "object",
optional: false,
nullable: false,
properties: {
id: {
type: "string",
optional: false,
nullable: false,
format: "id"
}
}
}
};
export const paramDef = {
type: "object",
properties: {
emojiId: {
type: "string",
format: "misskey:id"
}
},
required: [
"emojiId"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const emoji = await Emojis.findOneBy({
id: ps.emojiId
});
if (emoji == null) {
throw new ApiError(meta.errors.noSuchEmoji);
}
let driveFile;
try {
// Create file
driveFile = await uploadFromUrl({
url: emoji.originalUrl,
user: null,
force: true
});
} catch (e) {
throw new ApiError();
}
const size = await getEmojiSize(driveFile.url);
const copied = await Emojis.insert({
id: genId(),
updatedAt: new Date(),
name: emoji.name,
host: null,
aliases: [],
originalUrl: driveFile.url,
publicUrl: driveFile.webpublicUrl ?? driveFile.url,
type: driveFile.webpublicType ?? driveFile.type,
license: emoji.license,
glyph: emoji.glyph,
width: size.width || null,
height: size.height || null
}).then((x)=>Emojis.findOneByOrFail(x.identifiers[0]));
await db.queryResultCache.remove([
"meta_emojis"
]);
publishBroadcastStream("emojiAdded", {
emoji: await Emojis.pack(copied.id)
});
return {
id: copied.id
};
});
@@ -0,0 +1,41 @@
import define from "../../../define.js";
import { Emojis } from "../../../../../models/index.js";
import { In } from "typeorm";
import { insertModerationLog } from "../../../../../services/insert-moderation-log.js";
import { db } from "../../../../../db/postgre.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
ids: {
type: "array",
items: {
type: "string",
format: "misskey:id"
}
}
},
required: [
"ids"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const emojis = await Emojis.findBy({
id: In(ps.ids)
});
for (const emoji of emojis){
await Emojis.delete(emoji.id);
await db.queryResultCache.remove([
"meta_emojis"
]);
insertModerationLog(me, "deleteEmoji", {
emoji: emoji
});
}
});
@@ -0,0 +1,44 @@
import define from "../../../define.js";
import { Emojis } from "../../../../../models/index.js";
import { insertModerationLog } from "../../../../../services/insert-moderation-log.js";
import { ApiError } from "../../../error.js";
import { db } from "../../../../../db/postgre.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
errors: {
noSuchEmoji: {
message: "No such emoji.",
code: "NO_SUCH_EMOJI",
id: "be83669b-773a-44b7-b1f8-e5e5170ac3c2"
}
}
};
export const paramDef = {
type: "object",
properties: {
id: {
type: "string",
format: "misskey:id"
}
},
required: [
"id"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const emoji = await Emojis.findOneBy({
id: ps.id
});
if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji);
await Emojis.delete(emoji.id);
await db.queryResultCache.remove([
"meta_emojis"
]);
insertModerationLog(me, "deleteEmoji", {
emoji: emoji
});
});
@@ -0,0 +1,22 @@
import define from "../../../define.js";
import { createImportCustomEmojisJob } from "../../../../../queue/index.js";
export const meta = {
secure: true,
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
fileId: {
type: "string",
format: "misskey:id"
}
},
required: [
"fileId"
]
};
export default define(meta, paramDef, async (ps, user)=>{
createImportCustomEmojisJob(user, ps.fileId);
});
@@ -0,0 +1,134 @@
import define from "../../../define.js";
import { Emojis } from "../../../../../models/index.js";
import { toPuny } from "../../../../../misc/convert-host.js";
import { makePaginationQuery } from "../../../common/make-pagination-query.js";
import { sqlLikeEscape } from "../../../../../misc/sql-like-escape.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
res: {
type: "array",
optional: false,
nullable: false,
items: {
type: "object",
optional: false,
nullable: false,
properties: {
id: {
type: "string",
optional: false,
nullable: false,
format: "id"
},
aliases: {
type: "array",
optional: false,
nullable: false,
items: {
type: "string",
optional: false,
nullable: false
}
},
name: {
type: "string",
optional: false,
nullable: false
},
category: {
type: "string",
optional: false,
nullable: true
},
host: {
type: "string",
optional: false,
nullable: true,
description: "The local host is represented with `null`."
},
url: {
type: "string",
optional: false,
nullable: false
},
license: {
type: "string",
optional: false,
nullable: true
},
glyph: {
type: "boolean",
optional: false,
nullable: false
},
glyphUrl: {
type: "string",
optional: false,
nullable: true
},
width: {
type: "number",
optional: false,
nullable: true
},
height: {
type: "number",
optional: false,
nullable: true
}
}
}
}
};
export const paramDef = {
type: "object",
properties: {
query: {
type: "string",
nullable: true,
default: null
},
host: {
type: "string",
nullable: true,
default: null,
description: "Use `null` to represent the local host."
},
limit: {
type: "integer",
minimum: 1,
maximum: 100,
default: 10
},
sinceId: {
type: "string",
format: "misskey:id"
},
untilId: {
type: "string",
format: "misskey:id"
}
},
required: []
};
export default define(meta, paramDef, 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: `%${sqlLikeEscape(ps.query)}%`
});
}
const emojis = await q.orderBy("emoji.id", "DESC").take(ps.limit).getMany();
return Emojis.packMany(emojis);
});
@@ -0,0 +1,123 @@
import define from "../../../define.js";
import { Emojis } from "../../../../../models/index.js";
import { makePaginationQuery } from "../../../common/make-pagination-query.js";
//import { sqlLikeEscape } from "@/misc/sql-like-escape.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
res: {
type: "array",
optional: false,
nullable: false,
items: {
type: "object",
optional: false,
nullable: false,
properties: {
id: {
type: "string",
optional: false,
nullable: false,
format: "id"
},
aliases: {
type: "array",
optional: false,
nullable: false,
items: {
type: "string",
optional: false,
nullable: false
}
},
name: {
type: "string",
optional: false,
nullable: false
},
category: {
type: "string",
optional: false,
nullable: true
},
host: {
type: "null",
optional: false,
description: "The local host is represented with `null`. The field exists for compatibility with other API endpoints that return files."
},
url: {
type: "string",
optional: false,
nullable: false
},
license: {
type: "string",
optional: false,
nullable: true
},
glyph: {
type: "boolean",
optional: false,
nullable: false
},
glyphUrl: {
type: "string",
optional: false,
nullable: true
},
width: {
type: "number",
optional: false,
nullable: true
},
height: {
type: "number",
optional: false,
nullable: true
}
}
}
}
};
export const paramDef = {
type: "object",
properties: {
query: {
type: "string",
nullable: true,
default: null
},
limit: {
type: "integer",
minimum: 1,
maximum: 100,
default: 10
},
sinceId: {
type: "string",
format: "misskey:id"
},
untilId: {
type: "string",
format: "misskey:id"
}
},
required: []
};
export default define(meta, paramDef, async (ps)=>{
const q = makePaginationQuery(Emojis.createQueryBuilder("emoji"), ps.sinceId, ps.untilId).andWhere("emoji.host IS NULL");
let emojis;
if (ps.query) {
//q.andWhere('emoji.name ILIKE :q', { q: `%${sqlLikeEscape(ps.query)}%` });
//const emojis = await q.take(ps.limit).getMany();
emojis = await q.getMany();
emojis = emojis.filter((emoji)=>emoji.name.includes(ps.query) || emoji.aliases.some((a)=>a.includes(ps.query)) || emoji.category?.includes(ps.query));
emojis.splice(ps.limit + 1);
} else {
emojis = await q.take(ps.limit).getMany();
}
return Emojis.packMany(emojis);
});
@@ -0,0 +1,47 @@
import define from "../../../define.js";
import { Emojis } from "../../../../../models/index.js";
import { In } from "typeorm";
import { db } from "../../../../../db/postgre.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
ids: {
type: "array",
items: {
type: "string",
format: "misskey:id"
}
},
aliases: {
type: "array",
items: {
type: "string"
}
}
},
required: [
"ids",
"aliases"
]
};
export default define(meta, paramDef, async (ps)=>{
const emojis = await Emojis.findBy({
id: In(ps.ids)
});
for (const emoji of emojis){
await Emojis.update(emoji.id, {
updatedAt: new Date(),
aliases: emoji.aliases.filter((x)=>!ps.aliases.includes(x))
});
}
await db.queryResultCache.remove([
"meta_emojis"
]);
});
@@ -0,0 +1,44 @@
import define from "../../../define.js";
import { Emojis } from "../../../../../models/index.js";
import { In } from "typeorm";
import { db } from "../../../../../db/postgre.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
ids: {
type: "array",
items: {
type: "string",
format: "misskey:id"
}
},
aliases: {
type: "array",
items: {
type: "string"
}
}
},
required: [
"ids",
"aliases"
]
};
export default define(meta, paramDef, async (ps)=>{
await Emojis.update({
id: In(ps.ids)
}, {
updatedAt: new Date(),
aliases: ps.aliases
});
await db.queryResultCache.remove([
"meta_emojis"
]);
});
@@ -0,0 +1,42 @@
import define from "../../../define.js";
import { Emojis } from "../../../../../models/index.js";
import { In } from "typeorm";
import { db } from "../../../../../db/postgre.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
ids: {
type: "array",
items: {
type: "string",
format: "misskey:id"
}
},
category: {
type: "string",
nullable: true,
description: "Use `null` to reset the category."
}
},
required: [
"ids"
]
};
export default define(meta, paramDef, async (ps)=>{
await Emojis.update({
id: In(ps.ids)
}, {
updatedAt: new Date(),
category: ps.category
});
await db.queryResultCache.remove([
"meta_emojis"
]);
});
@@ -0,0 +1,42 @@
import define from "../../../define.js";
import { Emojis } from "../../../../../models/index.js";
import { In } from "typeorm";
import { db } from "../../../../../db/postgre.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
ids: {
type: "array",
items: {
type: "string",
format: "misskey:id"
}
},
license: {
type: "string",
nullable: true,
description: "Use `null` to reset the license."
}
},
required: [
"ids"
]
};
export default define(meta, paramDef, async (ps)=>{
await Emojis.update({
id: In(ps.ids)
}, {
updatedAt: new Date(),
license: ps.license
});
await db.queryResultCache.remove([
"meta_emojis"
]);
});
@@ -0,0 +1,72 @@
import define from "../../../define.js";
import { Emojis } from "../../../../../models/index.js";
import { ApiError } from "../../../error.js";
import { db } from "../../../../../db/postgre.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
errors: {
noSuchEmoji: {
message: "No such emoji.",
code: "NO_SUCH_EMOJI",
id: "684dec9d-a8c2-4364-9aa8-456c49cb1dc8"
}
}
};
export const paramDef = {
type: "object",
properties: {
id: {
type: "string",
format: "misskey:id"
},
name: {
type: "string"
},
category: {
type: "string",
nullable: true,
description: "Use `null` to reset the category."
},
aliases: {
type: "array",
items: {
type: "string"
}
},
license: {
type: "string",
nullable: true
},
glyph: {
type: "boolean"
}
},
required: [
"id",
"name",
"aliases"
]
};
export default define(meta, paramDef, async (ps)=>{
const emoji = await Emojis.findOneBy({
id: 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,
license: ps.license,
...typeof ps.glyph === "boolean" ? {
glyph: ps.glyph
} : {}
});
await db.queryResultCache.remove([
"meta_emojis"
]);
});
@@ -0,0 +1,29 @@
import define from "../../../define.js";
import { deleteFile } from "../../../../../services/drive/delete-file.js";
import { DriveFiles } from "../../../../../models/index.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
host: {
type: "string"
}
},
required: [
"host"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const files = await DriveFiles.findBy({
userHost: ps.host
});
for (const file of files){
deleteFile(file);
}
});
@@ -0,0 +1,31 @@
import define from "../../../define.js";
import { Instances } from "../../../../../models/index.js";
import { toPuny } from "../../../../../misc/convert-host.js";
import { fetchInstanceMetadata } from "../../../../../services/fetch-instance-metadata.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
host: {
type: "string"
}
},
required: [
"host"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const instance = await Instances.findOneBy({
host: toPuny(ps.host)
});
if (instance == null) {
throw new Error("instance not found");
}
fetchInstanceMetadata(instance, true);
});
@@ -0,0 +1,37 @@
import define from "../../../define.js";
import deleteFollowing from "../../../../../services/following/delete.js";
import { Followings, Users } from "../../../../../models/index.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
host: {
type: "string"
}
},
required: [
"host"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const followings = await Followings.findBy({
followerHost: ps.host
});
const pairs = await Promise.all(followings.map((f)=>Promise.all([
Users.findOneByOrFail({
id: f.followerId
}),
Users.findOneByOrFail({
id: f.followeeId
})
])));
for (const pair of pairs){
deleteFollowing(pair[0], pair[1]);
}
});
@@ -0,0 +1,38 @@
import define from "../../../define.js";
import { Instances } from "../../../../../models/index.js";
import { toPuny } from "../../../../../misc/convert-host.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
host: {
type: "string"
},
isSuspended: {
type: "boolean"
}
},
required: [
"host",
"isSuspended"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const instance = await Instances.findOneBy({
host: toPuny(ps.host)
});
if (instance == null) {
throw new Error("instance not found");
}
Instances.update({
host: toPuny(ps.host)
}, {
isSuspended: ps.isSuspended
});
});
@@ -0,0 +1,24 @@
import define from "../../define.js";
import { db } from "../../../../db/postgre.js";
export const meta = {
requireCredential: true,
requireModerator: true,
tags: [
"admin"
]
};
export const paramDef = {
type: "object",
properties: {},
required: []
};
export default define(meta, paramDef, async ()=>{
const stats = await db.query("SELECT * FROM pg_indexes;").then((recs)=>{
const res = [];
for (const rec of recs){
res.push(rec);
}
return res;
});
return stats;
});
@@ -0,0 +1,43 @@
import { db } from "../../../../db/postgre.js";
import define from "../../define.js";
export const meta = {
requireCredential: true,
requireModerator: true,
tags: [
"admin"
],
res: {
type: "object",
optional: false,
nullable: false,
example: {
migrations: {
count: 66,
size: 32768
}
}
}
};
export const paramDef = {
type: "object",
properties: {},
required: []
};
export default define(meta, paramDef, async ()=>{
const sizes = await db.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 = {};
for (const rec of recs){
res[rec.table] = {
count: parseInt(rec.count, 10),
size: parseInt(rec.size, 10)
};
}
return res;
});
return sizes;
});
@@ -0,0 +1,36 @@
import { UserIps } from "../../../../models/index.js";
import define from "../../define.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireAdmin: true
};
export const paramDef = {
type: "object",
properties: {
userId: {
type: "string",
format: "misskey:id"
}
},
required: [
"userId"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const ips = await UserIps.find({
where: {
userId: ps.userId
},
order: {
createdAt: "DESC"
},
take: 30
});
return ips.map((x)=>({
ip: x.ip,
createdAt: x.createdAt.toISOString()
}));
});
@@ -0,0 +1,45 @@
import rndstr from "rndstr";
import define from "../../define.js";
import { RegistrationTickets } from "../../../../models/index.js";
import { genId } from "../../../../misc/gen-id.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
res: {
type: "object",
optional: false,
nullable: false,
properties: {
code: {
type: "string",
optional: false,
nullable: false,
example: "2ERUA5VR",
maxLength: 8,
minLength: 8
}
}
}
};
export const paramDef = {
type: "object",
properties: {},
required: []
};
export default define(meta, paramDef, async ()=>{
const code = rndstr({
length: 8,
chars: "2-9A-HJ-NP-Z"
});
await RegistrationTickets.insert({
id: genId(),
createdAt: new Date(),
code
});
return {
code
};
});
@@ -0,0 +1,503 @@
import config from "../../../../config/index.js";
import { fetchMeta } from "../../../../misc/fetch-meta.js";
import { MAX_NOTE_TEXT_LENGTH, MAX_CAPTION_TEXT_LENGTH } from "../../../../const.js";
import define from "../../define.js";
export const meta = {
tags: [
"meta"
],
requireCredential: true,
requireAdmin: true,
res: {
type: "object",
optional: false,
nullable: false,
properties: {
driveCapacityPerLocalUserMb: {
type: "number",
optional: false,
nullable: false
},
driveCapacityPerRemoteUserMb: {
type: "number",
optional: false,
nullable: false
},
lua4frozenDatabaseCapacityMb: {
type: "number",
optional: false,
nullable: false
},
cacheRemoteFiles: {
type: "boolean",
optional: false,
nullable: false
},
emailRequiredForSignup: {
type: "boolean",
optional: false,
nullable: false
},
enableHcaptcha: {
type: "boolean",
optional: false,
nullable: false
},
hcaptchaSiteKey: {
type: "string",
optional: false,
nullable: true
},
enableRecaptcha: {
type: "boolean",
optional: false,
nullable: false
},
recaptchaSiteKey: {
type: "string",
optional: false,
nullable: true
},
swPublickey: {
type: "string",
optional: false,
nullable: true
},
mascotImageUrl: {
type: "string",
optional: false,
nullable: false,
default: "/twemoji/1f440.svg"
},
bannerUrl: {
type: "string",
optional: false,
nullable: false
},
errorImageUrl: {
type: "string",
optional: false,
nullable: false,
default: "/twemoji/1f480.svg"
},
iconUrl: {
type: "string",
optional: false,
nullable: true
},
maxNoteTextLength: {
type: "number",
optional: false,
nullable: false
},
maxCaptionTextLength: {
type: "number",
optional: false,
nullable: false
},
emojis: {
type: "array",
optional: false,
nullable: false,
items: {
type: "object",
optional: false,
nullable: false,
properties: {
id: {
type: "string",
optional: false,
nullable: false,
format: "id"
},
aliases: {
type: "array",
optional: false,
nullable: false,
items: {
type: "string",
optional: false,
nullable: false
}
},
category: {
type: "string",
optional: false,
nullable: true
},
host: {
type: "string",
optional: false,
nullable: true
},
url: {
type: "string",
optional: false,
nullable: false,
format: "url"
}
}
}
},
enableEmail: {
type: "boolean",
optional: false,
nullable: false
},
enableGithubIntegration: {
type: "boolean",
optional: false,
nullable: false
},
enableDiscordIntegration: {
type: "boolean",
optional: false,
nullable: false
},
translatorAvailable: {
type: "boolean",
optional: false,
nullable: false
},
recommendedInstances: {
type: "array",
optional: true,
nullable: false,
items: {
type: "string",
optional: false,
nullable: false
}
},
pinnedUsers: {
type: "array",
optional: true,
nullable: false,
items: {
type: "string",
optional: false,
nullable: false
}
},
customMOTD: {
type: "array",
optional: true,
nullable: false,
items: {
type: "string",
optional: false,
nullable: false
}
},
customSplashIcons: {
type: "array",
optional: true,
nullable: false,
items: {
type: "string",
optional: false,
nullable: false
}
},
hiddenTags: {
type: "array",
optional: true,
nullable: false,
items: {
type: "string",
optional: false,
nullable: false
}
},
blockedHosts: {
type: "array",
optional: true,
nullable: false,
items: {
type: "string",
optional: false,
nullable: false
}
},
silencedHosts: {
type: "array",
optional: true,
nullable: false,
items: {
type: "string",
optional: false,
nullable: false
}
},
allowedHosts: {
type: "array",
optional: true,
nullable: false,
items: {
type: "string",
optional: false,
nullable: false
}
},
privateMode: {
type: "boolean",
optional: false,
nullable: false
},
secureMode: {
type: "boolean",
optional: false,
nullable: false
},
hcaptchaSecretKey: {
type: "string",
optional: true,
nullable: true
},
recaptchaSecretKey: {
type: "string",
optional: true,
nullable: true
},
summaryProxy: {
type: "string",
optional: true,
nullable: true
},
email: {
type: "string",
optional: true,
nullable: true
},
smtpSecure: {
type: "boolean",
optional: true,
nullable: false
},
smtpHost: {
type: "string",
optional: true,
nullable: true
},
smtpPort: {
type: "string",
optional: true,
nullable: true
},
smtpUser: {
type: "string",
optional: true,
nullable: true
},
smtpPass: {
type: "string",
optional: true,
nullable: true
},
swPrivateKey: {
type: "string",
optional: true,
nullable: true
},
useObjectStorage: {
type: "boolean",
optional: true,
nullable: false
},
objectStorageBaseUrl: {
type: "string",
optional: true,
nullable: true
},
objectStorageBucket: {
type: "string",
optional: true,
nullable: true
},
objectStoragePrefix: {
type: "string",
optional: true,
nullable: true
},
objectStorageEndpoint: {
type: "string",
optional: true,
nullable: true
},
objectStorageRegion: {
type: "string",
optional: true,
nullable: true
},
objectStoragePort: {
type: "number",
optional: true,
nullable: true
},
objectStorageAccessKey: {
type: "string",
optional: true,
nullable: true
},
objectStorageSecretKey: {
type: "string",
optional: true,
nullable: true
},
objectStorageUseSSL: {
type: "boolean",
optional: true,
nullable: false
},
objectStorageUseProxy: {
type: "boolean",
optional: true,
nullable: false
},
objectStorageSetPublicRead: {
type: "boolean",
optional: true,
nullable: false
},
enableIpLogging: {
type: "boolean",
optional: true,
nullable: false
},
enableActiveEmailValidation: {
type: "boolean",
optional: true,
nullable: false
},
defaultReaction: {
type: "string",
optional: false,
nullable: false
},
experimentalFeatures: {
type: "object",
optional: true,
nullable: true,
properties: {
postImports: {
type: "boolean"
}
}
},
enableServerMachineStats: {
type: "boolean",
optional: false,
nullable: false
},
enableIdenticonGeneration: {
type: "boolean",
optional: false,
nullable: false
},
donationLink: {
type: "string",
optional: true,
nullable: true
},
autofollowedAccount: {
type: "string",
optional: true,
nullable: true
}
}
}
};
export const paramDef = {
type: "object",
properties: {},
required: []
};
export default define(meta, paramDef, async (ps, me)=>{
const instance = await fetchMeta(true);
return {
maintainerName: instance.maintainerName,
maintainerEmail: instance.maintainerEmail,
version: config.version,
name: instance.name,
uri: config.url,
description: instance.description,
langs: instance.langs,
tosUrl: instance.ToSUrl,
repositoryUrl: instance.repositoryUrl,
feedbackUrl: instance.feedbackUrl,
disableRegistration: instance.disableRegistration,
disableLocalTimeline: instance.disableLocalTimeline,
disableRecommendedTimeline: instance.disableRecommendedTimeline,
disableGlobalTimeline: instance.disableGlobalTimeline,
driveCapacityPerLocalUserMb: instance.localDriveCapacityMb,
driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb,
lua4frozenDatabaseCapacityMb: instance.lua4frozenDatabaseCapacityMb,
emailRequiredForSignup: instance.emailRequiredForSignup,
enableHcaptcha: instance.enableHcaptcha,
hcaptchaSiteKey: instance.hcaptchaSiteKey,
enableRecaptcha: instance.enableRecaptcha,
recaptchaSiteKey: instance.recaptchaSiteKey,
swPublickey: instance.swPublicKey,
themeColor: instance.themeColor,
mascotImageUrl: instance.mascotImageUrl,
bannerUrl: instance.bannerUrl,
errorImageUrl: instance.errorImageUrl,
iconUrl: instance.iconUrl,
backgroundImageUrl: instance.backgroundImageUrl,
logoImageUrl: instance.logoImageUrl,
maxNoteTextLength: MAX_NOTE_TEXT_LENGTH,
maxCaptionTextLength: MAX_CAPTION_TEXT_LENGTH,
defaultLightTheme: instance.defaultLightTheme,
defaultDarkTheme: instance.defaultDarkTheme,
enableEmail: instance.enableEmail,
translatorAvailable: instance.deeplAuthKey != null || instance.libreTranslateApiUrl != null,
pinnedPages: instance.pinnedPages,
pinnedClipId: instance.pinnedClipId,
cacheRemoteFiles: instance.cacheRemoteFiles,
defaultReaction: instance.defaultReaction,
recommendedInstances: instance.recommendedInstances,
pinnedUsers: instance.pinnedUsers,
customMOTD: instance.customMOTD,
customSplashIcons: instance.customSplashIcons,
hiddenTags: instance.hiddenTags,
blockedHosts: instance.blockedHosts,
silencedHosts: instance.silencedHosts,
allowedHosts: instance.allowedHosts,
privateMode: instance.privateMode,
secureMode: instance.secureMode,
hcaptchaSecretKey: instance.hcaptchaSecretKey,
recaptchaSecretKey: instance.recaptchaSecretKey,
summalyProxy: instance.summalyProxy,
email: instance.email,
smtpSecure: instance.smtpSecure,
smtpHost: instance.smtpHost,
smtpPort: instance.smtpPort,
smtpUser: instance.smtpUser,
smtpPass: instance.smtpPass,
swPrivateKey: instance.swPrivateKey,
useObjectStorage: instance.useObjectStorage,
objectStorageBaseUrl: instance.objectStorageBaseUrl,
objectStorageBucket: instance.objectStorageBucket,
objectStoragePrefix: instance.objectStoragePrefix,
objectStorageEndpoint: instance.objectStorageEndpoint,
objectStorageRegion: instance.objectStorageRegion,
objectStoragePort: instance.objectStoragePort,
objectStorageAccessKey: instance.objectStorageAccessKey,
objectStorageSecretKey: instance.objectStorageSecretKey,
objectStorageUseSSL: instance.objectStorageUseSSL,
objectStorageUseProxy: instance.objectStorageUseProxy,
objectStorageSetPublicRead: instance.objectStorageSetPublicRead,
objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle,
deeplAuthKey: instance.deeplAuthKey,
deeplIsPro: instance.deeplIsPro,
libreTranslateApiUrl: instance.libreTranslateApiUrl,
libreTranslateApiKey: instance.libreTranslateApiKey,
enableIpLogging: instance.enableIpLogging,
enableActiveEmailValidation: instance.enableActiveEmailValidation,
experimentalFeatures: instance.experimentalFeatures,
enableServerMachineStats: instance.enableServerMachineStats,
enableIdenticonGeneration: instance.enableIdenticonGeneration,
donationLink: instance.donationLink,
autofollowedAccount: instance.autofollowedAccount
};
});
@@ -0,0 +1,40 @@
import define from "../../../define.js";
import { Users } from "../../../../../models/index.js";
import { publishInternalEvent } from "../../../../../services/stream.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireAdmin: true
};
export const paramDef = {
type: "object",
properties: {
userId: {
type: "string",
format: "misskey:id"
}
},
required: [
"userId"
]
};
export default define(meta, paramDef, async (ps)=>{
const user = await Users.findOneBy({
id: ps.userId
});
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
});
publishInternalEvent("userChangeModeratorState", {
id: user.id,
isModerator: true
});
});
@@ -0,0 +1,37 @@
import define from "../../../define.js";
import { Users } from "../../../../../models/index.js";
import { publishInternalEvent } from "../../../../../services/stream.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireAdmin: true
};
export const paramDef = {
type: "object",
properties: {
userId: {
type: "string",
format: "misskey:id"
}
},
required: [
"userId"
]
};
export default define(meta, paramDef, async (ps)=>{
const user = await Users.findOneBy({
id: ps.userId
});
if (user == null) {
throw new Error("user not found");
}
await Users.update(user.id, {
isModerator: false
});
publishInternalEvent("userChangeModeratorState", {
id: user.id,
isModerator: false
});
});
@@ -0,0 +1,59 @@
import define from "../../../define.js";
import { Plans } from "../../../../../models/index.js";
import { genId } from "../../../../../misc/gen-id.js";
import { insertModerationLog } from "../../../../../services/insert-moderation-log.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireAdmin: true
};
export const paramDef = {
type: "object",
properties: {
name: {
type: "string",
minLength: 1,
maxLength: 128
},
icon: {
type: "string",
minLength: 1,
maxLength: 64
},
description: {
type: "string",
maxLength: 512,
default: ""
}
},
required: [
"name",
"icon"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const name = ps.name.trim();
const icon = ps.icon.trim();
const description = ps.description.trim();
if (name === "") throw new Error("name is empty");
if (icon === "") throw new Error("icon is empty");
const exists = await Plans.findOneBy({
name
});
if (exists) throw new Error("plan name already exists");
const plan = await Plans.insert({
id: genId(),
createdAt: new Date(),
updatedAt: null,
name,
icon,
description
}).then((x)=>Plans.findOneByOrFail(x.identifiers[0]));
insertModerationLog(me, "createPlan", {
planId: plan.id,
name
});
return await Plans.pack(plan);
});
@@ -0,0 +1,32 @@
import define from "../../../define.js";
import { Plans } from "../../../../../models/index.js";
import { insertModerationLog } from "../../../../../services/insert-moderation-log.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireAdmin: true
};
export const paramDef = {
type: "object",
properties: {
planId: {
type: "string",
format: "misskey:id"
}
},
required: [
"planId"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const plan = await Plans.findOneByOrFail({
id: ps.planId
});
await Plans.delete(plan.id);
insertModerationLog(me, "deletePlan", {
planId: plan.id,
name: plan.name
});
});
@@ -0,0 +1,22 @@
import define from "../../../define.js";
import { Plans } from "../../../../../models/index.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireAdmin: true
};
export const paramDef = {
type: "object",
properties: {},
required: []
};
export default define(meta, paramDef, async ()=>{
const plans = await Plans.find({
order: {
createdAt: "ASC"
}
});
return await Plans.packMany(plans);
});
@@ -0,0 +1,64 @@
import define from "../../../define.js";
import { Plans } from "../../../../../models/index.js";
import { insertModerationLog } from "../../../../../services/insert-moderation-log.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireAdmin: true
};
export const paramDef = {
type: "object",
properties: {
planId: {
type: "string",
format: "misskey:id"
},
name: {
type: "string",
minLength: 1,
maxLength: 128
},
icon: {
type: "string",
minLength: 1,
maxLength: 64
},
description: {
type: "string",
maxLength: 512,
default: ""
}
},
required: [
"planId",
"name",
"icon"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const plan = await Plans.findOneByOrFail({
id: ps.planId
});
const name = ps.name.trim();
const icon = ps.icon.trim();
const description = ps.description.trim();
if (name === "") throw new Error("name is empty");
if (icon === "") throw new Error("icon is empty");
const exists = await Plans.findOneBy({
name
});
if (exists && exists.id !== plan.id) throw new Error("plan name already exists");
await Plans.update(plan.id, {
updatedAt: new Date(),
name,
icon,
description
});
insertModerationLog(me, "updatePlan", {
planId: plan.id,
name
});
return await Plans.pack(plan.id);
});
@@ -0,0 +1,79 @@
import define from "../../../define.js";
import { ApiError } from "../../../error.js";
import { Notes, PromoNotes } from "../../../../../models/index.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
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"
},
notAdService: {
message: "The note must have #AdService.",
code: "NOT_AD_SERVICE",
id: "970a4d57-7b67-4540-80ea-f210041724ef"
}
}
};
export const paramDef = {
type: "object",
properties: {
noteId: {
type: "string",
format: "misskey:id"
},
expiresAt: {
type: "integer"
},
credits: {
type: "integer",
minimum: 1,
maximum: 100000,
default: 1
}
},
required: [
"noteId"
]
};
export default define(meta, paramDef, async (ps, user)=>{
const note = await Notes.findOneBy({
id: ps.noteId
});
if (note == null) {
throw new ApiError(meta.errors.noSuchNote);
}
if (!note.tags.includes("adservice")) {
throw new ApiError(meta.errors.notAdService);
}
const expiresAt = ps.expiresAt ? new Date(ps.expiresAt) : new Date(Date.now() + 30 * 86400000);
const credits = ps.credits ?? 1;
const exist = await PromoNotes.findOneBy({
noteId: note.id
});
if (exist) {
await PromoNotes.update(note.id, {
expiresAt: exist.expiresAt.getTime() > expiresAt.getTime() ? exist.expiresAt : expiresAt,
totalCredits: exist.totalCredits + credits,
remainingCredits: exist.remainingCredits + credits
});
return;
}
await PromoNotes.insert({
noteId: note.id,
expiresAt,
totalCredits: credits,
remainingCredits: credits,
userId: note.userId
});
});
@@ -0,0 +1,52 @@
import rndstr from "rndstr";
import { Notes, PromoNotes } from "../../../../../models/index.js";
import define from "../../../define.js";
import { makePaginationQuery } from "../../../common/make-pagination-query.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
limit: {
type: "integer",
minimum: 1,
maximum: 100,
default: 10
},
sinceId: {
type: "string",
format: "misskey:id"
},
untilId: {
type: "string",
format: "misskey:id"
}
},
required: []
};
export default define(meta, paramDef, async (ps, user)=>{
const query = makePaginationQuery(Notes.createQueryBuilder("note"), ps.sinceId, ps.untilId).andWhere(`'adservice' = ANY(note.tags)`).innerJoinAndSelect("note.user", "user").leftJoinAndSelect("note.reply", "reply").leftJoinAndSelect("note.renote", "renote").leftJoinAndSelect("reply.user", "replyUser").leftJoinAndSelect("renote.user", "renoteUser");
const notes = await query.take(ps.limit).getMany();
if (notes.length === 0) return [];
const promos = await PromoNotes.findBy(notes.map((note)=>({
noteId: note.id
})));
return Promise.all(notes.map(async (note)=>{
const promo = promos.find((item)=>item.noteId === note.id);
const expiredCredits = promo && promo.expiresAt.getTime() <= Date.now() ? promo.remainingCredits : 0;
note._prId_ = rndstr("a-z0-9", 8);
return {
id: note.id,
note: await Notes.pack(note, user),
expiresAt: promo?.expiresAt.toISOString() ?? null,
totalCredits: promo?.totalCredits ?? 0,
remainingCredits: promo?.remainingCredits ?? 0,
expiredCredits
};
}));
});
@@ -0,0 +1,19 @@
import define from "../../../define.js";
import { destroy } from "../../../../../queue/index.js";
import { insertModerationLog } from "../../../../../services/insert-moderation-log.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {},
required: []
};
export default define(meta, paramDef, async (ps, me)=>{
destroy();
insertModerationLog(me, "clearQueue");
});
@@ -0,0 +1,60 @@
import { deliverQueue } from "../../../../../queue/queues.js";
import { URL } from "node:url";
import define from "../../../define.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
res: {
type: "array",
optional: false,
nullable: false,
items: {
type: "array",
optional: false,
nullable: false,
items: {
anyOf: [
{
type: "string"
},
{
type: "number"
}
]
}
},
example: [
[
"example.com",
12
]
]
}
};
export const paramDef = {
type: "object",
properties: {},
required: []
};
export default define(meta, paramDef, async (ps)=>{
const jobs = await deliverQueue.getJobs([
"delayed"
]);
const res = [];
for (const job of jobs){
const host = new URL(job.data.to).host;
if (res.find((x)=>x[0] === host)) {
res.find((x)=>x[0] === host)[1]++;
} else {
res.push([
host,
1
]);
}
}
res.sort((a, b)=>b[1] - a[1]);
return res;
});
@@ -0,0 +1,60 @@
import { URL } from "node:url";
import define from "../../../define.js";
import { inboxQueue } from "../../../../../queue/queues.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
res: {
type: "array",
optional: false,
nullable: false,
items: {
type: "array",
optional: false,
nullable: false,
items: {
anyOf: [
{
type: "string"
},
{
type: "number"
}
]
}
},
example: [
[
"example.com",
12
]
]
}
};
export const paramDef = {
type: "object",
properties: {},
required: []
};
export default define(meta, paramDef, async (ps)=>{
const jobs = await inboxQueue.getJobs([
"delayed"
]);
const res = [];
for (const job of jobs){
const host = new URL(job.data.signature.keyId).host;
if (res.find((x)=>x[0] === host)) {
res.find((x)=>x[0] === host)[1]++;
} else {
res.push([
host,
1
]);
}
}
res.sort((a, b)=>b[1] - a[1]);
return res;
});
@@ -0,0 +1,53 @@
import { deliverQueue, inboxQueue, dbQueue, objectStorageQueue } from "../../../../../queue/queues.js";
import define from "../../../define.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
res: {
type: "object",
optional: false,
nullable: false,
properties: {
deliver: {
optional: false,
nullable: false,
ref: "QueueCount"
},
inbox: {
optional: false,
nullable: false,
ref: "QueueCount"
},
db: {
optional: false,
nullable: false,
ref: "QueueCount"
},
objectStorage: {
optional: false,
nullable: false,
ref: "QueueCount"
}
}
}
};
export const paramDef = {
type: "object",
properties: {},
required: []
};
export default define(meta, paramDef, async (ps)=>{
const deliverJobCounts = await deliverQueue.getJobCounts();
const inboxJobCounts = await inboxQueue.getJobCounts();
const dbJobCounts = await dbQueue.getJobCounts();
const objectStorageJobCounts = await objectStorageQueue.getJobCounts();
return {
deliver: deliverJobCounts,
inbox: inboxJobCounts,
db: dbJobCounts,
objectStorage: objectStorageJobCounts
};
});
@@ -0,0 +1,68 @@
import { URL } from "node:url";
import define from "../../../define.js";
import { addRelay } from "../../../../../services/relay.js";
import { ApiError } from "../../../error.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
errors: {
invalidUrl: {
message: "Invalid URL",
code: "INVALID_URL",
id: "fb8c92d3-d4e5-44e7-b3d4-800d5cef8b2c"
}
},
res: {
type: "object",
optional: false,
nullable: false,
properties: {
id: {
type: "string",
optional: false,
nullable: false,
format: "id"
},
inbox: {
description: "URL of the inbox, must be a https scheme URL",
type: "string",
optional: false,
nullable: false,
format: "url"
},
status: {
type: "string",
optional: false,
nullable: false,
default: "requesting",
enum: [
"requesting",
"accepted",
"rejected"
]
}
}
}
};
export const paramDef = {
type: "object",
properties: {
inbox: {
type: "string"
}
},
required: [
"inbox"
]
};
export default define(meta, paramDef, async (ps, user)=>{
try {
if (new URL(ps.inbox).protocol !== "https:") throw new Error("https only");
} catch {
throw new ApiError(meta.errors.invalidUrl);
}
return await addRelay(ps.inbox);
});
@@ -0,0 +1,52 @@
import define from "../../../define.js";
import { listRelay } from "../../../../../services/relay.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
res: {
type: "array",
optional: false,
nullable: false,
items: {
type: "object",
optional: false,
nullable: false,
properties: {
id: {
type: "string",
optional: false,
nullable: false,
format: "id"
},
inbox: {
type: "string",
optional: false,
nullable: false,
format: "url"
},
status: {
type: "string",
optional: false,
nullable: false,
default: "requesting",
enum: [
"requesting",
"accepted",
"rejected"
]
}
}
}
}
};
export const paramDef = {
type: "object",
properties: {},
required: []
};
export default define(meta, paramDef, async (ps, user)=>{
return await listRelay();
});
@@ -0,0 +1,23 @@
import define from "../../../define.js";
import { removeRelay } from "../../../../../services/relay.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
inbox: {
type: "string"
}
},
required: [
"inbox"
]
};
export default define(meta, paramDef, async (ps, user)=>{
return await removeRelay(ps.inbox);
});
@@ -0,0 +1,61 @@
import define from "../../define.js";
// import bcrypt from "bcryptjs";
import rndstr from "rndstr";
import { Users, UserProfiles } from "../../../../models/index.js";
import { hashPassword } from "../../../../misc/password.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
res: {
type: "object",
optional: false,
nullable: false,
properties: {
password: {
type: "string",
optional: false,
nullable: false,
minLength: 8,
maxLength: 8
}
}
}
};
export const paramDef = {
type: "object",
properties: {
userId: {
type: "string",
format: "misskey:id"
}
},
required: [
"userId"
]
};
export default define(meta, paramDef, async (ps)=>{
const user = await Users.findOneBy({
id: ps.userId
});
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);
const hash = await hashPassword(passwd);
await UserProfiles.update({
userId: user.id
}, {
password: hash
});
return {
password: passwd
};
});
@@ -0,0 +1,51 @@
import define from "../../define.js";
import { AbuseUserReports, Users } from "../../../../models/index.js";
import { getInstanceActor } from "../../../../services/instance-actor.js";
import { deliver } from "../../../../queue/index.js";
import { renderActivity } from "../../../../remote/activitypub/renderer/index.js";
import { renderFlag } from "../../../../remote/activitypub/renderer/flag.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
reportId: {
type: "string",
format: "misskey:id"
},
forward: {
type: "boolean",
default: false
}
},
required: [
"reportId"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const report = await AbuseUserReports.findOneByOrFail({
id: ps.reportId
});
if (report == null) {
throw new Error("report not found");
}
if (ps.forward && report.targetUserHost != null) {
const actor = await getInstanceActor();
const targetUser = await Users.findOneByOrFail({
id: report.targetUserId
});
deliver(actor, renderActivity(renderFlag(actor, [
targetUser.uri
], report.comment)), targetUser.inbox);
}
await AbuseUserReports.update(report.id, {
resolved: true,
assigneeId: me.id,
forwarded: ps.forward && report.targetUserHost != null
});
});
@@ -0,0 +1,48 @@
import define from "../../define.js";
import { Users, VerifiedBadgeRequests } from "../../../../models/index.js";
import { insertModerationLog } from "../../../../services/insert-moderation-log.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireAdmin: true
};
export const paramDef = {
type: "object",
properties: {
requestId: {
type: "string",
format: "misskey:id"
},
approve: {
type: "boolean"
}
},
required: [
"requestId",
"approve"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const request = await VerifiedBadgeRequests.findOneByOrFail({
id: ps.requestId
});
if (request.status !== "pending") {
throw new Error("request already resolved");
}
if (ps.approve) {
await Users.update(request.userId, {
isVerified: true
});
}
await VerifiedBadgeRequests.update(request.id, {
status: ps.approve ? "approved" : "rejected",
resolvedAt: new Date(),
resolverId: me.id
});
insertModerationLog(me, ps.approve ? "approveVerifiedBadgeRequest" : "rejectVerifiedBadgeRequest", {
targetId: request.userId,
requestId: request.id
});
});
@@ -0,0 +1,31 @@
import define from "../../define.js";
import { sendEmail } from "../../../../services/send-email.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
to: {
type: "string"
},
subject: {
type: "string"
},
text: {
type: "string"
}
},
required: [
"to",
"subject",
"text"
]
};
export default define(meta, paramDef, async (ps)=>{
await sendEmail(ps.to, ps.subject, ps.text, ps.text);
});
@@ -0,0 +1,70 @@
import * as sanitizeHtml from "sanitize-html";
import define from "../../define.js";
import { Users, UserProfiles } from "../../../../models/index.js";
import { ApiError } from "../../error.js";
import { sendEmail } from "../../../../services/send-email.js";
import { createNotification } from "../../../../services/create-notification.js";
import config from "../../../../config/index.js";
export const meta = {
tags: [
"users"
],
requireCredential: true,
requireModerator: true,
description: "Send a moderation notice.",
errors: {
noSuchUser: {
message: "No such user.",
code: "NO_SUCH_USER",
id: "1acefcb5-0959-43fd-9685-b48305736cb5"
},
noEmail: {
message: "No email for user.",
code: "NO_EMAIL",
id: "ac9d2d22-ef73-11ed-a05b-0242ac120003"
}
}
};
export const paramDef = {
type: "object",
properties: {
userId: {
type: "string",
format: "misskey:id"
},
comment: {
type: "string",
minLength: 1,
maxLength: 2048
}
},
required: [
"userId",
"comment"
]
};
export default define(meta, paramDef, async (ps)=>{
const [user, profile] = await Promise.all([
Users.findOneBy({
id: ps.userId
}),
UserProfiles.findOneBy({
userId: ps.userId
})
]);
if (user == null || profile == null) {
throw new ApiError(meta.errors.noSuchUser);
}
createNotification(user.id, "app", {
customBody: ps.comment,
customHeader: "Moderation Notice",
customIcon: config?.images?.info
});
setImmediate(async ()=>{
const email = profile.email;
if (email == null) {
throw new ApiError(meta.errors.noEmail);
}
sendEmail(email, "Moderation notice", sanitizeHtml(ps.comment), sanitizeHtml(ps.comment));
});
});
@@ -0,0 +1,137 @@
import * as os from "node:os";
import si from "systeminformation";
import define from "../../define.js";
import { redisClient } from "../../../../db/redis.js";
import { db } from "../../../../db/postgre.js";
export const meta = {
requireCredential: true,
requireModerator: true,
tags: [
"admin",
"meta"
],
res: {
type: "object",
optional: false,
nullable: false,
properties: {
machine: {
type: "string",
optional: false,
nullable: false
},
os: {
type: "string",
optional: false,
nullable: false,
example: "linux"
},
node: {
type: "string",
optional: false,
nullable: false
},
psql: {
type: "string",
optional: false,
nullable: false
},
cpu: {
type: "object",
optional: false,
nullable: false,
properties: {
model: {
type: "string",
optional: false,
nullable: false
},
cores: {
type: "number",
optional: false,
nullable: false
}
}
},
mem: {
type: "object",
optional: false,
nullable: false,
properties: {
total: {
type: "number",
optional: false,
nullable: false,
format: "bytes"
}
}
},
fs: {
type: "object",
optional: false,
nullable: false,
properties: {
total: {
type: "number",
optional: false,
nullable: false,
format: "bytes"
},
used: {
type: "number",
optional: false,
nullable: false,
format: "bytes"
}
}
},
net: {
type: "object",
optional: false,
nullable: false,
properties: {
interface: {
type: "string",
optional: false,
nullable: false,
example: "eth0"
}
}
}
}
}
};
export const paramDef = {
type: "object",
properties: {},
required: []
};
export default define(meta, paramDef, async ()=>{
const memStats = await si.mem();
const fsStats = await si.fsSize();
const netInterface = await si.networkInterfaceDefault();
const redisServerInfo = await redisClient.info("Server");
const m = redisServerInfo.match(new RegExp("^redis_version:(.*)", "m"));
const redis_version = m?.[1];
return {
machine: os.hostname(),
os: os.platform(),
node: process.version,
psql: await db.query("SHOW server_version").then((x)=>x[0].server_version),
redis: redis_version,
cpu: {
model: os.cpus()[0].model,
cores: os.cpus().length
},
mem: {
total: memStats.total
},
fs: {
total: fsStats[0].size,
used: fsStats[0].used
},
net: {
interface: netInterface
}
};
});
@@ -0,0 +1,40 @@
import define from "../../define.js";
import { Users } from "../../../../models/index.js";
import { insertModerationLog } from "../../../../services/insert-moderation-log.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireAdmin: true
};
export const paramDef = {
type: "object",
properties: {
userId: {
type: "string",
format: "misskey:id"
},
isVerified: {
type: "boolean"
}
},
required: [
"userId",
"isVerified"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const user = await Users.findOneBy({
id: ps.userId
});
if (user == null) {
throw new Error("user not found");
}
await Users.update(user.id, {
isVerified: ps.isVerified
});
insertModerationLog(me, ps.isVerified ? "markAsVerified" : "unmarkAsVerified", {
targetId: user.id
});
});
@@ -0,0 +1,81 @@
import define from "../../define.js";
import { ModerationLogs } from "../../../../models/index.js";
import { makePaginationQuery } from "../../common/make-pagination-query.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
res: {
type: "array",
optional: false,
nullable: false,
items: {
type: "object",
optional: false,
nullable: false,
properties: {
id: {
type: "string",
optional: false,
nullable: false,
format: "id"
},
createdAt: {
type: "string",
optional: false,
nullable: false,
format: "date-time"
},
type: {
type: "string",
optional: false,
nullable: false
},
info: {
type: "object",
optional: false,
nullable: false
},
userId: {
type: "string",
optional: false,
nullable: false,
format: "id"
},
user: {
type: "object",
optional: false,
nullable: false,
ref: "UserDetailed"
}
}
}
}
};
export const paramDef = {
type: "object",
properties: {
limit: {
type: "integer",
minimum: 1,
maximum: 100,
default: 10
},
sinceId: {
type: "string",
format: "misskey:id"
},
untilId: {
type: "string",
format: "misskey:id"
}
},
required: []
};
export default define(meta, paramDef, 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);
});
@@ -0,0 +1,86 @@
import { Signins, UserProfiles, Users } from "../../../../models/index.js";
import define from "../../define.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
res: {
type: "object",
nullable: false,
optional: false
}
};
export const paramDef = {
type: "object",
properties: {
userId: {
type: "string",
format: "misskey:id"
}
},
required: [
"userId"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const [user, profile] = await Promise.all([
Users.findOneBy({
id: ps.userId
}),
UserProfiles.findOneBy({
userId: ps.userId
})
]);
if (user == null || profile == null) {
throw new Error("user not found");
}
const _me = await Users.findOneByOrFail({
id: me.id
});
if (_me.isModerator && !_me.isAdmin && user.isAdmin) {
throw new Error("cannot show info of admin");
}
if (!_me.isAdmin) {
return {
isModerator: user.isModerator,
isSilenced: user.isSilenced,
isSuspended: user.isSuspended,
moderationNote: profile.moderationNote
};
}
const maskedKeys = [
"accessToken",
"accessTokenSecret",
"refreshToken"
];
Object.keys(profile.integrations).forEach((integration)=>{
maskedKeys.forEach((key)=>profile.integrations[integration][key] = "<MASKED>");
});
const signins = await Signins.findBy({
userId: user.id
});
return {
email: profile.email,
emailVerified: profile.emailVerified,
autoAcceptFollowed: profile.autoAcceptFollowed,
noCrawle: profile.noCrawle,
preventAiLearning: profile.preventAiLearning,
alwaysMarkNsfw: profile.alwaysMarkNsfw,
carefulBot: profile.carefulBot,
injectFeaturedNote: profile.injectFeaturedNote,
receiveAnnouncementEmail: profile.receiveAnnouncementEmail,
integrations: profile.integrations,
mutedWords: profile.mutedWords,
mutedInstances: profile.mutedInstances,
mutingNotificationTypes: profile.mutingNotificationTypes,
isModerator: user.isModerator,
isVerified: user.isVerified,
isSilenced: user.isSilenced,
isSuspended: user.isSuspended,
lastActiveDate: user.lastActiveDate,
moderationNote: profile.moderationNote,
signins
};
});
@@ -0,0 +1,161 @@
import { Users } from "../../../../models/index.js";
import define from "../../define.js";
import { sqlLikeEscape } from "../../../../misc/sql-like-escape.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true,
res: {
type: "array",
nullable: false,
optional: false,
items: {
type: "object",
nullable: false,
optional: false,
ref: "UserDetailed"
}
}
};
export const paramDef = {
type: "object",
properties: {
limit: {
type: "integer",
minimum: 1,
maximum: 100,
default: 10
},
offset: {
type: "integer",
default: 0
},
sort: {
type: "string",
enum: [
"+follower",
"-follower",
"+createdAt",
"-createdAt",
"+updatedAt",
"-updatedAt"
]
},
state: {
type: "string",
enum: [
"all",
"alive",
"available",
"admin",
"moderator",
"adminOrModerator",
"silenced",
"suspended",
"verified"
],
default: "all"
},
origin: {
type: "string",
enum: [
"combined",
"local",
"remote"
],
default: "combined"
},
username: {
type: "string",
nullable: true,
default: null
},
hostname: {
type: "string",
nullable: true,
default: null,
description: "The local host is represented with `null`."
}
},
required: []
};
export default define(meta, paramDef, 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;
case "verified":
query.where("user.isVerified = 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: `${sqlLikeEscape(ps.username.toLowerCase())}%`
});
}
if (ps.hostname) {
query.andWhere("user.host = :hostname", {
hostname: ps.hostname.toLowerCase()
});
}
switch(ps.sort){
case "+follower":
query.orderBy("user.followersCount", "DESC");
break;
case "-follower":
query.orderBy("user.followersCount", "ASC");
break;
case "+createdAt":
query.orderBy("user.createdAt", "DESC");
break;
case "-createdAt":
query.orderBy("user.createdAt", "ASC");
break;
case "+updatedAt":
query.orderBy("user.updatedAt", "DESC", "NULLS LAST");
break;
case "-updatedAt":
query.orderBy("user.updatedAt", "ASC", "NULLS FIRST");
break;
default:
query.orderBy("user.id", "ASC");
break;
}
query.take(ps.limit);
query.skip(ps.offset);
const users = await query.getMany();
return await Users.packMany(users, me, {
detail: true
});
});
@@ -0,0 +1,44 @@
import define from "../../define.js";
import { Users } from "../../../../models/index.js";
import { insertModerationLog } from "../../../../services/insert-moderation-log.js";
import { publishInternalEvent } from "../../../../services/stream.js";
export const meta = {
tags: [
"admin"
],
requireCredential: true,
requireModerator: true
};
export const paramDef = {
type: "object",
properties: {
userId: {
type: "string",
format: "misskey:id"
}
},
required: [
"userId"
]
};
export default define(meta, paramDef, async (ps, me)=>{
const user = await Users.findOneBy({
id: ps.userId
});
if (user == null) {
throw new Error("user not found");
}
if (user.isAdmin) {
throw new Error("cannot silence admin");
}
await Users.update(user.id, {
isSilenced: true
});
publishInternalEvent("userChangeSilencedState", {
id: user.id,
isSilenced: true
});
insertModerationLog(me, "silence", {
targetId: user.id
});
});

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