Initial commit

This commit is contained in:
2026-07-26 12:29:43 +09:00
parent 5f290dc465
commit 50bfaeafdf
2253 changed files with 318636 additions and 196 deletions
+8
View File
@@ -0,0 +1,8 @@
# 📦 Packages
This directory contains all of the packages Iceshrimp uses.
- `backend`: Main backend code written in TypeScript for NodeJS
- `client`: Web interface written in Vue3 and TypeScript
- `sw`: Web [Service Worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) written in TypeScript
- `iceshrimp-sdk`: TypeScript SDK for both backend and client
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"jsc": {
"parser": {
"syntax": "typescript",
"dynamicImport": true,
"decorators": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
},
"experimental": {
"keepImportAssertions": true
},
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
"target": "es2022"
},
"minify": false
}
+10
View File
@@ -0,0 +1,10 @@
{
"typescript.tsdk": "node_modules\\typescript\\lib",
"path-intellisense.mappings": {
"@": "${workspaceRoot}/packages/backend/src/"
},
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": true
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,24 @@
Phosphor Icons
-------------------------
MIT License
Copyright (c) 2020 Phosphor Icons
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<title>Iceshrimp API</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!--
ReDoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url="/api.json" expand-responses="200" expand-single-schema-field="true"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@2.0.0-rc.50/bundles/redoc.standalone.js" integrity="sha256-WJbngBWN9vp6vkEuzeoSj5tE5saW9Hfj6/SinkzhL2s=" crossorigin="anonymous"></script>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
user-agent: *
allow: /
# todo: sitemap
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"allowSyntheticDefaultImports": true
},
"exclude": ["node_modules", "jspm_packages", "tmp", "temp"]
}
+176
View File
@@ -0,0 +1,176 @@
{
"name": "backend",
"main": "./index.js",
"private": true,
"type": "module",
"scripts": {
"start": "NODE_ENV=production node ./built/index.js",
"start:debug": "node --heapsnapshot-signal=SIGUSR2 --inspect ./built/index.js",
"start:test": "NODE_ENV=test node ./built/index.js",
"migrate": "typeorm migration:run -d built/ormconfig.js",
"revertmigration": "typeorm migration:revert -d built/ormconfig.js",
"generatemigration": "yarn build && typeorm migration:generate -d built/ormconfig.js",
"build": "swc src -d built --extensions .ts,.js --delete-dir-on-start && node ../../scripts/backend-web-assets.js",
"build:debug": "swc src -d built -s --extensions .ts,.js --delete-dir-on-start && node ../../scripts/backend-web-assets.js",
"watch": "swc src -d built -D -w",
"lint": "biome check --apply *",
"format": "biome format * --write"
},
"optionalDependencies": {
"@swc/core-android-arm64": "1.3.11",
"@types/formidable": "^2.0.5"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1045.0",
"@aws-sdk/lib-storage": "^3.1045.0",
"@bull-board/api": "6.0.0",
"@bull-board/koa": "6.0.0",
"@bull-board/ui": "6.0.0",
"@discordapp/twemoji": "^16.0.1",
"@iceshrimp/summaly": "2.7.3",
"@koa/cors": "3.4.3",
"@koa/multer": "^4.0.0",
"@koa/router": "^15.5.0",
"@paralleldrive/cuid2": "^2.2.2",
"@peertube/http-signature": "1.7.0",
"@smithy/node-http-handler": "4.6.1",
"@twemoji/parser": "^17.0.1",
"adm-zip": "^0.5.10",
"ajv": "8.12.0",
"archiver": "5.3.1",
"argon2": "^0.44.0",
"async-lock": "1.4.0",
"async-mutex": "^0.4.0",
"bcryptjs": "^3.0.3",
"blurhash": "2.0.5",
"bullmq": "5.16.0",
"cacheable-lookup": "7.0.0",
"cbor": "^10.0.12",
"chalk": "^5.6.2",
"chalk-template": "0.4.0",
"cli-highlight": "2.1.11",
"color-convert": "2.0.1",
"content-disposition": "0.5.4",
"date-fns": "^4.1.0",
"decompress": "^4.2.1",
"deep-email-validator": "0.1.21",
"escape-regexp": "0.0.1",
"fast-xml-parser": "^4.2.7",
"feed": "4.2.2",
"file-type": "^22.0.1",
"fluent-ffmpeg": "2.1.2",
"got": "^15.0.5",
"gunzip-maybe": "^1.4.2",
"happy-dom": "^20.9.0",
"hpagent": "0.1.2",
"iceshrimp-sdk": "workspace:*",
"ioredis": "5.4.1",
"ip-cidr": "3.1.0",
"is-svg": "4.3.2",
"js-yaml": "4.1.0",
"jsdom": "^26.1.0",
"json5": "2.2.3",
"jsonld": "8.2.0",
"jsrsasign": "10.8.6",
"koa": "2.16.4",
"koa-body": "^6.0.1",
"koa-bodyparser": "4.4.1",
"koa-favicon": "2.1.0",
"koa-logger": "3.2.1",
"koa-mount": "4.0.0",
"koa-remove-trailing-slashes": "2.0.3",
"koa-send": "5.0.1",
"koa-slow": "2.1.0",
"koa-views": "7.0.2",
"mfm-js": "^0.25.0",
"mime-types": "2.1.35",
"msgpackr": "1.11.2",
"multer": "^2.1.1",
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
"nodemailer": "6.9.3",
"oauth": "^0.10.0",
"os-utils": "0.0.14",
"otpauth": "^9.1.3",
"parse-duration": "^1.1.0",
"parse5": "7.1.2",
"pg": "8.11.1",
"private-ip": "2.3.4",
"probe-image-size": "7.2.3",
"prom-client": "^15.1.0",
"promise-limit": "2.7.0",
"punycode": "2.3.0",
"pureimage": "^0.4.18",
"qrcode": "1.5.3",
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
"re2": "^1.22.1",
"redis-lock": "0.1.4",
"redis-semaphore": "5.3.1",
"reflect-metadata": "0.1.13",
"rename": "1.0.4",
"rndstr": "1.0.0",
"rss-parser": "3.13.0",
"sanitize-html": "2.10.0",
"semver": "7.5.4",
"sharp": "0.33.5",
"shogiops": "^0.21.0",
"stringz": "2.1.0",
"syslog-pro": "1.0.0",
"systeminformation": "^5.21.12",
"tar-stream": "^3.1.6",
"tesseract.js": "^7.0.0",
"tinycolor2": "1.5.2",
"tmp": "0.2.1",
"typeorm": "0.3.17",
"uuid": "^14.0.0",
"web-push": "3.6.3",
"websocket": "1.0.34",
"xev": "3.0.2"
},
"devDependencies": {
"@swc/cli": "^0.1.62",
"@swc/core": "^1.3.68",
"@types/adm-zip": "^0.5.0",
"@types/async-lock": "1.4.0",
"@types/escape-regexp": "0.0.1",
"@types/fluent-ffmpeg": "2.1.21",
"@types/js-yaml": "4.0.5",
"@types/jsonld": "1.5.9",
"@types/jsrsasign": "10.5.8",
"@types/koa": "2.13.6",
"@types/koa-bodyparser": "4.3.10",
"@types/koa-cors": "0.0.2",
"@types/koa-favicon": "2.0.21",
"@types/koa-logger": "3.1.2",
"@types/koa-mount": "4.0.2",
"@types/koa-send": "4.1.3",
"@types/koa-views": "7.0.0",
"@types/koa__cors": "3.3.0",
"@types/node": "^22",
"@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.8",
"@types/oauth": "0.9.1",
"@types/pg": "^8.10.5",
"@types/probe-image-size": "^7.2.0",
"@types/pug": "2.0.6",
"@types/punycode": "2.1.0",
"@types/qrcode": "1.5.1",
"@types/random-seed": "0.3.3",
"@types/ratelimiter": "3.4.4",
"@types/redis": "4.0.11",
"@types/rename": "1.0.4",
"@types/sanitize-html": "2.9.0",
"@types/semver": "7.5.0",
"@types/tinycolor2": "1.4.3",
"@types/tmp": "0.2.3",
"@types/uuid": "^10.0.0",
"@types/web-push": "3.3.2",
"@types/websocket": "1.0.5",
"execa": "6.1.0",
"pug": "3.0.2",
"strict-event-emitter-types": "2.0.0",
"tsconfig-paths": "4.2.0",
"typescript": "^6.0.3"
}
}
@@ -0,0 +1,313 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import pg from "pg";
import config from "../built/config/index.js";
const { Client } = pg;
const marker = "karaoke-service-rights-safe-test-data";
const createdAt = new Date();
function genId(date = new Date()) {
const time2000 = 946684800000;
const timestamp = Math.max(date.getTime() - time2000, 0).toString(36).padStart(8, "0");
const random = crypto.randomBytes(8).toString("base64url").replace(/[^a-z0-9]/gi, "").toLowerCase().slice(0, 8).padEnd(8, "0");
return `${timestamp}${random}`;
}
function md5(buffer) {
return crypto.createHash("md5").update(buffer).digest("hex");
}
function saveInternalFile(name, buffer) {
const accessKey = crypto.randomUUID();
fs.mkdirSync(config.mediaDir, { recursive: true });
fs.writeFileSync(path.join(config.mediaDir, accessKey), buffer);
return {
accessKey,
url: `${config.url}/files/${accessKey}`,
name,
size: buffer.byteLength,
md5: md5(buffer),
};
}
function createWav() {
const sampleRate = 44100;
const seconds = 16;
const samples = sampleRate * seconds;
const data = Buffer.alloc(samples * 2);
const notes = [261.63, 293.66, 329.63, 392.0, 349.23, 329.63, 293.66, 261.63];
for (let i = 0; i < samples; i++) {
const t = i / sampleRate;
const beat = Math.floor(t / 2) % notes.length;
const root = notes[beat] / 2;
const value =
Math.sin(2 * Math.PI * root * t) * 0.22 +
Math.sin(2 * Math.PI * root * 1.5 * t) * 0.12 +
Math.sin(2 * Math.PI * root * 2 * t) * 0.08;
data.writeInt16LE(Math.round(Math.max(-1, Math.min(1, value)) * 0x7fff), i * 2);
}
const header = Buffer.alloc(44);
header.write("RIFF", 0);
header.writeUInt32LE(36 + data.length, 4);
header.write("WAVE", 8);
header.write("fmt ", 12);
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20);
header.writeUInt16LE(1, 22);
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(sampleRate * 2, 28);
header.writeUInt16LE(2, 32);
header.writeUInt16LE(16, 34);
header.write("data", 36);
header.writeUInt32LE(data.length, 40);
return Buffer.concat([header, data]);
}
function varLen(value) {
let buffer = value & 0x7f;
while ((value >>= 7) > 0) {
buffer <<= 8;
buffer |= (value & 0x7f) | 0x80;
}
const bytes = [];
for (;;) {
bytes.push(buffer & 0xff);
if (buffer & 0x80) buffer >>= 8;
else break;
}
return Buffer.from(bytes);
}
function createMidi() {
const ticksPerQuarter = 480;
const events = [];
const push = (...bytes) => events.push(Buffer.from(bytes));
const pushDelta = (delta) => events.push(varLen(delta));
pushDelta(0);
push(0xff, 0x51, 0x03, 0x07, 0xa1, 0x20); // 120 BPM
pushDelta(0);
push(0xc0, 0x00);
const melody = [60, 62, 64, 67, 65, 64, 62, 60];
for (const note of melody) {
pushDelta(0);
push(0x90, note, 0x64);
pushDelta(ticksPerQuarter * 2);
push(0x80, note, 0x40);
}
pushDelta(0);
push(0xff, 0x2f, 0x00);
const track = Buffer.concat(events);
const header = Buffer.alloc(14);
header.write("MThd", 0);
header.writeUInt32BE(6, 4);
header.writeUInt16BE(0, 8);
header.writeUInt16BE(1, 10);
header.writeUInt16BE(ticksPerQuarter, 12);
const trackHeader = Buffer.alloc(8);
trackHeader.write("MTrk", 0);
trackHeader.writeUInt32BE(track.length, 4);
return Buffer.concat([header, trackHeader, track]);
}
function textBuffer(text) {
return Buffer.from(text.replace(/\n/g, "\r\n"), "utf8");
}
async function main() {
const client = new Client({
host: config.db.host,
port: config.db.port,
user: config.db.user,
password: config.db.pass,
database: config.db.db,
...config.db.extra,
});
await client.connect();
try {
const admin = await client.query(
`SELECT "id", "username", "host" FROM "user" WHERE "usernameLower" = 'admin' AND "host" IS NULL LIMIT 1`,
);
if (admin.rowCount === 0) {
throw new Error("Local @admin user was not found. Create @admin first, then rerun this script.");
}
const adminUser = admin.rows[0];
const existing = await client.query(
`SELECT "id", "fileIds" FROM "note" WHERE "userId" = $1 AND "text" ILIKE $2 LIMIT 1`,
[adminUser.id, `%${marker}%`],
);
const assets = [
{
name: "karaoke-test-accompaniment.wav",
type: "audio/wav",
comment: "KaraokeService test accompaniment. Original generated tone progression.",
buffer: createWav(),
},
{
name: "karaoke-test-lyrics.lrc",
type: "text/plain",
comment: "KaraokeService timed lyrics.",
buffer: textBuffer(`[00:00.000]Original karaoke test
[00:02.000]Sing the first clear tone
[00:04.000]Move to the next note
[00:06.000]Hold the bright third
[00:08.000]Reach the open fifth
[00:10.000]Return with steady timing
[00:12.000]Finish on the home note
[00:14.000]This data is rights-safe`),
},
{
name: "karaoke-test-pitch.mid",
type: "audio/midi",
comment: "KaraokeService MIDI pitch and tempo data.",
buffer: createMidi(),
},
{
name: "karaoke-test-score.json",
type: "application/json",
comment: "KaraokeService scoring metadata.",
buffer: textBuffer(JSON.stringify({
version: 1,
scoring: "midi-pitch",
pitchFile: "karaoke-test-pitch.mid",
toleranceCents: 50,
partialToleranceCents: 100,
extraSingingPenalty: true,
}, null, 2)),
},
{
name: "karaoke-test-song-info.txt",
type: "text/plain",
comment: "KaraokeService song metadata.",
buffer: textBuffer(`Title: Original Karaoke Test
Artist: FrozenFriendsYume test data
License: Public-domain equivalent test fixture generated for this repository
BPM: 120
Key: C major
Notes: All melody, lyrics, accompaniment, and metadata were generated locally for testing.`),
},
];
const files = assets.map((asset) => ({
id: genId(createdAt),
createdAt,
userId: adminUser.id,
userHost: null,
...saveInternalFile(asset.name, asset.buffer),
type: asset.type,
comment: asset.comment,
}));
await client.query("BEGIN");
for (const file of files) {
await client.query(
`INSERT INTO "drive_file" (
"id", "createdAt", "userId", "userHost", "md5", "name", "type", "size", "comment",
"blurhash", "properties", "storedInternal", "url", "thumbnailUrl", "webpublicUrl",
"webpublicType", "accessKey", "thumbnailAccessKey", "webpublicAccessKey", "uri", "src",
"folderId", "isSensitive", "allowDownload", "isLink", "requestHeaders", "requestIp"
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
NULL, '{}', TRUE, $10, NULL, NULL,
NULL, $11, NULL, NULL, NULL, NULL,
NULL, FALSE, FALSE, FALSE, NULL, NULL
)`,
[
file.id,
file.createdAt,
file.userId,
file.userHost,
file.md5,
file.name,
file.type,
file.size,
file.comment,
file.url,
file.accessKey,
],
);
}
const noteText = `Original Karaoke Test
Rights-safe generated karaoke test data.
Marker: ${marker}
#KaraokeService`;
if (existing.rowCount > 0) {
const noteId = existing.rows[0].id;
const oldFileIds = existing.rows[0].fileIds ?? [];
const oldFiles = oldFileIds.length > 0
? await client.query(`SELECT "id", "accessKey" FROM "drive_file" WHERE "id" = ANY($1)`, [oldFileIds])
: { rows: [] };
await client.query(
`UPDATE "note" SET "text" = $2, "fileIds" = $3, "attachedFileTypes" = $4, "updatedAt" = $5 WHERE "id" = $1`,
[noteId, noteText, files.map((file) => file.id), files.map((file) => file.type), new Date()],
);
if (oldFileIds.length > 0) {
await client.query(`DELETE FROM "drive_file" WHERE "id" = ANY($1)`, [oldFileIds]);
for (const file of oldFiles.rows) {
if (!file.accessKey) continue;
fs.rmSync(path.join(config.mediaDir, file.accessKey), { force: true });
}
}
await client.query("COMMIT");
console.log(`Recreated KaraokeService test media for existing @admin note: ${noteId}`);
return;
}
const noteId = genId(createdAt);
await client.query(
`INSERT INTO "note" (
"id", "createdAt", "replyId", "renoteId", "threadId", "text", "name", "cw",
"userId", "groupId", "localOnly", "renoteCount", "repliesCount", "viewCount",
"reactions", "visibility", "uri", "url", "score", "fileIds", "attachedFileTypes",
"visibleUserIds", "mentions", "mentionedRemoteUsers", "emojis", "tags", "hasPoll",
"channelId", "quoteAuthorization", "canQuote", "userHost", "replyUserId",
"replyUserHost", "renoteUserId", "renoteUserHost", "updatedAt"
) VALUES (
$1, $2, NULL, NULL, NULL, $3, NULL, NULL,
$4, NULL, FALSE, 0, 0, 0,
'{}', 'public', NULL, NULL, 0, $5, $6,
'{}', '{}', '[]', '{}', $7, FALSE,
NULL, NULL, TRUE, NULL, NULL,
NULL, NULL, NULL, NULL
)`,
[
noteId,
createdAt,
noteText,
adminUser.id,
files.map((file) => file.id),
files.map((file) => file.type),
["karaokeservice"],
],
);
await client.query(`UPDATE "user" SET "notesCount" = "notesCount" + 1 WHERE "id" = $1`, [adminUser.id]);
await client.query("COMMIT");
console.log(`Created KaraokeService test note as @admin: ${noteId}`);
} catch (err) {
await client.query("ROLLBACK").catch(() => undefined);
throw err;
} finally {
await client.end();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+14
View File
@@ -0,0 +1,14 @@
declare module "hcaptcha" {
interface IVerifyResponse {
success: boolean;
challenge_ts: string;
hostname: string;
credit?: boolean;
"error-codes"?: unknown[];
}
export function verify(
secret: string,
token: string,
): Promise<IVerifyResponse>;
}
+98
View File
@@ -0,0 +1,98 @@
declare module "@peertube/http-signature" {
import type { IncomingMessage, ClientRequest } from "node:http";
interface ISignature {
keyId: string;
algorithm: string;
headers: string[];
signature: string;
}
interface IOptions {
headers?: string[];
algorithm?: string;
strict?: boolean;
authorizationHeaderName?: string;
}
interface IParseRequestOptions extends IOptions {
clockSkew?: number;
}
interface IParsedSignature {
scheme: string;
params: ISignature;
signingString: string;
algorithm: string;
keyId: string;
}
type RequestSignerConstructorOptions =
| IRequestSignerConstructorOptionsFromProperties
| IRequestSignerConstructorOptionsFromFunction;
interface IRequestSignerConstructorOptionsFromProperties {
keyId: string;
key: string | Buffer;
algorithm?: string;
}
interface IRequestSignerConstructorOptionsFromFunction {
sign?: (data: string, cb: (err: any, sig: ISignature) => void) => void;
}
class RequestSigner {
constructor(options: RequestSignerConstructorOptions);
public writeHeader(header: string, value: string): string;
public writeDateHeader(): string;
public writeTarget(method: string, path: string): void;
public sign(cb: (err: any, authz: string) => void): void;
}
interface ISignRequestOptions extends IOptions {
keyId: string;
key: string;
httpVersion?: string;
}
export function parse(
request: IncomingMessage,
options?: IParseRequestOptions,
): IParsedSignature;
export function parseRequest(
request: IncomingMessage,
options?: IParseRequestOptions,
): IParsedSignature;
export function sign(
request: ClientRequest,
options: ISignRequestOptions,
): boolean;
export function signRequest(
request: ClientRequest,
options: ISignRequestOptions,
): boolean;
export function createSigner(): RequestSigner;
export function isSigner(obj: any): obj is RequestSigner;
export function sshKeyToPEM(key: string): string;
export function sshKeyFingerprint(key: string): string;
export function pemToRsaSSHKey(pem: string, comment: string): string;
export function verify(
parsedSignature: IParsedSignature,
pubkey: string | Buffer,
): boolean;
export function verifySignature(
parsedSignature: IParsedSignature,
pubkey: string | Buffer,
): boolean;
export function verifyHMAC(
parsedSignature: IParsedSignature,
secret: string,
): boolean;
}
@@ -0,0 +1 @@
declare module "koa-remove-trailing-slashes";
+14
View File
@@ -0,0 +1,14 @@
declare module "koa-slow" {
import type { Middleware } from "koa";
interface ISlowOptions {
url?: RegExp;
delay?: number;
}
function slow(options?: ISlowOptions): Middleware;
namespace slow {} // Hack
export = slow;
}
+33
View File
@@ -0,0 +1,33 @@
declare module "os-utils" {
type FreeCommandCallback = (usedmem: number) => void;
type HarddriveCallback = (total: number, free: number, used: number) => void;
type GetProcessesCallback = (result: string) => void;
type CPUCallback = (perc: number) => void;
export function platform(): NodeJS.Platform;
export function cpuCount(): number;
export function sysUptime(): number;
export function processUptime(): number;
export function freemem(): number;
export function totalmem(): number;
export function freememPercentage(): number;
export function freeCommand(callback: FreeCommandCallback): void;
export function harddrive(callback: HarddriveCallback): void;
export function getProcesses(callback: GetProcessesCallback): void;
export function getProcesses(
nProcess: number,
callback: GetProcessesCallback,
): void;
export function allLoadavg(): string;
export function loadavg(_time?: number): number;
export function cpuFree(callback: CPUCallback): void;
export function cpuUsage(callback: CPUCallback): void;
}
+10
View File
@@ -0,0 +1,10 @@
declare module "*/package.json" {
interface IRepository {
type: string;
url: string;
}
export const name: string;
export const version: string;
export const repository: IRepository;
}
+94
View File
@@ -0,0 +1,94 @@
import cluster from "node:cluster";
import chalk from "chalk";
import Xev from "xev";
import Logger from "@/services/logger.js";
import { envOption } from "../env.js";
// for typeorm
import "reflect-metadata";
import { masterMain } from "./master.js";
import { workerMain } from "./worker.js";
import os from "node:os";
import { isIgnorableConnectionError } from "@/server/is-ignorable-connection-error.js";
const logger = new Logger("core", "cyan");
const clusterLogger = logger.createSubLogger("cluster", "orange", false);
const ev = new Xev();
/**
* Init process
*/
export default async function () {
process.title = `FrozenFriendsYume (${cluster.isPrimary ? "master" : "worker"})`;
if (cluster.isPrimary || envOption.disableClustering) {
await masterMain();
if (cluster.isPrimary) {
ev.mount();
}
}
if (cluster.isWorker || envOption.disableClustering) {
await workerMain();
}
if (cluster.isPrimary) {
// Leave the master process with a marginally lower priority but not too low.
os.setPriority(2);
}
if (cluster.isWorker) {
// Set workers to a much lower priority so that the master process will be
// able to respond to api calls even if the workers gank everything.
os.setPriority(10);
}
// For when FrozenFriendsYume is started in a child process during unit testing.
// Otherwise, process.send cannot be used, so start it.
if (process.send) {
process.send("ok");
}
}
//#region Events
// Listen new workers
cluster.on("fork", (worker) => {
clusterLogger.debug(`Process forked: [${worker.id}]`);
});
// Listen online workers
cluster.on("online", (worker) => {
clusterLogger.debug(`Process is now online: [${worker.id}]`);
});
// Listen for dying workers
cluster.on("exit", (worker) => {
// Replace the dead worker,
// we're not sentimental
clusterLogger.error(chalk.red(`[${worker.id}] died :(`));
cluster.fork();
});
// Display detail of unhandled promise rejection
if (!envOption.quiet) {
process.on("unhandledRejection", (err) => {
if (isIgnorableConnectionError(err)) return;
console.dir(err);
});
}
// Display detail of uncaught exception
process.on("uncaughtException", (err) => {
if (isIgnorableConnectionError(err)) return;
try {
logger.error(err);
} catch {}
});
// Dying away...
process.on("exit", (code) => {
logger.info(`The process is going to exit with code ${code}`);
});
//#endregion
+197
View File
@@ -0,0 +1,197 @@
import * as fs from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import * as os from "node:os";
import cluster from "node:cluster";
import net from "node:net";
import repl from "node:repl";
import chalk from "chalk";
import chalkTemplate from "chalk-template";
import semver from "semver";
import Logger from "@/services/logger.js";
import loadConfig from "@/config/load.js";
import type { Config } from "@/config/types.js";
import { lessThan } from "@/prelude/array.js";
import { envOption } from "../env.js";
import { showMachineInfo } from "@/misc/show-machine-info.js";
import { db, initDb } from "../db/postgre.js";
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const meta = JSON.parse(
fs.readFileSync(`${_dirname}/../../../../built/meta.json`, "utf-8"),
);
const logger = new Logger("core", "cyan");
const bootLogger = logger.createSubLogger("boot", "magenta", false);
const themeColor = chalk.hex("#31748f");
function greet() {
if (!envOption.quiet) {
console.log(
chalkTemplate`--- ${os.hostname()} {gray (PID: ${process.pid.toString()})} ---`,
);
}
const displayVersion = meta.version.startsWith("v") ? meta.version : `v${meta.version}`;
bootLogger.info(`FrozenFriendsYume ${displayVersion}, initializing...`, null, true);
}
/**
* Init master process
*/
export async function masterMain() {
let config!: Config;
// initialize app
try {
greet();
showEnvironment();
await showMachineInfo(bootLogger);
showNodejsVersion();
config = loadConfigBoot();
await connectDb();
if (config.shellAddress !== undefined)
spawnShell(config.shellAddress);
} catch (e) {
bootLogger.error("Fatal error occurred during initialization", null, true);
bootLogger.error(e);
process.exit(1);
}
bootLogger.succ("FrozenFriendsYume initialized");
if (!envOption.disableClustering) {
await spawnWorkers(config.clusterLimit);
}
bootLogger.succ(
`Now listening on port ${config.port} on ${config.url} - using ${config.domain}`,
null,
true,
);
if (!envOption.noDaemons && !config.onlyQueueProcessor) {
import("../daemons/server-stats.js").then((x) => x.default());
import("../daemons/queue-stats.js").then((x) => x.default());
import("../daemons/janitor.js").then((x) => x.default());
}
}
function showEnvironment(): void {
const env = process.env.NODE_ENV;
const logger = bootLogger.createSubLogger("env");
logger.info(
typeof env === "undefined" ? "NODE_ENV is not set" : `NODE_ENV: ${env}`,
);
if (env !== "production") {
logger.warn("The environment is not in production mode.");
logger.warn("DO NOT USE FOR PRODUCTION PURPOSE!", null, true);
}
}
function showNodejsVersion(): void {
const nodejsLogger = bootLogger.createSubLogger("nodejs");
nodejsLogger.info(`Version ${process.version} detected.`);
const minVersion = fs
.readFileSync(`${_dirname}/../../../../.node-version`, "utf-8")
.trim();
if (semver.lt(process.version, minVersion)) {
nodejsLogger.error(`At least Node.js ${minVersion} required!`);
process.exit(1);
}
}
function loadConfigBoot(): Config {
const configLogger = bootLogger.createSubLogger("config");
let config;
try {
config = loadConfig();
} catch (exception) {
if (exception.code === "ENOENT") {
configLogger.error("Configuration file not found", null, true);
process.exit(1);
} else if (e instanceof Error) {
configLogger.error(e.message);
process.exit(1);
}
throw exception;
}
configLogger.succ("Loaded");
return config;
}
async function connectDb(): Promise<void> {
const dbLogger = bootLogger.createSubLogger("db");
// Try to connect to DB
try {
dbLogger.info("Connecting...");
await initDb();
const v = await db
.query("SHOW server_version")
.then((x) => x[0].server_version);
dbLogger.succ(`Connected: v${v}`);
} catch (e) {
dbLogger.error("Cannot connect", null, true);
dbLogger.error(e);
process.exit(1);
}
}
async function spawnWorkers(limit = 1) {
const workers = Math.min(limit, os.cpus().length);
bootLogger.info(`Starting ${workers} worker${workers === 1 ? "" : "s"}...`);
await Promise.all([...Array(workers)].map(spawnWorker));
bootLogger.succ("All workers started");
}
function spawnWorker(): Promise<void> {
return new Promise((res) => {
const worker = cluster.fork();
worker.on("message", (message) => {
if (message === "listenFailed") {
bootLogger.error("The server Listen failed due to the previous error.");
process.exit(1);
}
if (message !== "ready") return;
res();
});
});
}
function spawnShell(address: number | string) {
logger.info(`Spawning debug shell on ${address}`);
if (typeof address == "string") {
try {
fs.unlinkSync(address);
} catch {}
}
net
.createServer()
.unref()
.listen(address)
.on("connection", (socket) => {
const r = repl.start({ input: socket, output: socket });
socket.on("close", () => r.close());
socket.on("error", () => r.close());
r.on("close", () => socket.destroy());
if (!envOption.disableClustering) {
r.defineCommand("worker", (iid) => {
r.close();
const id = parseInt(iid.trim());
cluster.workers[id].send("debug-shell", socket);
});
}
})
.on("error", (error) => logger.error(error));
}
+37
View File
@@ -0,0 +1,37 @@
import net from "node:net";
import repl from "node:repl";
import cluster from "node:cluster";
import { initDb } from "../db/postgre.js";
import config from "@/config/index.js";
/**
* Init worker process
*/
export async function workerMain() {
await initDb();
if (!config.onlyQueueProcessor) {
// start server
await import("../server/index.js").then((x) => x.default());
}
// start job queue
import("../queue/index.js").then((x) => x.default());
if (cluster.isWorker) {
// Send a 'ready' message to parent process
process.send!("ready");
cluster.worker.on("message", (message, handle) => {
if (message == "debug-shell") spawnWorkerShell(handle);
});
}
}
function spawnWorkerShell(socket: net.Socket) {
const r = repl.start({ input: socket, output: socket });
r.defineCommand("worker", (iid) => {
r.close();
const id = parseInt(iid.trim());
cluster.workers[id].send("debug-shell", socket);
});
}
+3
View File
@@ -0,0 +1,3 @@
import load from "./load.js";
export default load();
+120
View File
@@ -0,0 +1,120 @@
/**
* Config loader
*/
import * as fs from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import * as yaml from "js-yaml";
import type { Source, Mixin } from "./types.js";
import path from "node:path";
import parseDuration from 'parse-duration'
export default function load() {
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const dir = `${_dirname}/../../../..`;
const {
ICESHRIMP_CONFIG: configFile,
ICESHRIMP_SECRETS: secretsFile,
ICESHRIMP_MEDIA_DIR: mediaDir,
} = process.env;
const configPath =
process.env.NODE_ENV === "test"
? `${dir}/.config/test.yml`
: configFile ?? `${dir}/.config/default.yml`;
const meta = JSON.parse(
fs.readFileSync(`${_dirname}/../../../../built/meta.json`, "utf-8"),
);
const clientManifest = JSON.parse(
fs.readFileSync(
`${_dirname}/../../../../built/_client_dist_/manifest.json`,
"utf-8",
),
);
let config = yaml.load(fs.readFileSync(configPath, "utf-8")) as Source;
if (secretsFile !== undefined)
config = Object.assign(config, yaml.load(fs.readFileSync(secretsFile, "utf-8")) as Source);
const mixin = {} as Mixin;
const url = tryCreateUrl(config.url);
config.url = url.origin;
config.port = config.port || parseInt(process.env.PORT || "", 10);
config.listen = process.env.ICESHRIMP_LISTEN ?? config.listen ?? "0.0.0.0";
if (config.tls != null) {
config.tls = {
keyPath:
typeof config.tls.keyPath === "string"
? path.resolve(dir, config.tls.keyPath)
: undefined,
certPath:
typeof config.tls.certPath === "string"
? path.resolve(dir, config.tls.certPath)
: undefined,
};
}
config.images = {
info: '/twemoji/1f440.svg',
notFound: '/twemoji/2049.svg',
error: '/twemoji/1f480.svg',
...config.images,
};
config.htmlCache = {
ttlSeconds: parseDuration(config.htmlCache?.ttl ?? '1h', 's')!,
prewarm: false,
dbFallback: false,
...config.htmlCache,
}
if (config.htmlCache.ttlSeconds == null) throw new Error('Failed to parse config.htmlCache.ttl');
config.wordMuteCache = {
ttlSeconds: parseDuration(config.wordMuteCache?.ttl ?? '24h', 's')!,
}
if (config.wordMuteCache.ttlSeconds == null) throw new Error('Failed to parse config.wordMuteCache.ttl');
config.searchEngine = config.searchEngine ?? 'https://duckduckgo.com/?q=';
config.metrics = config.metrics ?? {};
config.metrics.enable = config.metrics?.enable ?? false;
if (typeof config.shellAddress === "string") {
config.shellAddress = path.isAbsolute(config.shellAddress) ? config.shellAddress : path.resolve(`../../${config.shellAddress}`);
}
mixin.version = meta.version;
mixin.host = url.host;
mixin.hostname = url.hostname;
mixin.domain = config.accountDomain ?? url.host;
mixin.scheme = url.protocol.replace(/:$/, "");
mixin.wsScheme = mixin.scheme.replace("http", "ws");
mixin.wsUrl = `${mixin.wsScheme}://${mixin.host}`;
mixin.apiUrl = `${mixin.scheme}://${mixin.host}/api`;
mixin.authUrl = `${mixin.scheme}://${mixin.host}/auth`;
mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`;
mixin.userAgent = `FrozenFriendsYume/${meta.version} (${config.url})`;
mixin.clientEntry = clientManifest["src/init.ts"];
mixin.mediaDir = mediaDir ?? `${dir}/files`;
if (!config.redis.prefix) config.redis.prefix = mixin.hostname;
return Object.assign(config, mixin);
}
function tryCreateUrl(url: string) {
try {
return new URL(url);
} catch (e) {
throw new Error(`url="${url}" is not a valid URL.`);
}
}
+185
View File
@@ -0,0 +1,185 @@
/**
* ユーザーが設定する必要のある情報
*/
export type TypeORMLoggingOptions =
| "error"
| "slow"
| "query"
| "schema"
| "info"
| "log";
export type TypeORMLoggingConfig = TypeORMLoggingOptions | TypeORMLoggingOptions[] | "all";
export type Source = {
repository_url?: string;
feedback_url?: string;
url: string;
accountDomain?: string;
port: number;
listen?: string;
disableHsts?: boolean;
tls?: {
keyPath?: string;
certPath?: string;
};
db: {
logging?: TypeORMLoggingConfig;
host: string;
port: number;
db: string;
user: string;
pass: string;
disableCache?: boolean;
extra?: { [x: string]: string };
};
redis: {
host: string;
port: number;
family?: number;
pass?: string;
db?: number;
prefix?: string;
user?: string;
tls?: { [y: string]: string };
};
mediaCleanup?: {
cron?: boolean;
maxAgeDays?: number;
keepAvatars?: boolean;
keepHeaders?: boolean;
};
images?: {
error?: string;
notFound?: string;
info?: string;
};
htmlCache?: {
ttl?: string;
ttlSeconds?: number;
prewarm?: boolean;
dbFallback?: boolean;
}
wordMuteCache?: {
ttl?: string;
ttlSeconds?: number;
}
searchEngine?: string;
proxy?: string;
proxySmtp?: string;
proxyBypassHosts?: string[];
allowedPrivateNetworks?: string[];
maxFileSize?: number;
accesslog?: string;
clusterLimit?: number;
onlyQueueProcessor?: boolean;
cuid?: {
length?: number;
fingerprint?: string;
};
outgoingAddressFamily?: "ipv4" | "ipv6" | "dual";
deliverJobConcurrency?: number;
inboxJobConcurrency?: number;
deliverJobPerSec?: number;
inboxJobPerSec?: number;
deliverJobMaxAttempts?: number;
inboxJobMaxAttempts?: number;
syslog: {
host: string;
port: number;
};
mediaProxy?: string;
proxyRemoteFiles?: boolean;
twa: {
nameSpace?: string;
packageName?: string;
sha256CertFingerprints?: string[];
};
reservedUsernames?: string[];
shellAddress?: number | string;
// Managed hosting stuff
maxUserSignups?: number;
isManagedHosting?: boolean;
maxNoteLength?: number;
maxCaptionLength?: number;
deepl: {
managed?: boolean;
authKey?: string;
isPro?: boolean;
};
libreTranslate: {
managed?: boolean;
apiUrl?: string;
apiKey?: string;
};
email: {
managed?: boolean;
address?: string;
host?: string;
port?: number;
user?: string;
pass?: string;
useImplicitSslTls?: boolean;
};
objectStorage: {
managed?: boolean;
baseUrl?: string;
bucket?: string;
prefix?: string;
endpoint?: string;
region?: string;
accessKey?: string;
secretKey?: string;
useSsl?: boolean;
connnectOverProxy?: boolean;
setPublicReadOnUpload?: boolean;
s3ForcePathStyle?: boolean;
};
summalyProxyUrl?: string;
metrics?: {
enable?: boolean;
token?: string;
};
};
/**
* Misskeyが自動的に(ユーザーが設定した情報から推論して)設定する情報
*/
export type Mixin = {
version: string;
host: string;
hostname: string;
domain: string;
scheme: string;
wsScheme: string;
apiUrl: string;
wsUrl: string;
authUrl: string;
driveUrl: string;
userAgent: string;
clientEntry: string;
mediaDir: string;
};
export type Config = Source & Mixin;
+79
View File
@@ -0,0 +1,79 @@
import config from "@/config/index.js";
import {
DB_MAX_NOTE_TEXT_LENGTH,
DB_MAX_IMAGE_COMMENT_LENGTH,
} from "@/misc/hard-limits.js";
export const MAX_NOTE_TEXT_LENGTH = Math.min(
config.maxNoteLength ?? 3000,
DB_MAX_NOTE_TEXT_LENGTH,
);
export const MAX_CAPTION_TEXT_LENGTH = Math.min(
config.maxCaptionLength ?? 1500,
DB_MAX_IMAGE_COMMENT_LENGTH,
);
export const SECOND = 1000;
export const SEC = 1000; // why do we need this duplicate here?
export const MINUTE = 60 * SEC;
export const MIN = 60 * SEC; // why do we need this duplicate here?
export const HOUR = 60 * MIN;
export const DAY = 24 * HOUR;
export const USER_ONLINE_THRESHOLD = 10 * MINUTE;
export const USER_ACTIVE_THRESHOLD = 3 * DAY;
// List of file types allowed to be viewed directly in the browser
// Anything not included here will be responded as application/octet-stream
// SVG is not allowed because it generates XSS <- we need to fix this and later allow it to be viewed directly
export const FILE_TYPE_BROWSERSAFE = [
// Images
"image/png",
"image/gif", // TODO: deprecated, but still used by old notes, new gifs should be converted to webp in the future
"image/jpeg",
"image/webp", // TODO: make this the default image format
"image/apng",
"image/bmp",
"image/tiff",
"image/x-icon",
"image/avif", // not as good supported now, but its good to introduce initial support for the future
// OggS
"audio/opus",
"video/ogg",
"audio/ogg",
"application/ogg",
// ISO/IEC base media file format
"video/quicktime",
"video/mp4", // TODO: we need to check for av1 later
"video/vnd.avi", // also av1
"audio/mp4",
"video/x-m4v",
"audio/x-m4a",
"video/3gpp",
"video/3gpp2",
"video/3gp2",
"audio/3gpp",
"audio/3gpp2",
"audio/3gp2",
"video/mpeg",
"audio/mpeg",
"video/webm",
"audio/webm",
"audio/aac",
"audio/x-flac",
"audio/flac",
"audio/vnd.wave",
// Documents
"application/epub+zip",
];
/*
https://github.com/sindresorhus/file-type/blob/main/supported.js
https://github.com/sindresorhus/file-type/blob/main/core.js
https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers
*/
+20
View File
@@ -0,0 +1,20 @@
// TODO: 消したい
const interval = 30 * 60 * 1000;
import { AttestationChallenges } from "@/models/index.js";
import { LessThan } from "typeorm";
/**
* Clean up database occasionally
*/
export default function () {
async function tick() {
await AttestationChallenges.delete({
createdAt: LessThan(new Date(new Date().getTime() - 5 * 60 * 1000)),
});
}
tick();
setInterval(tick, interval);
}
@@ -0,0 +1,60 @@
import Xev from "xev";
import { deliverQueue, inboxQueue } from "../queue/queues.js";
const ev = new Xev();
const interval = 10000;
/**
* Report queue stats regularly
*/
export default function () {
const log = [] as any[];
ev.on("requestQueueStatsLog", (x) => {
ev.emit(`queueStatsLog:${x.id}`, log.slice(0, x.length || 50));
});
let activeDeliverJobs = 0;
let activeInboxJobs = 0;
deliverQueue.on("global:active", () => {
activeDeliverJobs++;
});
inboxQueue.on("global:active", () => {
activeInboxJobs++;
});
async function tick() {
const deliverJobCounts = await deliverQueue.getJobCounts();
const inboxJobCounts = await inboxQueue.getJobCounts();
const stats = {
deliver: {
activeSincePrevTick: activeDeliverJobs,
active: deliverJobCounts.active,
waiting: deliverJobCounts.waiting,
delayed: deliverJobCounts.delayed,
},
inbox: {
activeSincePrevTick: activeInboxJobs,
active: inboxJobCounts.active,
waiting: inboxJobCounts.waiting,
delayed: inboxJobCounts.delayed,
},
};
ev.emit("queueStats", stats);
log.unshift(stats);
if (log.length > 200) log.pop();
activeDeliverJobs = 0;
activeInboxJobs = 0;
}
tick();
setInterval(tick, interval);
}
@@ -0,0 +1,85 @@
import si from "systeminformation";
import Xev from "xev";
import * as osUtils from "os-utils";
import { fetchMeta } from "@/misc/fetch-meta.js";
const ev = new Xev();
const interval = 2000;
const roundCpu = (num: number) => Math.round(num * 1000) / 1000;
const round = (num: number) => Math.round(num * 10) / 10;
/**
* Report server stats regularly
*/
export default function () {
const log = [] as any[];
ev.on("requestServerStatsLog", (x) => {
ev.emit(`serverStatsLog:${x.id}`, log.slice(0, x.length || 50));
});
fetchMeta().then((meta) => {
if (!meta.enableServerMachineStats) return;
});
async function tick() {
const cpu = await cpuUsage();
const memStats = await mem();
const netStats = await net();
const fsStats = await fs();
const stats = {
cpu: roundCpu(cpu),
mem: {
used: round(memStats.used - memStats.buffers - memStats.cached),
active: round(memStats.active),
total: round(memStats.total),
},
net: {
rx: round(Math.max(0, netStats.rx_sec)),
tx: round(Math.max(0, netStats.tx_sec)),
},
fs: {
r: round(Math.max(0, fsStats.rIO_sec ?? 0)),
w: round(Math.max(0, fsStats.wIO_sec ?? 0)),
}
};
ev.emit("serverStats", stats);
log.unshift(stats);
if (log.length > 200) log.pop();
}
tick();
setInterval(tick, interval);
}
// CPU STAT
function cpuUsage(): Promise<number> {
return new Promise((res, rej) => {
osUtils.cpuUsage((cpuUsage) => {
res(cpuUsage);
});
});
}
// MEMORY STAT
async function mem() {
const data = await si.mem();
return data;
}
// NETWORK STAT
async function net() {
const iface = await si.networkInterfaceDefault();
const data = await si.networkStats(iface);
return data[0];
}
// FS STAT
async function fs() {
const data = await si.disksIO().catch(() => ({ rIO_sec: 0, wIO_sec: 0 }));
return data || { rIO_sec: 0, wIO_sec: 0 };
}
+3
View File
@@ -0,0 +1,3 @@
import Logger from "@/services/logger.js";
export const dbLogger = new Logger("db");
+308
View File
@@ -0,0 +1,308 @@
// https://github.com/typeorm/typeorm/issues/2400
import pg from "pg";
pg.types.setTypeParser(20, Number);
import type { Logger } from "typeorm";
import { DataSource } from "typeorm";
import * as highlight from "cli-highlight";
import config from "@/config/index.js";
import { User } from "@/models/entities/user.js";
import { DriveFile } from "@/models/entities/drive-file.js";
import { DriveFolder } from "@/models/entities/drive-folder.js";
import { AccessToken } from "@/models/entities/access-token.js";
import { App } from "@/models/entities/app.js";
import { PollVote } from "@/models/entities/poll-vote.js";
import { Note } from "@/models/entities/note.js";
import { NoteReaction } from "@/models/entities/note-reaction.js";
import { NoteWatching } from "@/models/entities/note-watching.js";
import { NoteThreadMuting } from "@/models/entities/note-thread-muting.js";
import { NoteUnread } from "@/models/entities/note-unread.js";
import { Notification } from "@/models/entities/notification.js";
import { Meta } from "@/models/entities/meta.js";
import { Following } from "@/models/entities/following.js";
import { Instance } from "@/models/entities/instance.js";
import { Muting } from "@/models/entities/muting.js";
import { RenoteMuting } from "@/models/entities/renote-muting.js";
import { SwSubscription } from "@/models/entities/sw-subscription.js";
import { Blocking } from "@/models/entities/blocking.js";
import { CallBlocking } from "@/models/entities/call-blocking.js";
import { UserList } from "@/models/entities/user-list.js";
import { UserListJoining } from "@/models/entities/user-list-joining.js";
import { UserGroup } from "@/models/entities/user-group.js";
import { UserGroupJoining } from "@/models/entities/user-group-joining.js";
import { UserGroupInvitation } from "@/models/entities/user-group-invitation.js";
import { Hashtag } from "@/models/entities/hashtag.js";
import { NoteFavorite } from "@/models/entities/note-favorite.js";
import { AbuseUserReport } from "@/models/entities/abuse-user-report.js";
import { RegistrationTicket } from "@/models/entities/registration-tickets.js";
import { MessagingMessage } from "@/models/entities/messaging-message.js";
import { Signin } from "@/models/entities/signin.js";
import { AuthSession } from "@/models/entities/auth-session.js";
import { FollowRequest } from "@/models/entities/follow-request.js";
import { Emoji } from "@/models/entities/emoji.js";
import { UserNotePining } from "@/models/entities/user-note-pining.js";
import { Poll } from "@/models/entities/poll.js";
import { UserKeypair } from "@/models/entities/user-keypair.js";
import { UserPublickey } from "@/models/entities/user-publickey.js";
import { UserProfile } from "@/models/entities/user-profile.js";
import { UserSecurityKey } from "@/models/entities/user-security-key.js";
import { AttestationChallenge } from "@/models/entities/attestation-challenge.js";
import { Page } from "@/models/entities/page.js";
import { PageLike } from "@/models/entities/page-like.js";
import { GalleryPost } from "@/models/entities/gallery-post.js";
import { GalleryLike } from "@/models/entities/gallery-like.js";
import { ModerationLog } from "@/models/entities/moderation-log.js";
import { UsedUsername } from "@/models/entities/used-username.js";
import { Announcement } from "@/models/entities/announcement.js";
import { AnnouncementRead } from "@/models/entities/announcement-read.js";
import { Clip } from "@/models/entities/clip.js";
import { ClipNote } from "@/models/entities/clip-note.js";
import { Antenna } from "@/models/entities/antenna.js";
import { PromoNote } from "@/models/entities/promo-note.js";
import { PromoRead } from "@/models/entities/promo-read.js";
import { Relay } from "@/models/entities/relay.js";
import { Channel } from "@/models/entities/channel.js";
import { ChannelFollowing } from "@/models/entities/channel-following.js";
import { ChannelNotePining } from "@/models/entities/channel-note-pining.js";
import { RegistryItem } from "@/models/entities/registry-item.js";
import { PasswordResetRequest } from "@/models/entities/password-reset-request.js";
import { UserPending } from "@/models/entities/user-pending.js";
import { Webhook } from "@/models/entities/webhook.js";
import { UserIp } from "@/models/entities/user-ip.js";
import { UserEmoji } from "@/models/entities/user-emoji.js";
import { NoteEdit } from "@/models/entities/note-edit.js";
import { entities as charts } from "@/services/chart/entities.js";
import { dbLogger } from "./logger.js";
import { OAuthApp } from "@/models/entities/oauth-app.js";
import { OAuthToken } from "@/models/entities/oauth-token.js";
import { HtmlNoteCacheEntry } from "@/models/entities/html-note-cache-entry.js";
import { HtmlUserCacheEntry } from "@/models/entities/html-user-cache-entry.js";
import { TypeORMLoggingOptions } from "@/config/types.js";
import { Bite } from "@/models/entities/bite.js";
import { InteractionStamp } from "@/models/entities/interaction-stamp.js";
import { ReversiGame } from "@/models/entities/reversi-game.js";
import { ReversiMatching } from "@/models/entities/reversi-matching.js";
import { ShogiGame } from "@/models/entities/shogi-game.js";
import { ScheduledNote } from "@/models/entities/scheduled-note.js";
import { Memoriet } from "@/models/entities/memoriet.js";
import { MemorietArchive } from "@/models/entities/memoriet-archive.js";
import { MemorietView } from "@/models/entities/memoriet-view.js";
import { VerifiedBadgeRequest } from "@/models/entities/verified-badge-request.js";
import { Plan } from "@/models/entities/plan.js";
import { UserPlan } from "@/models/entities/user-plan.js";
const sqlLogger = dbLogger.createSubLogger("sql", "gray", false);
const isLogEnabled = (level: TypeORMLoggingOptions): boolean => {
const logLevel = config.db.logging;
return Array.isArray(logLevel)
? logLevel.includes(level)
: logLevel === level || logLevel?.trim()?.toLowerCase() === "all";
};
const isLoggingEnabled = () => {
const logLevel = config.db.logging;
return Array.isArray(logLevel)
? logLevel.length > 0
: logLevel != null;
}
class MyCustomLogger implements Logger {
private highlight(sql: string) {
return highlight.highlight(sql, {
language: "sql",
ignoreIllegals: true,
});
}
public logQuery(query: string, parameters?: any[]) {
if (isLogEnabled("query"))
sqlLogger.info(this.highlight(query).substring(0, 100));
}
public logQueryError(error: string, query: string, parameters?: any[]) {
if (isLogEnabled("error")) sqlLogger.error(this.highlight(query));
}
public logQuerySlow(time: number, query: string, parameters?: any[]) {
if (isLogEnabled("slow")) sqlLogger.warn(this.highlight(query));
}
public logSchemaBuild(message: string) {
if (isLogEnabled("schema")) sqlLogger.info(message);
}
public log(message: string) {
if (isLogEnabled("log")) sqlLogger.info(message);
}
public logMigration(message: string) {
if (isLogEnabled("info")) sqlLogger.info(message);
}
}
export const entities = [
Announcement,
AnnouncementRead,
Meta,
Instance,
App,
AuthSession,
AccessToken,
User,
UserProfile,
UserKeypair,
UserPublickey,
UserList,
UserListJoining,
UserGroup,
UserGroupJoining,
UserGroupInvitation,
UserNotePining,
UserSecurityKey,
UsedUsername,
AttestationChallenge,
Following,
FollowRequest,
Muting,
RenoteMuting,
Blocking,
CallBlocking,
Note,
NoteEdit,
NoteFavorite,
NoteReaction,
NoteWatching,
NoteThreadMuting,
NoteUnread,
Page,
PageLike,
GalleryPost,
GalleryLike,
DriveFile,
DriveFolder,
Poll,
PollVote,
Notification,
Emoji,
Hashtag,
SwSubscription,
AbuseUserReport,
RegistrationTicket,
MessagingMessage,
Signin,
ModerationLog,
Clip,
ClipNote,
Antenna,
PromoNote,
PromoRead,
Relay,
Channel,
ChannelFollowing,
ChannelNotePining,
RegistryItem,
PasswordResetRequest,
UserPending,
Webhook,
UserIp,
UserEmoji,
OAuthApp,
OAuthToken,
HtmlNoteCacheEntry,
HtmlUserCacheEntry,
Bite,
InteractionStamp,
ReversiGame,
ReversiMatching,
ShogiGame,
ScheduledNote,
Memoriet,
MemorietArchive,
MemorietView,
VerifiedBadgeRequest,
Plan,
UserPlan,
...charts,
];
const log = isLoggingEnabled() || process.env.LOG_SQL === "true";
export const db = new DataSource({
type: "postgres",
host: config.db.host,
port: config.db.port,
username: config.db.user,
password: config.db.pass,
database: config.db.db,
extra: {
statement_timeout: 1000 * 10,
...config.db.extra,
},
synchronize: process.env.NODE_ENV === "test",
dropSchema: process.env.NODE_ENV === "test",
cache: !config.db.disableCache
? {
type: "ioredis",
options: {
host: config.redis.host,
port: config.redis.port,
family: config.redis.family == null ? 0 : config.redis.family,
username: config.redis.user ?? "default",
password: config.redis.pass,
keyPrefix: `${config.redis.prefix}:query:`,
db: config.redis.db || 0,
tls: config.redis.tls,
},
}
: false,
logging: log,
logger: new MyCustomLogger(),
maxQueryExecutionTime: 300,
entities: entities,
migrations: ["../../migration/*.js"],
});
export async function initDb(force = false) {
if (force) {
if (db.isInitialized) {
await db.destroy();
}
await db.initialize();
return;
}
if (db.isInitialized) {
// nop
} else {
await db.initialize();
}
}
export async function resetDb() {
const reset = async () => {
const { redisClient } = await import("./redis.js");
await redisClient.flushdb();
const tables = await db.query(`SELECT relname AS "table"
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 = 'r'
AND nspname !~ '^pg_toast';`);
for (const table of tables) {
await db.query(`DELETE FROM "${table.table}" CASCADE`);
}
};
for (let i = 1; i <= 3; i++) {
try {
await reset();
} catch (e) {
if (i === 3) {
throw e;
} else {
await new Promise((resolve) => setTimeout(resolve, 1000));
continue;
}
}
break;
}
}
+22
View File
@@ -0,0 +1,22 @@
import Redis from "ioredis";
import config from "@/config/index.js";
export function createConnection() {
let source = config.redis;
return new Redis({
port: source.port,
host: source.host,
family: source.family ?? 0,
password: source.pass,
username: source.user ?? "default",
keyPrefix: `${source.prefix}:`,
db: source.db || 0,
tls: source.tls,
});
}
export const subscriber = createConnection();
subscriber.subscribe(config.redis.prefix ?? config.host);
export const redisClient = createConnection();
+25
View File
@@ -0,0 +1,25 @@
const envOption = {
onlyQueue: false,
onlyServer: false,
noDaemons: false,
disableClustering: false,
verbose: false,
withLogTime: false,
quiet: false,
slow: false,
};
for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) {
if (
process.env[
`MK_${key.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase()}`
]
)
envOption[key] = true;
}
if (process.env.NODE_ENV === "test") envOption.disableClustering = true;
if (process.env.NODE_ENV === "test") envOption.quiet = true;
if (process.env.NODE_ENV === "test") envOption.noDaemons = true;
export { envOption };
+2
View File
@@ -0,0 +1,2 @@
// rome-ignore lint/suspicious/noExplicitAny: i have no idea
type FIXME = any;
+24
View File
@@ -0,0 +1,24 @@
/**
* Misskey Entry Point!
*/
import { EventEmitter } from "node:events";
Error.stackTraceLimit = Infinity;
EventEmitter.defaultMaxListeners = 128;
const emitWarning = process.emitWarning;
process.emitWarning = ((warning: string | Error, ...args: any[]) => {
const code =
(warning instanceof Error ? (warning as Error & { code?: string }).code : undefined) ??
(typeof args[0] === "object" ? args[0]?.code : undefined) ??
(typeof args[1] === "string" ? args[1] : undefined);
if (code === "DEP0040" || code === "DEP0060") return;
return emitWarning.call(process, warning as any, ...args);
}) as typeof process.emitWarning;
const { default: boot } = await import("./boot/index.js");
boot().catch((err) => {
console.error(err);
});
+141
View File
@@ -0,0 +1,141 @@
import Router from "@koa/router";
import {
collectDefaultMetrics,
register,
Gauge,
Counter,
CounterConfiguration,
} from "prom-client";
import config from "./config/index.js";
import { queues } from "./queue/queues.js";
import cluster from "node:cluster";
import Xev from "xev";
const xev = new Xev();
if (config.metrics?.enable) {
if (cluster.isPrimary) {
collectDefaultMetrics();
new Gauge({
name: "iceshrimp_queue_jobs",
help: "Amount of jobs in the bull queues",
labelNames: ["queue", "status"] as const,
async collect() {
for (const queue of queues) {
const counts = await queue.getJobCounts();
this.set({ queue: queue.name, status: "completed" }, counts.completed);
this.set({ queue: queue.name, status: "waiting" }, counts.waiting);
this.set({ queue: queue.name, status: "active" }, counts.active);
this.set({ queue: queue.name, status: "delayed" }, counts.delayed);
this.set({ queue: queue.name, status: "failed" }, counts.failed);
}
},
});
}
}
if (cluster.isPrimary) {
xev.on("registry-request", async () => {
try {
const metrics = await register.metrics();
xev.emit("registry-response", {
contentType: register.contentType,
body: metrics
});
} catch (error) {
xev.emit("registry-response", { error });
}
});
}
export const handleMetrics: Router.Middleware = async (ctx) => {
if (config.metrics?.token !== undefined) {
if (ctx.query.token === undefined) {
ctx.res.statusCode = 401;
ctx.body = "Missing token parameter";
return;
}
const correct = config.metrics.token === ctx.query.token;
if (!correct) {
ctx.res.statusCode = 403;
ctx.body = "Incorrect token";
return;
}
}
try {
if (cluster.isPrimary) {
ctx.set("content-type", register.contentType);
ctx.body = await register.metrics();
} else {
const wait = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject("Timeout while waiting for cluster master"),
1000 * 60
);
xev.once("registry-response", (response) => {
clearTimeout(timeout);
if (response.error) reject(response.error);
ctx.set("content-type", response.contentType);
ctx.body = response.body;
resolve();
});
});
xev.emit("registry-request");
await wait;
}
} catch (err) {
ctx.res.statusCode = 500;
ctx.body = err;
}
};
const counter = (configuration: CounterConfiguration<string>) => {
if (config.metrics?.enable) {
if (cluster.isPrimary) {
const counter = new Counter(configuration);
counter.reset(); // initialize internal hashmap
xev.on(`metrics-counter-${configuration.name}`, () => counter.inc());
return () => counter.inc();
} else {
return () => xev.emit(`metrics-counter-${configuration.name}`);
}
} else {
return () => { };
}
};
export const tickOutbox = counter({
name: "iceshrimp_outbox_total",
help: "Total AP outbox calls",
});
export const tickInbox = counter({
name: "iceshrimp_inbox_total",
help: "Total AP inbox calls",
});
export const tickFetch = counter({
name: "iceshrimp_fetch_total",
help: "Total AP fetch calls",
});
export const tickResolve = counter({
name: "iceshrimp_resolve_total",
help: "Total AP resolve calls",
});
export const tickBiteIncoming = counter({
name: "iceshrimp_bite_remote_incoming_total",
help: "Total bites received from remote",
});
export const tickBiteOutgoing = counter({
name: "iceshrimp_bite_remote_outgoing_total",
help: "Total bites sent to remote",
});
export const tickBiteLocal = counter ({
name: "iceshrimp_bite_local_total",
help: "Total local bites"
});
+221
View File
@@ -0,0 +1,221 @@
import * as parse5 from "parse5";
import { defaultTreeAdapter as treeAdapter } from "parse5";
import { getSubjectHostFromUriAndUsernameCached } from "@/remote/resolve-user.js";
import type {
Node as TreeAdapterNode,
ChildNode as TreeAdapterChildNode,
} from "parse5/dist/tree-adapters/default.js";
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
export async function fromHtml(html: string, hashtagNames?: string[]): Promise<string> {
// some AP servers like Pixelfed use br tags as well as newlines
html = html.replace(/<br\s?\/?>\r?\n/gi, "\n");
const dom = parse5.parseFragment(html);
let text = "";
for (const n of dom.childNodes) {
await analyze(n);
}
return text.trim();
function getText(node: TreeAdapterNode): string {
if (treeAdapter.isTextNode(node)) return node.value;
if (!treeAdapter.isElementNode(node)) return "";
if (node.nodeName === "br") return "\n";
if (node.childNodes) {
return node.childNodes.map((n) => getText(n)).join("");
}
return "";
}
async function appendChildren(childNodes: TreeAdapterChildNode[]): Promise<void> {
if (childNodes) {
for (const n of childNodes) {
await analyze(n);
}
}
}
async function analyze(node: TreeAdapterNode) {
if (treeAdapter.isTextNode(node)) {
text += node.value;
return;
}
// Skip comment or document type node
if (!treeAdapter.isElementNode(node)) return;
// Strip quote marker
const classes = node.attrs.find((x) => x.name === "class");
if (classes && classes.value.split(" ").includes("quote-inline")) {
return;
}
switch (node.nodeName) {
case "br": {
text += "\n";
break;
}
case "a": {
const txt = getText(node);
const rel = node.attrs.find((x) => x.name === "rel");
const href = node.attrs.find((x) => x.name === "href");
// ハッシュタグ
if (
hashtagNames &&
href &&
hashtagNames.map((x) => x.toLowerCase()).includes(txt.toLowerCase())
) {
text += txt;
// メンション
} else if (txt.startsWith("@") && !rel?.value.match(/^me /)) {
const part = txt.split("@");
if (part.length === 2 && href) {
//#region ホスト名部分が省略されているので復元する
const acct = `${txt}@${await getSubjectHostFromUriAndUsernameCached(href.value, txt)}`;
text += acct;
//#endregion
} else if (part.length === 3) {
text += txt;
}
// その他
} else {
const generateLink = () => {
if (!(href || txt)) {
return "";
}
if (!href) {
return txt;
}
if (!txt || txt === href.value) {
// #6383: Missing text node
if (href.value.match(urlRegexFull)) {
return href.value;
} else {
return `<${href.value}>`;
}
}
if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) {
return `[${txt}](<${href.value}>)`; // #6846
} else {
return `[${txt}](${href.value})`;
}
};
text += generateLink();
}
break;
}
case "h1": {
text += "【";
await appendChildren(node.childNodes);
text += "】\n";
break;
}
case "b":
case "strong": {
text += "**";
await appendChildren(node.childNodes);
text += "**";
break;
}
case "small": {
text += "<small>";
await appendChildren(node.childNodes);
text += "</small>";
break;
}
case "s":
case "del": {
text += "~~";
await appendChildren(node.childNodes);
text += "~~";
break;
}
case "i":
case "em": {
text += "<i>";
await appendChildren(node.childNodes);
text += "</i>";
break;
}
// block code (<pre><code>)
case "pre": {
if (
node.childNodes.length === 1 &&
node.childNodes[0].nodeName === "code"
) {
text += "\n```\n";
text += getText(node.childNodes[0]);
text += "\n```\n";
} else {
await appendChildren(node.childNodes);
}
break;
}
// inline code (<code>)
case "code": {
text += "`";
await appendChildren(node.childNodes);
text += "`";
break;
}
case "blockquote": {
const t = getText(node);
if (t) {
text += "\n> ";
text += t.split("\n").join("\n> ");
}
break;
}
case "p":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6": {
text += "\n\n";
await appendChildren(node.childNodes);
break;
}
// other block elements
case "div":
case "header":
case "footer":
case "article":
case "li":
case "dt":
case "dd": {
text += "\n";
await appendChildren(node.childNodes);
break;
}
default: {
// includes inline elements
await appendChildren(node.childNodes);
break;
}
}
}
}
+205
View File
@@ -0,0 +1,205 @@
import { Window as HappyDom } from "happy-dom";
import type * as mfm from "mfm-js";
import config from "@/config/index.js";
import { intersperse } from "@/prelude/array.js";
import type { IMentionedRemoteUsers } from "@/models/entities/note.js";
import { resolveMentionFromCache } from "@/remote/resolve-user.js";
export async function toHtml(
nodes: mfm.MfmNode[] | null,
mentionedRemoteUsers: IMentionedRemoteUsers = [],
objectHost: string | null
) {
if (nodes == null) {
return null;
}
const window = new HappyDom();
const doc = window.document;
function appendTextWithGlyphs(text: string, targetElement: Element): void {
const regexp = /;([^:;\s]{1,100});/g;
let last = 0;
for (const match of text.matchAll(regexp)) {
if (match.index! > last) {
targetElement.appendChild(doc.createTextNode(text.slice(last, match.index)));
}
targetElement.appendChild(doc.createTextNode(`\u200B:${match[1]}:\u200B`));
last = match.index! + match[0].length;
}
if (last < text.length) {
targetElement.appendChild(doc.createTextNode(text.slice(last)));
}
}
async function appendChildren(children: mfm.MfmNode[], targetElement: any): Promise<void> {
if (children) {
for (const child of await Promise.all(children.map(async (x) => await (handlers as any)[x.type](x))))
targetElement.appendChild(child);
}
}
const handlers: {
[K in mfm.MfmNode["type"]]: (node: mfm.NodeType<K>) => any;
} = {
async bold(node) {
const el = doc.createElement("b");
await appendChildren(node.children, el);
return el;
},
async small(node) {
const el = doc.createElement("small");
await appendChildren(node.children, el);
return el;
},
async strike(node) {
const el = doc.createElement("del");
await appendChildren(node.children, el);
return el;
},
async italic(node) {
const el = doc.createElement("i");
await appendChildren(node.children, el);
return el;
},
async fn(node) {
const el = doc.createElement("i");
await appendChildren(node.children, el);
return el;
},
blockCode(node) {
const pre = doc.createElement("pre");
const inner = doc.createElement("code");
inner.textContent = node.props.code;
pre.appendChild(inner);
return pre;
},
async center(node) {
const el = doc.createElement("div");
await appendChildren(node.children, el);
return el;
},
emojiCode(node) {
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
},
unicodeEmoji(node) {
return doc.createTextNode(node.props.emoji);
},
hashtag(node) {
const a = doc.createElement("a");
a.setAttribute('href', `${config.url}/tags/${node.props.hashtag}`);
a.textContent = `#${node.props.hashtag}`;
a.setAttribute("rel", "tag");
return a;
},
inlineCode(node) {
const el = doc.createElement("code");
el.textContent = node.props.code;
return el;
},
mathInline(node) {
const el = doc.createElement("code");
el.textContent = node.props.formula;
return el;
},
mathBlock(node) {
const el = doc.createElement("code");
el.textContent = node.props.formula;
return el;
},
async link(node) {
const a = doc.createElement("a");
a.setAttribute('href', node.props.url);
await appendChildren(node.children, a);
return a;
},
async mention(node) {
const { username, host, acct } = node.props;
const resolved = await resolveMentionFromCache(username, host, objectHost, mentionedRemoteUsers);
const el = doc.createElement("span");
if (resolved === null) {
el.textContent = acct;
} else {
el.setAttribute("class", "h-card");
el.setAttribute("translate", "no");
const a = doc.createElement("a");
a.setAttribute('href', resolved.href);
a.className = "u-url mention";
const span = doc.createElement("span");
span.textContent = resolved.username;
a.textContent = '@';
a.appendChild(span);
el.appendChild(a);
}
return el;
},
async quote(node) {
const el = doc.createElement("blockquote");
await appendChildren(node.children, el);
return el;
},
text(node) {
const el = doc.createElement("span");
const lines = node.props.text.split(/\r\n|\r|\n/);
for (const x of intersperse<FIXME | string>("br", lines)) {
if (x === "br") {
el.appendChild(doc.createElement("br"));
continue;
}
appendTextWithGlyphs(x, el);
}
return el;
},
url(node) {
const a = doc.createElement("a");
a.setAttribute('href', node.props.url);
a.textContent = node.props.url.replace(/^https?:\/\//, '');
return a;
},
search(node) {
const a = doc.createElement("a");
a.setAttribute('href', `${config.searchEngine}${node.props.query}`);
a.textContent = node.props.content;
return a;
},
async plain(node) {
const el = doc.createElement("span");
await appendChildren(node.children, el);
return el;
},
};
await appendChildren(nodes, doc.body);
const html = `<p>${doc.body.innerHTML}</p>`;
await window.happyDOM.close();
return html;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Pages1556348509290 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "page_visibility_enum" AS ENUM('public', 'followers', 'specified')`,
);
await queryRunner.query(
`CREATE TABLE "page" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "title" character varying(256) NOT NULL, "name" character varying(256) NOT NULL, "summary" character varying(256), "alignCenter" boolean NOT NULL, "font" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "eyeCatchingImageId" character varying(32), "content" jsonb NOT NULL DEFAULT '[]', "variables" jsonb NOT NULL DEFAULT '[]', "visibility" "page_visibility_enum" NOT NULL, "visibleUserIds" character varying(32) array NOT NULL DEFAULT '{}'::varchar[], CONSTRAINT "PK_742f4117e065c5b6ad21b37ba1f" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_fbb4297c927a9b85e9cefa2eb1" ON "page" ("createdAt") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_af639b066dfbca78b01a920f8a" ON "page" ("updatedAt") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_b82c19c08afb292de4600d99e4" ON "page" ("name") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_ae1d917992dd0c9d9bbdad06c4" ON "page" ("userId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_90148bbc2bf0854428786bfc15" ON "page" ("visibleUserIds") `,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_2133ef8317e4bdb839c0dcbf13" ON "page" ("userId", "name") `,
);
await queryRunner.query(
`ALTER TABLE "page" ADD CONSTRAINT "FK_ae1d917992dd0c9d9bbdad06c4a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "page" ADD CONSTRAINT "FK_3126dd7c502c9e4d7597ef7ef10" FOREIGN KEY ("eyeCatchingImageId") REFERENCES "drive_file"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "page" DROP CONSTRAINT "FK_3126dd7c502c9e4d7597ef7ef10"`,
);
await queryRunner.query(
`ALTER TABLE "page" DROP CONSTRAINT "FK_ae1d917992dd0c9d9bbdad06c4a"`,
);
await queryRunner.query(`DROP INDEX "IDX_2133ef8317e4bdb839c0dcbf13"`);
await queryRunner.query(`DROP INDEX "IDX_90148bbc2bf0854428786bfc15"`);
await queryRunner.query(`DROP INDEX "IDX_ae1d917992dd0c9d9bbdad06c4"`);
await queryRunner.query(`DROP INDEX "IDX_b82c19c08afb292de4600d99e4"`);
await queryRunner.query(`DROP INDEX "IDX_af639b066dfbca78b01a920f8a"`);
await queryRunner.query(`DROP INDEX "IDX_fbb4297c927a9b85e9cefa2eb1"`);
await queryRunner.query(`DROP TABLE "page"`);
await queryRunner.query(`DROP TYPE "page_visibility_enum"`);
}
}
@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UserProfile1556746559567 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_profile" ALTER COLUMN "githubId" TYPE VARCHAR(64) USING "githubId"::VARCHAR(64)`,
);
await queryRunner.query(
`ALTER TABLE "user_profile" ALTER COLUMN "discordExpiresDate" TYPE VARCHAR(64) USING "discordExpiresDate"::VARCHAR(64)`,
);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`UPDATE "user_profile" SET github = FALSE, discord = FALSE`,
);
await queryRunner.query(
`ALTER TABLE "user_profile" ALTER COLUMN "githubId" TYPE INTEGER USING NULL`,
);
await queryRunner.query(
`ALTER TABLE "user_profile" ALTER COLUMN "discordExpiresDate" TYPE INTEGER USING NULL`,
);
}
}
@@ -0,0 +1,11 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class PinnedUsers1557476068003 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "meta" ADD "pinnedUsers" character varying(256) array NOT NULL DEFAULT '{}'::varchar[]`,
);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "pinnedUsers"`);
}
}
@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddSomeUrls1557761316509 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "meta" ADD "ToSUrl" character varying(512)`,
);
await queryRunner.query(
`ALTER TABLE "meta" ADD "repositoryUrl" character varying(512) NOT NULL DEFAULT 'https://codeberg.org/firefish/firefish'`,
);
await queryRunner.query(
`ALTER TABLE "meta" ADD "feedbackUrl" character varying(512) DEFAULT 'https://codeberg.org/firefish/firefish/issues'`,
);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "feedbackUrl"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "repositoryUrl"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "ToSUrl"`);
}
}
@@ -0,0 +1,67 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class ObjectStorageSetting1557932705754 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "meta" ADD "useObjectStorage" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "meta" ADD "objectStorageBucket" character varying(512)`,
);
await queryRunner.query(
`ALTER TABLE "meta" ADD "objectStoragePrefix" character varying(512)`,
);
await queryRunner.query(
`ALTER TABLE "meta" ADD "objectStorageBaseUrl" character varying(512)`,
);
await queryRunner.query(
`ALTER TABLE "meta" ADD "objectStorageEndpoint" character varying(512)`,
);
await queryRunner.query(
`ALTER TABLE "meta" ADD "objectStorageRegion" character varying(512)`,
);
await queryRunner.query(
`ALTER TABLE "meta" ADD "objectStorageAccessKey" character varying(512)`,
);
await queryRunner.query(
`ALTER TABLE "meta" ADD "objectStorageSecretKey" character varying(512)`,
);
await queryRunner.query(
`ALTER TABLE "meta" ADD "objectStoragePort" integer`,
);
await queryRunner.query(
`ALTER TABLE "meta" ADD "objectStorageUseSSL" boolean NOT NULL DEFAULT true`,
);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "meta" DROP COLUMN "objectStorageUseSSL"`,
);
await queryRunner.query(
`ALTER TABLE "meta" DROP COLUMN "objectStoragePort"`,
);
await queryRunner.query(
`ALTER TABLE "meta" DROP COLUMN "objectStorageSecretKey"`,
);
await queryRunner.query(
`ALTER TABLE "meta" DROP COLUMN "objectStorageAccessKey"`,
);
await queryRunner.query(
`ALTER TABLE "meta" DROP COLUMN "objectStorageRegion"`,
);
await queryRunner.query(
`ALTER TABLE "meta" DROP COLUMN "objectStorageEndpoint"`,
);
await queryRunner.query(
`ALTER TABLE "meta" DROP COLUMN "objectStorageBaseUrl"`,
);
await queryRunner.query(
`ALTER TABLE "meta" DROP COLUMN "objectStoragePrefix"`,
);
await queryRunner.query(
`ALTER TABLE "meta" DROP COLUMN "objectStorageBucket"`,
);
await queryRunner.query(
`ALTER TABLE "meta" DROP COLUMN "useObjectStorage"`,
);
}
}
@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class PageLike1558072954435 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "page_like" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "pageId" character varying(32) NOT NULL, CONSTRAINT "PK_813f034843af992d3ae0f43c64c" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_0e61efab7f88dbb79c9166dbb4" ON "page_like" ("userId") `,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_4ce6fb9c70529b4c8ac46c9bfa" ON "page_like" ("userId", "pageId") `,
);
await queryRunner.query(
`ALTER TABLE "page" ADD "likedCount" integer NOT NULL DEFAULT 0`,
);
await queryRunner.query(
`ALTER TABLE "page_like" ADD CONSTRAINT "FK_0e61efab7f88dbb79c9166dbb48" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "page_like" ADD CONSTRAINT "FK_cf8782626dced3176038176a847" FOREIGN KEY ("pageId") REFERENCES "page"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "page_like" DROP CONSTRAINT "FK_cf8782626dced3176038176a847"`,
);
await queryRunner.query(
`ALTER TABLE "page_like" DROP CONSTRAINT "FK_0e61efab7f88dbb79c9166dbb48"`,
);
await queryRunner.query(`ALTER TABLE "page" DROP COLUMN "likedCount"`);
await queryRunner.query(`DROP INDEX "IDX_4ce6fb9c70529b4c8ac46c9bfa"`);
await queryRunner.query(`DROP INDEX "IDX_0e61efab7f88dbb79c9166dbb4"`);
await queryRunner.query(`DROP TABLE "page_like"`);
}
}
@@ -0,0 +1,83 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UserGroup1558103093633 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "user_group" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(256) NOT NULL, "userId" character varying(32) NOT NULL, "isPrivate" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_3c29fba6fe013ec8724378ce7c9" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_20e30aa35180e317e133d75316" ON "user_group" ("createdAt") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_3d6b372788ab01be58853003c9" ON "user_group" ("userId") `,
);
await queryRunner.query(
`CREATE TABLE "user_group_joining" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "userGroupId" character varying(32) NOT NULL, CONSTRAINT "PK_15f2425885253c5507e1599cfe7" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_f3a1b4bd0c7cabba958a0c0b23" ON "user_group_joining" ("userId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_67dc758bc0566985d1b3d39986" ON "user_group_joining" ("userGroupId") `,
);
await queryRunner.query(
`ALTER TABLE "messaging_message" ADD "groupId" character varying(32)`,
);
await queryRunner.query(
`ALTER TABLE "messaging_message" ADD "reads" character varying(32) array NOT NULL DEFAULT '{}'::varchar[]`,
);
await queryRunner.query(
`ALTER TABLE "messaging_message" ALTER COLUMN "recipientId" DROP NOT NULL`,
);
await queryRunner.query(
`COMMENT ON COLUMN "messaging_message"."recipientId" IS 'The recipient user ID.'`,
);
await queryRunner.query(
`CREATE INDEX "IDX_2c4be03b446884f9e9c502135b" ON "messaging_message" ("groupId") `,
);
await queryRunner.query(
`ALTER TABLE "messaging_message" ADD CONSTRAINT "FK_2c4be03b446884f9e9c502135be" FOREIGN KEY ("groupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "user_group" ADD CONSTRAINT "FK_3d6b372788ab01be58853003c93" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "user_group_joining" ADD CONSTRAINT "FK_f3a1b4bd0c7cabba958a0c0b231" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "user_group_joining" ADD CONSTRAINT "FK_67dc758bc0566985d1b3d399865" FOREIGN KEY ("userGroupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_group_joining" DROP CONSTRAINT "FK_67dc758bc0566985d1b3d399865"`,
);
await queryRunner.query(
`ALTER TABLE "user_group_joining" DROP CONSTRAINT "FK_f3a1b4bd0c7cabba958a0c0b231"`,
);
await queryRunner.query(
`ALTER TABLE "user_group" DROP CONSTRAINT "FK_3d6b372788ab01be58853003c93"`,
);
await queryRunner.query(
`ALTER TABLE "messaging_message" DROP CONSTRAINT "FK_2c4be03b446884f9e9c502135be"`,
);
await queryRunner.query(`DROP INDEX "IDX_2c4be03b446884f9e9c502135b"`);
await queryRunner.query(
`COMMENT ON COLUMN "messaging_message"."recipientId" IS ''`,
);
await queryRunner.query(
`ALTER TABLE "messaging_message" ALTER COLUMN "recipientId" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "messaging_message" DROP COLUMN "reads"`,
);
await queryRunner.query(
`ALTER TABLE "messaging_message" DROP COLUMN "groupId"`,
);
await queryRunner.query(`DROP INDEX "IDX_67dc758bc0566985d1b3d39986"`);
await queryRunner.query(`DROP INDEX "IDX_f3a1b4bd0c7cabba958a0c0b23"`);
await queryRunner.query(`DROP TABLE "user_group_joining"`);
await queryRunner.query(`DROP INDEX "IDX_3d6b372788ab01be58853003c9"`);
await queryRunner.query(`DROP INDEX "IDX_20e30aa35180e317e133d75316"`);
await queryRunner.query(`DROP TABLE "user_group"`);
}
}
@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UserGroupInvite1558257926829 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "user_group_invite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "userGroupId" character varying(32) NOT NULL, CONSTRAINT "PK_3893884af0d3a5f4d01e7921a97" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_1039988afa3bf991185b277fe0" ON "user_group_invite" ("userId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_e10924607d058004304611a436" ON "user_group_invite" ("userGroupId") `,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_78787741f9010886796f2320a4" ON "user_group_invite" ("userId", "userGroupId") `,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_d9ecaed8c6dc43f3592c229282" ON "user_group_joining" ("userId", "userGroupId") `,
);
await queryRunner.query(
`ALTER TABLE "user_group_invite" ADD CONSTRAINT "FK_1039988afa3bf991185b277fe03" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "user_group_invite" ADD CONSTRAINT "FK_e10924607d058004304611a436a" FOREIGN KEY ("userGroupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_group_invite" DROP CONSTRAINT "FK_e10924607d058004304611a436a"`,
);
await queryRunner.query(
`ALTER TABLE "user_group_invite" DROP CONSTRAINT "FK_1039988afa3bf991185b277fe03"`,
);
await queryRunner.query(`DROP INDEX "IDX_d9ecaed8c6dc43f3592c229282"`);
await queryRunner.query(`DROP INDEX "IDX_78787741f9010886796f2320a4"`);
await queryRunner.query(`DROP INDEX "IDX_e10924607d058004304611a436"`);
await queryRunner.query(`DROP INDEX "IDX_1039988afa3bf991185b277fe0"`);
await queryRunner.query(`DROP TABLE "user_group_invite"`);
}
}
@@ -0,0 +1,11 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UserListJoining1558266512381 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_90f7da835e4c10aca6853621e1" ON "user_list_joining" ("userId", "userListId") `,
);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_90f7da835e4c10aca6853621e1"`);
}
}

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