1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-22 22:14:06 +02:00

Merge branch 'develop' into minify-backend

This commit is contained in:
syuilo
2025-12-24 13:01:39 +09:00
240 changed files with 6225 additions and 4735 deletions

View File

@@ -3,12 +3,17 @@
"jsc": {
"parser": {
"syntax": "typescript",
"jsx": true,
"dynamicImport": true,
"decorators": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
"decoratorMetadata": true,
"react": {
"runtime": "automatic",
"importSource": "@kitajs/html"
}
},
"experimental": {
"keepImportAssertions": true

View File

@@ -0,0 +1,46 @@
(async () => {
const msg = document.getElementById('msg');
const successText = `\nSuccess Flush! <a href="/">Back to Misskey</a>\n成功しました。<a href="/">Misskeyを開き直してください。</a>`;
if (!document.cookie) {
message('Your site data is fully cleared by your browser.');
message(successText);
} else {
message('Your browser does not support Clear-Site-Data header. Start opportunistic flushing.');
try {
localStorage.clear();
message('localStorage cleared.');
const idbPromises = ['MisskeyClient', 'keyval-store'].map((name, i, arr) => new Promise((res, rej) => {
const delidb = indexedDB.deleteDatabase(name);
delidb.onsuccess = () => res(message(`indexedDB "${name}" cleared. (${i + 1}/${arr.length})`));
delidb.onerror = e => rej(e)
}));
await Promise.all(idbPromises);
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage('clear');
await navigator.serviceWorker.getRegistrations()
.then(registrations => {
return Promise.all(registrations.map(registration => registration.unregister()));
})
.catch(e => { throw new Error(e) });
}
message(successText);
} catch (e) {
message(`\n${e}\n\nFlush Failed. <a href="/flush">Please retry.</a>\n失敗しました。<a href="/flush">もう一度試してみてください。</a>`);
message(`\nIf you retry more than 3 times, try manually clearing the browser cache or contact to instance admin.\n3回以上試しても失敗する場合、ブラウザのキャッシュを手動で消去し、それでもだめならインスタンス管理者に連絡してみてください。\n`)
console.error(e);
setTimeout(() => {
location = '/';
}, 10000)
}
}
function message(text) {
msg.insertAdjacentHTML('beforeend', `<p>[${(new Date()).toString()}] ${text.replace(/\n/g,'<br>')}</p>`)
}
})();

View File

@@ -0,0 +1,35 @@
html,
body {
margin: 0;
padding: 0;
min-height: 100vh;
background: #fff;
}
#a {
display: block;
}
#banner {
background-size: cover;
background-position: center center;
}
#title {
display: inline-block;
margin: 24px;
padding: 0.5em 0.8em;
color: #fff;
background: rgba(0, 0, 0, 0.5);
font-weight: bold;
font-size: 1.3em;
}
#content {
overflow: auto;
color: #353c3e;
}
#description {
margin: 24px;
}

View File

@@ -205,7 +205,7 @@ module.exports = {
// Whether to use watchman for file crawling
// watchman: true,
extensionsToTreatAsEsm: ['.ts'],
extensionsToTreatAsEsm: ['.ts', '.tsx'],
testTimeout: 60000,

View File

@@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"allowSyntheticDefaultImports": true
},
"exclude": [
"node_modules",
"jspm_packages",
"tmp",
"temp"
]
}

View File

@@ -3,14 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { isConcurrentIndexMigrationEnabled } from "./js/migration-config.js";
const isConcurrentIndexMigrationEnabled = process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';
export class CompositeNoteIndex1745378064470 {
name = 'CompositeNoteIndex1745378064470';
transaction = isConcurrentIndexMigrationEnabled() ? false : undefined;
transaction = isConcurrentIndexMigrationEnabled ? false : undefined;
async up(queryRunner) {
const concurrently = isConcurrentIndexMigrationEnabled();
const concurrently = isConcurrentIndexMigrationEnabled;
if (concurrently) {
const hasValidIndex = await queryRunner.query(`SELECT indisvalid FROM pg_index INNER JOIN pg_class ON pg_index.indexrelid = pg_class.oid WHERE pg_class.relname = 'IDX_724b311e6f883751f261ebe378'`);
@@ -29,7 +29,7 @@ export class CompositeNoteIndex1745378064470 {
}
async down(queryRunner) {
const mayConcurrently = isConcurrentIndexMigrationEnabled() ? 'CONCURRENTLY' : '';
const mayConcurrently = isConcurrentIndexMigrationEnabled ? 'CONCURRENTLY' : '';
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`);
await queryRunner.query(`CREATE INDEX ${mayConcurrently} "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`);
}

View File

@@ -3,17 +3,15 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {loadConfig} from "./js/migration-config.js";
export class MigrateSomeConfigFileSettingsToMeta1746949539915 {
name = 'MigrateSomeConfigFileSettingsToMeta1746949539915'
async up(queryRunner) {
const config = loadConfig();
// $1 cannot be used in ALTER TABLE queries
await queryRunner.query(`ALTER TABLE "meta" ADD "proxyRemoteFiles" boolean NOT NULL DEFAULT ${config.proxyRemoteFiles}`);
await queryRunner.query(`ALTER TABLE "meta" ADD "signToActivityPubGet" boolean NOT NULL DEFAULT ${config.signToActivityPubGet}`);
await queryRunner.query(`ALTER TABLE "meta" ADD "allowExternalApRedirect" boolean NOT NULL DEFAULT ${!config.disallowExternalApRedirect}`);
await queryRunner.query(`ALTER TABLE "meta" ADD "proxyRemoteFiles" boolean NOT NULL DEFAULT TRUE`);
await queryRunner.query(`ALTER TABLE "meta" ADD "signToActivityPubGet" boolean NOT NULL DEFAULT TRUE`);
await queryRunner.query(`ALTER TABLE "meta" ADD "allowExternalApRedirect" boolean NOT NULL DEFAULT TRUE`);
}
async down(queryRunner) {

View File

@@ -1,31 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { path as configYamlPath } from '../../built/config.js';
import * as yaml from 'js-yaml';
import fs from "node:fs";
export function isConcurrentIndexMigrationEnabled() {
return process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';
}
let loadedConfigCache = undefined;
function loadConfigInternal() {
const config = yaml.load(fs.readFileSync(configYamlPath, 'utf-8'));
return {
disallowExternalApRedirect: Boolean(config.disallowExternalApRedirect ?? false),
proxyRemoteFiles: Boolean(config.proxyRemoteFiles ?? false),
signToActivityPubGet: Boolean(config.signToActivityPubGet ?? true),
}
}
export function loadConfig() {
if (loadedConfigCache === undefined) {
loadedConfigCache = loadConfigInternal();
}
return loadedConfigCache;
}

View File

@@ -1,7 +1,8 @@
import { DataSource } from 'typeorm';
import { loadConfig } from './built/config.js';
import { entities } from './built/postgres.js';
import { isConcurrentIndexMigrationEnabled } from "./migration/js/migration-config.js";
const isConcurrentIndexMigrationEnabled = process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';
const config = loadConfig();
@@ -15,5 +16,5 @@ export default new DataSource({
extra: config.db.extra,
entities: entities,
migrations: ['migration/*.js'],
migrationsTransactionMode: isConcurrentIndexMigrationEnabled() ? 'each' : 'all',
migrationsTransactionMode: isConcurrentIndexMigrationEnabled ? 'each' : 'all',
});

View File

@@ -7,36 +7,37 @@
"node": "^22.15.0 || ^24.10.0"
},
"scripts": {
"start": "node ./built/boot/entry.js",
"start:inspect": "node --inspect ./built/boot/entry.js",
"start:test": "cross-env NODE_ENV=test node ./built/boot/entry.js",
"migrate": "pnpm typeorm migration:run -d ormconfig.js",
"revert": "pnpm typeorm migration:revert -d ormconfig.js",
"cli": "node ./built/boot/cli.js",
"check:connect": "node ./scripts/check_connect.js",
"start": "pnpm compile-config && node ./built/boot/entry.js",
"start:inspect": "pnpm compile-config && node --inspect ./built/boot/entry.js",
"start:test": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./built/boot/entry.js",
"migrate": "pnpm compile-config && pnpm typeorm migration:run -d ormconfig.js",
"revert": "pnpm compile-config && pnpm typeorm migration:revert -d ormconfig.js",
"cli": "pnpm compile-config && node ./built/boot/cli.js",
"check:connect": "pnpm compile-config && node ./scripts/check_connect.js",
"compile-config": "node ./scripts/compile_config.js",
"build": "node ./build.js",
"build:test": "swc test-server -d built-test -D --config-file test-server/.swcrc --strip-leading-paths",
"watch:swc": "swc src -d built -D -w --strip-leading-paths",
"build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
"watch": "node ./scripts/watch.mjs",
"build:tsc": "tsgo -p tsconfig.json && tsc-alias -p tsconfig.json",
"watch": "pnpm compile-config && node ./scripts/watch.mjs",
"restart": "pnpm build && pnpm start",
"dev": "node ./scripts/dev.mjs",
"typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit",
"dev": "pnpm compile-config && node ./scripts/dev.mjs",
"typecheck": "tsgo --noEmit && tsgo -p test --noEmit && tsgo -p test-federation --noEmit",
"eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"",
"lint": "pnpm typecheck && pnpm eslint",
"jest": "cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.unit.cjs",
"jest:e2e": "cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.e2e.cjs",
"jest:fed": "node ./jest.js --forceExit --config jest.config.fed.cjs",
"jest-and-coverage": "cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.unit.cjs",
"jest-and-coverage:e2e": "cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.e2e.cjs",
"jest-clear": "cross-env NODE_ENV=test node ./jest.js --clearCache",
"jest": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.unit.cjs",
"jest:e2e": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.e2e.cjs",
"jest:fed": "pnpm compile-config && node ./jest.js --forceExit --config jest.config.fed.cjs",
"jest-and-coverage": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.unit.cjs",
"jest-and-coverage:e2e": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.e2e.cjs",
"jest-clear": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --clearCache",
"test": "pnpm jest",
"test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e",
"test:fed": "pnpm jest:fed",
"test-and-coverage": "pnpm jest-and-coverage",
"test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e",
"check-migrations": "node scripts/check_migrations_clean.js",
"generate-api-json": "node ./scripts/generate_api_json.js"
"generate-api-json": "pnpm compile-config && node ./scripts/generate_api_json.js"
},
"optionalDependencies": {
"@swc/core-android-arm64": "1.3.11",
@@ -70,26 +71,25 @@
"utf-8-validate": "6.0.5"
},
"dependencies": {
"@aws-sdk/client-s3": "3.937.0",
"@aws-sdk/lib-storage": "3.937.0",
"@aws-sdk/client-s3": "3.948.0",
"@aws-sdk/lib-storage": "3.948.0",
"@discordapp/twemoji": "16.0.1",
"@fastify/accepts": "5.0.3",
"@fastify/cookie": "11.0.2",
"@fastify/cors": "11.1.0",
"@fastify/accepts": "5.0.4",
"@fastify/cors": "11.2.0",
"@fastify/express": "4.0.2",
"@fastify/http-proxy": "11.3.0",
"@fastify/http-proxy": "11.4.1",
"@fastify/multipart": "9.3.0",
"@fastify/static": "8.3.0",
"@fastify/view": "11.1.1",
"@kitajs/html": "4.2.11",
"@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/summaly": "5.2.5",
"@napi-rs/canvas": "0.1.82",
"@napi-rs/canvas": "0.1.84",
"@nestjs/common": "11.1.9",
"@nestjs/core": "11.1.9",
"@nestjs/testing": "11.1.9",
"@peertube/http-signature": "1.7.0",
"@sentry/node": "10.26.0",
"@sentry/profiling-node": "10.26.0",
"@sentry/node": "10.29.0",
"@sentry/profiling-node": "10.29.0",
"@simplewebauthn/server": "13.2.2",
"@sinonjs/fake-timers": "15.0.0",
"@smithy/node-http-handler": "4.4.5",
@@ -103,13 +103,12 @@
"async-mutex": "0.5.0",
"bcryptjs": "3.0.3",
"blurhash": "2.0.5",
"body-parser": "2.2.0",
"bullmq": "5.64.1",
"body-parser": "2.2.1",
"bullmq": "5.65.1",
"cacheable-lookup": "7.0.0",
"cbor": "10.0.11",
"chalk": "5.6.2",
"chalk-template": "1.1.2",
"chokidar": "4.0.3",
"chokidar": "5.0.0",
"color-convert": "3.1.3",
"content-disposition": "1.0.1",
"date-fns": "4.1.0",
@@ -120,21 +119,19 @@
"file-type": "21.1.1",
"fluent-ffmpeg": "2.1.3",
"form-data": "4.0.5",
"got": "14.6.4",
"got": "14.6.5",
"hpagent": "1.2.0",
"http-link-header": "1.1.3",
"i18n": "workspace:*",
"ioredis": "5.8.2",
"ip-cidr": "4.0.2",
"ipaddr.js": "2.2.0",
"ipaddr.js": "2.3.0",
"is-svg": "6.1.0",
"js-yaml": "4.1.1",
"json5": "2.2.3",
"jsonld": "9.0.0",
"jsrsasign": "11.1.0",
"juice": "11.0.3",
"meilisearch": "0.54.0",
"mfm-js": "0.25.0",
"microformats-parser": "2.0.4",
"mime-types": "3.0.2",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
@@ -143,18 +140,16 @@
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
"node-html-parser": "7.0.1",
"nodemailer": "7.0.10",
"nodemailer": "7.0.11",
"nsfwjs": "4.2.0",
"oauth": "0.10.2",
"oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2",
"os-utils": "0.0.14",
"otpauth": "9.4.1",
"pg": "8.16.3",
"pkce-challenge": "5.0.0",
"pkce-challenge": "5.0.1",
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
"pug": "3.0.3",
"qrcode": "1.5.4",
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
@@ -171,14 +166,12 @@
"slacc": "0.0.10",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"systeminformation": "5.27.11",
"systeminformation": "5.27.14",
"tinycolor2": "1.6.0",
"tmp": "0.2.5",
"tsc-alias": "1.8.16",
"tsconfig-paths": "4.2.0",
"typeorm": "0.3.27",
"typescript": "5.9.3",
"ulid": "3.0.1",
"typeorm": "0.3.28",
"ulid": "3.0.2",
"vary": "1.1.2",
"web-push": "3.6.7",
"ws": "8.18.3",
@@ -186,8 +179,9 @@
},
"devDependencies": {
"@jest/globals": "29.7.0",
"@kitajs/ts-html-plugin": "4.1.3",
"@nestjs/platform-express": "11.1.9",
"@sentry/vue": "10.26.0",
"@sentry/vue": "10.29.0",
"@simplewebauthn/types": "12.0.0",
"@swc/jest": "0.2.39",
"@types/accepts": "1.3.7",
@@ -198,25 +192,21 @@
"@types/fluent-ffmpeg": "2.1.28",
"@types/http-link-header": "1.0.7",
"@types/jest": "29.5.14",
"@types/js-yaml": "4.0.9",
"@types/jsonld": "1.5.15",
"@types/jsrsasign": "10.5.15",
"@types/mime-types": "3.0.1",
"@types/ms": "2.1.0",
"@types/node": "24.10.1",
"@types/node": "24.10.2",
"@types/nodemailer": "7.0.4",
"@types/oauth": "0.9.6",
"@types/oauth2orize": "1.11.5",
"@types/oauth2orize-pkce": "0.1.2",
"@types/pg": "8.15.6",
"@types/pug": "2.0.10",
"@types/qrcode": "1.5.6",
"@types/random-seed": "0.3.5",
"@types/ratelimiter": "3.4.6",
"@types/rename": "1.0.7",
"@types/sanitize-html": "2.16.0",
"@types/semver": "7.7.1",
"@types/simple-oauth2": "5.0.7",
"@types/simple-oauth2": "5.0.8",
"@types/sinonjs__fake-timers": "15.0.1",
"@types/supertest": "6.0.3",
"@types/tinycolor2": "1.4.6",
@@ -224,19 +214,21 @@
"@types/vary": "1.1.3",
"@types/web-push": "3.6.4",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.47.0",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"aws-sdk-client-mock": "4.1.0",
"cbor": "10.0.11",
"cross-env": "10.1.0",
"eslint-plugin-import": "2.32.0",
"execa": "9.6.0",
"execa": "9.6.1",
"fkill": "10.0.1",
"jest": "29.7.0",
"jest-mock": "29.7.0",
"jest-util": "29.7.0",
"js-yaml": "4.1.1",
"nodemon": "3.1.11",
"pid-port": "2.0.0",
"simple-oauth2": "5.1.0",
"supertest": "7.1.4"
"supertest": "7.1.4",
"vite": "7.2.7"
}
}

View File

@@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* YAMLファイルをJSONファイルに変換するスクリプト
* ビルド前に実行し、ランタイムにjs-yamlを含まないようにする
*/
import fs from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const configDir = resolve(_dirname, '../../../.config');
const OUTPUT_PATH = resolve(_dirname, '../../../built/.config.json');
// TODO: yamlのパースに失敗したときのエラーハンドリング
/**
* YAMLファイルをJSONファイルに変換
* @param {string} ymlPath - YAMLファイルのパス
*/
function yamlToJson(ymlPath) {
if (!fs.existsSync(ymlPath)) {
console.warn(`YAML file not found: ${ymlPath}`);
return;
}
console.log(`${ymlPath}${OUTPUT_PATH}`);
const yamlContent = fs.readFileSync(ymlPath, 'utf-8');
const jsonContent = yaml.load(yamlContent);
if (!fs.existsSync(dirname(OUTPUT_PATH))) {
fs.mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
}
fs.writeFileSync(OUTPUT_PATH, JSON.stringify({
'_NOTE_': 'This file is auto-generated from YAML file. DO NOT EDIT.',
...jsonContent,
}), 'utf-8');
}
if (process.env.MISSKEY_CONFIG_YML) {
const customYmlPath = resolve(configDir, process.env.MISSKEY_CONFIG_YML);
yamlToJson(customYmlPath);
} else {
yamlToJson(resolve(configDir, process.env.NODE_ENV === 'test' ? 'test.yml' : 'default.yml'));
}
console.log('Configuration compiled ✓');

View File

@@ -42,7 +42,7 @@ async function killProc() {
'./node_modules/nodemon/bin/nodemon.js',
[
'-w', 'src',
'-e', 'ts,js,mjs,cjs,json,pug',
'-e', 'ts,js,mjs,cjs,tsx,json,pug',
'--exec', 'pnpm', 'run', 'build',
],
{

View File

@@ -0,0 +1,152 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* This script starts the Misskey backend server, waits for it to be ready,
* measures memory usage, and outputs the result as JSON.
*
* Usage: node scripts/measure-memory.mjs
*/
import { fork } from 'node:child_process';
import { setTimeout } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const STARTUP_TIMEOUT = 120000; // 120 seconds timeout for server startup
const MEMORY_SETTLE_TIME = 10000; // Wait 10 seconds after startup for memory to settle
async function measureMemory() {
const startTime = Date.now();
// Start the Misskey backend server using fork to enable IPC
const serverProcess = fork(join(__dirname, '../built/boot/entry.js'), [], {
cwd: join(__dirname, '..'),
env: {
...process.env,
NODE_ENV: 'test',
},
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
});
let serverReady = false;
// Listen for the 'ok' message from the server indicating it's ready
serverProcess.on('message', (message) => {
if (message === 'ok') {
serverReady = true;
}
});
// Handle server output
serverProcess.stdout?.on('data', (data) => {
process.stderr.write(`[server stdout] ${data}`);
});
serverProcess.stderr?.on('data', (data) => {
process.stderr.write(`[server stderr] ${data}`);
});
// Handle server error
serverProcess.on('error', (err) => {
process.stderr.write(`[server error] ${err}\n`);
});
// Wait for server to be ready or timeout
const startupStartTime = Date.now();
while (!serverReady) {
if (Date.now() - startupStartTime > STARTUP_TIMEOUT) {
serverProcess.kill('SIGTERM');
throw new Error('Server startup timeout');
}
await setTimeout(100);
}
const startupTime = Date.now() - startupStartTime;
process.stderr.write(`Server started in ${startupTime}ms\n`);
// Wait for memory to settle
await setTimeout(MEMORY_SETTLE_TIME);
// Get memory usage from the server process via /proc
const pid = serverProcess.pid;
let memoryInfo;
try {
const fs = await import('node:fs/promises');
// Read /proc/[pid]/status for detailed memory info
const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8');
const vmRssMatch = status.match(/VmRSS:\s+(\d+)\s+kB/);
const vmDataMatch = status.match(/VmData:\s+(\d+)\s+kB/);
const vmSizeMatch = status.match(/VmSize:\s+(\d+)\s+kB/);
memoryInfo = {
rss: vmRssMatch ? parseInt(vmRssMatch[1], 10) * 1024 : null,
heapUsed: vmDataMatch ? parseInt(vmDataMatch[1], 10) * 1024 : null,
vmSize: vmSizeMatch ? parseInt(vmSizeMatch[1], 10) * 1024 : null,
};
} catch (err) {
// Fallback: use ps command
process.stderr.write(`Warning: Could not read /proc/${pid}/status: ${err}\n`);
const { execSync } = await import('node:child_process');
try {
const ps = execSync(`ps -o rss= -p ${pid}`, { encoding: 'utf-8' });
const rssKb = parseInt(ps.trim(), 10);
memoryInfo = {
rss: rssKb * 1024,
heapUsed: null,
vmSize: null,
};
} catch {
memoryInfo = {
rss: null,
heapUsed: null,
vmSize: null,
error: 'Could not measure memory',
};
}
}
// Stop the server
serverProcess.kill('SIGTERM');
// Wait for process to exit
let exited = false;
await new Promise((resolve) => {
serverProcess.on('exit', () => {
exited = true;
resolve(undefined);
});
// Force kill after 10 seconds if not exited
setTimeout(10000).then(() => {
if (!exited) {
serverProcess.kill('SIGKILL');
}
resolve(undefined);
});
});
const result = {
timestamp: new Date().toISOString(),
startupTimeMs: startupTime,
memory: memoryInfo,
};
// Output as JSON to stdout
console.log(JSON.stringify(result, null, 2));
}
measureMemory().catch((err) => {
console.error(JSON.stringify({
error: err.message,
timestamp: new Date().toISOString(),
}));
process.exit(1);
});

View File

@@ -21,7 +21,7 @@ import { execa } from 'execa';
});
}, 3000);
execa('tsc', ['-w', '-p', 'tsconfig.json'], {
execa('tsgo', ['-w', '-p', 'tsconfig.json'], {
stdout: process.stdout,
stderr: process.stderr,
});

View File

@@ -6,11 +6,11 @@
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import * as yaml from 'js-yaml';
import { type FastifyServerOptions } from 'fastify';
import type * as Sentry from '@sentry/node';
import type * as SentryVue from '@sentry/vue';
import type { RedisOptions } from 'ioredis';
import type { ManifestChunk } from 'vite';
type RedisOptionsSource = Partial<RedisOptions> & {
host: string;
@@ -30,6 +30,7 @@ type Source = {
socket?: string;
trustProxy?: FastifyServerOptions['trustProxy'];
chmodSocket?: string;
enableIpRateLimit?: boolean;
disableHsts?: boolean;
db: {
host: string;
@@ -120,8 +121,9 @@ export type Config = {
url: string;
port: number;
socket: string | undefined;
trustProxy: FastifyServerOptions['trustProxy'];
trustProxy: NonNullable<FastifyServerOptions['trustProxy']>;
chmodSocket: string | undefined;
enableIpRateLimit: boolean;
disableHsts: boolean | undefined;
db: {
host: string;
@@ -187,9 +189,9 @@ export type Config = {
authUrl: string;
driveUrl: string;
userAgent: string;
frontendEntry: { file: string | null };
frontendEntry: ManifestChunk;
frontendManifestExists: boolean;
frontendEmbedEntry: { file: string | null };
frontendEmbedEntry: ManifestChunk;
frontendEmbedManifestExists: boolean;
mediaProxy: string;
externalMediaProxyEnabled: boolean;
@@ -217,21 +219,15 @@ export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
/**
* Path of configuration directory
*/
const dir = `${_dirname}/../../../.config`;
const compiledConfigFilePathForTest = resolve(_dirname, '../../../built/._config_.json');
/**
* Path of configuration file
*/
export const path = process.env.MISSKEY_CONFIG_YML
? resolve(dir, process.env.MISSKEY_CONFIG_YML)
: process.env.NODE_ENV === 'test'
? resolve(dir, 'test.yml')
: resolve(dir, 'default.yml');
export const compiledConfigFilePath = fs.existsSync(compiledConfigFilePathForTest) ? compiledConfigFilePathForTest : resolve(_dirname, '../../../built/.config.json');
export function loadConfig(): Config {
if (!fs.existsSync(compiledConfigFilePath)) {
throw new Error('Compiled configuration file not found. Try running \'pnpm compile-config\'.');
}
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8'));
const frontendManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_vite_/manifest.json');
@@ -243,7 +239,7 @@ export function loadConfig(): Config {
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8'))
: { 'src/boot.ts': { file: null } };
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
const config = JSON.parse(fs.readFileSync(compiledConfigFilePath, 'utf-8')) as Source;
const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? '');
const version = meta.version;
@@ -269,9 +265,17 @@ export function loadConfig(): Config {
url: url.origin,
port: config.port ?? parseInt(process.env.PORT ?? '', 10),
socket: config.socket,
trustProxy: config.trustProxy,
trustProxy: config.trustProxy ?? [
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'127.0.0.1/32',
'::1/128',
'fc00::/7',
],
chmodSocket: config.chmodSocket,
disableHsts: config.disableHsts,
enableIpRateLimit: config.enableIpRateLimit ?? true,
host,
hostname,
scheme,

View File

@@ -7,7 +7,6 @@ import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import { Injectable } from '@nestjs/common';
import si from 'systeminformation';
import { Mutex } from 'async-mutex';
import fetch from 'node-fetch';
import { bindThis } from '@/decorators.js';
@@ -84,6 +83,7 @@ export class AiService {
@bindThis
private async getCpuFlags(): Promise<string[]> {
const si = await import('systeminformation');
const str = await si.cpuFlags();
return str.split(/\s+/);
}

View File

@@ -141,7 +141,7 @@ import { ApLoggerService } from './activitypub/ApLoggerService.js';
import { ApMfmService } from './activitypub/ApMfmService.js';
import { ApRendererService } from './activitypub/ApRendererService.js';
import { ApRequestService } from './activitypub/ApRequestService.js';
import { ApResolverService } from './activitypub/ApResolverService.js';
import { ApResolverService, Resolver } from './activitypub/ApResolverService.js';
import { JsonLdService } from './activitypub/JsonLdService.js';
import { RemoteLoggerService } from './RemoteLoggerService.js';
import { RemoteUserResolveService } from './RemoteUserResolveService.js';
@@ -447,6 +447,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApRendererService,
ApRequestService,
ApResolverService,
Resolver,
JsonLdService,
RemoteLoggerService,
RemoteUserResolveService,
@@ -745,6 +746,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApRendererService,
ApRequestService,
ApResolverService,
Resolver,
JsonLdService,
RemoteLoggerService,
RemoteUserResolveService,

View File

@@ -95,7 +95,7 @@ export class ApInboxService {
if (isCollectionOrOrderedCollection(activity)) {
const results = [] as [string, string | void][];
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
resolver ??= await this.apResolverService.createResolver();
const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems);
if (items.length >= resolver.getRecursionLimit()) {
@@ -221,7 +221,7 @@ export class ApInboxService {
this.logger.info(`Accept: ${uri}`);
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
resolver ??= await this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(err => {
this.logger.error(`Resolution failed: ${err}`);
@@ -284,7 +284,7 @@ export class ApInboxService {
this.logger.info(`Announce: ${uri}`);
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
resolver ??= await this.apResolverService.createResolver();
if (!activity.object) return 'skip: activity has no object property';
const targetUri = getApId(activity.object);
@@ -406,7 +406,7 @@ export class ApInboxService {
}
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
resolver ??= await this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
@@ -575,7 +575,7 @@ export class ApInboxService {
this.logger.info(`Reject: ${uri}`);
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
resolver ??= await this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
@@ -642,7 +642,7 @@ export class ApInboxService {
this.logger.info(`Undo: ${uri}`);
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
resolver ??= await this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
@@ -774,7 +774,7 @@ export class ApInboxService {
this.logger.debug('Update');
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
resolver ??= await this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);

View File

@@ -3,10 +3,17 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js';
import type {
FollowRequestsRepository,
MiMeta,
NoteReactionsRepository,
NotesRepository,
PollsRepository,
UsersRepository
} from '@/models/_.js';
import type { Config } from '@/config.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { DI } from '@/di-symbols.js';
@@ -16,26 +23,43 @@ import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { SystemAccountService } from '@/core/SystemAccountService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { ICollection, IObject, IOrderedCollection } from './type.js';
import { isCollectionOrOrderedCollection } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js';
import { ApRequestService } from './ApRequestService.js';
import { FetchAllowSoftFailMask } from './misc/check-against-url.js';
import type { IObject, ICollection, IOrderedCollection } from './type.js';
import { ModuleRef } from '@nestjs/core';
@Injectable({ scope: Scope.TRANSIENT })
export class Resolver {
private history: Set<string>;
private user?: MiLocalUser;
private logger: Logger;
private recursionLimit = 256;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.meta)
private meta: MiMeta,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.pollsRepository)
private pollsRepository: PollsRepository,
@Inject(DI.noteReactionsRepository)
private noteReactionsRepository: NoteReactionsRepository,
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
private utilityService: UtilityService,
private systemAccountService: SystemAccountService,
private apRequestService: ApRequestService,
@@ -43,7 +67,6 @@ export class Resolver {
private apRendererService: ApRendererService,
private apDbResolverService: ApDbResolverService,
private loggerService: LoggerService,
private recursionLimit = 256,
) {
this.history = new Set();
this.logger = this.loggerService.getLogger('ap-resolve');
@@ -180,54 +203,12 @@ export class Resolver {
@Injectable()
export class ApResolverService {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.meta)
private meta: MiMeta,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.pollsRepository)
private pollsRepository: PollsRepository,
@Inject(DI.noteReactionsRepository)
private noteReactionsRepository: NoteReactionsRepository,
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
private utilityService: UtilityService,
private systemAccountService: SystemAccountService,
private apRequestService: ApRequestService,
private httpRequestService: HttpRequestService,
private apRendererService: ApRendererService,
private apDbResolverService: ApDbResolverService,
private loggerService: LoggerService,
private moduleRef: ModuleRef,
) {
}
@bindThis
public createResolver(): Resolver {
return new Resolver(
this.config,
this.meta,
this.usersRepository,
this.notesRepository,
this.pollsRepository,
this.noteReactionsRepository,
this.followRequestsRepository,
this.utilityService,
this.systemAccountService,
this.apRequestService,
this.httpRequestService,
this.apRendererService,
this.apDbResolverService,
this.loggerService,
);
public async createResolver(): Promise<Resolver> {
return await this.moduleRef.create(Resolver);
}
}

View File

@@ -46,7 +46,7 @@ export class ApImageService {
throw new Error('actor has been suspended');
}
const image = await this.apResolverService.createResolver().resolve(value);
const image = await (await this.apResolverService.createResolver()).resolve(value);
if (!isDocument(image)) return null;

View File

@@ -128,7 +128,7 @@ export class ApNoteService {
@bindThis
public async createNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise<MiNote | null> {
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
if (resolver == null) resolver = await this.apResolverService.createResolver();
const object = await resolver.resolve(value);

View File

@@ -310,7 +310,7 @@ export class ApPersonService implements OnModuleInit {
}
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
if (resolver == null) resolver = await this.apResolverService.createResolver();
const object = await resolver.resolve(uri);
if (object.id == null) throw new Error('invalid object.id: ' + object.id);
@@ -500,7 +500,7 @@ export class ApPersonService implements OnModuleInit {
//#endregion
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
if (resolver == null) resolver = await this.apResolverService.createResolver();
const object = hint ?? await resolver.resolve(uri);
@@ -678,7 +678,7 @@ export class ApPersonService implements OnModuleInit {
// リモートサーバーからフェッチしてきて登録
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
if (resolver == null) resolver = await this.apResolverService.createResolver();
return await this.createPerson(uri, resolver);
}
@@ -707,7 +707,7 @@ export class ApPersonService implements OnModuleInit {
this.logger.info(`Updating the featured: ${user.uri}`);
const _resolver = resolver ?? this.apResolverService.createResolver();
const _resolver = resolver ?? await this.apResolverService.createResolver();
// Resolve to (Ordered)Collection Object
const collection = await _resolver.resolveCollection(user.featured);

View File

@@ -45,7 +45,7 @@ export class ApQuestionService {
@bindThis
public async extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise<IPoll> {
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
if (resolver == null) resolver = await this.apResolverService.createResolver();
const question = await resolver.resolve(source);
if (!isQuestion(question)) throw new Error('invalid type');
@@ -91,7 +91,7 @@ export class ApQuestionService {
// resolve new Question object
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
if (resolver == null) resolver = await this.apResolverService.createResolver();
const question = await resolver.resolve(value);
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);

View File

@@ -15,6 +15,7 @@ import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepos
import { bindThis } from '@/decorators.js';
import { DebounceLoader } from '@/misc/loader.js';
import { IdService } from '@/core/IdService.js';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js';
@@ -116,12 +117,7 @@ export class NoteEntityService implements OnModuleInit {
private treatVisibility(packedNote: Packed<'Note'>): Packed<'Note'>['visibility'] {
if (packedNote.visibility === 'public' || packedNote.visibility === 'home') {
const followersOnlyBefore = packedNote.user.makeNotesFollowersOnlyBefore;
if ((followersOnlyBefore != null)
&& (
(followersOnlyBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (followersOnlyBefore * 1000)))
|| (followersOnlyBefore > 0 && (new Date(packedNote.createdAt).getTime() < followersOnlyBefore * 1000))
)
) {
if (shouldHideNoteByTime(followersOnlyBefore, packedNote.createdAt)) {
packedNote.visibility = 'followers';
}
}
@@ -141,12 +137,7 @@ export class NoteEntityService implements OnModuleInit {
if (!hide) {
const hiddenBefore = packedNote.user.makeNotesHiddenBefore;
if ((hiddenBefore != null)
&& (
(hiddenBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (hiddenBefore * 1000)))
|| (hiddenBefore > 0 && (new Date(packedNote.createdAt).getTime() < hiddenBefore * 1000))
)
) {
if (shouldHideNoteByTime(hiddenBefore, packedNote.createdAt)) {
hide = true;
}
}

View File

@@ -4,13 +4,12 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import si from 'systeminformation';
import Xev from 'xev';
import * as osUtils from 'os-utils';
import { bindThis } from '@/decorators.js';
import type { OnApplicationShutdown } from '@nestjs/common';
import { MiMeta } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import type { OnApplicationShutdown } from '@nestjs/common';
const ev = new Xev();
@@ -97,12 +96,14 @@ function cpuUsage(): Promise<number> {
// MEMORY STAT
async function mem() {
const si = await import('systeminformation');
const data = await si.mem();
return data;
}
// NETWORK STAT
async function net() {
const si = await import('systeminformation');
const iface = await si.networkInterfaceDefault();
const data = await si.networkStats(iface);
return data[0];
@@ -110,5 +111,6 @@ async function net() {
// FS STAT
async function fs() {
const si = await import('systeminformation');
return await si.disksIO().catch(() => ({ rIO_sec: 0, wIO_sec: 0 }));
}

View File

@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
const ESCAPE_LOOKUP = {
'&': '\\u0026',
'>': '\\u003e',
'<': '\\u003c',
'\u2028': '\\u2028',
'\u2029': '\\u2029',
} as Record<string, string>;
const ESCAPE_REGEX = /[&><\u2028\u2029]/g;
export function htmlSafeJsonStringify(obj: any): string {
return JSON.stringify(obj).replace(ESCAPE_REGEX, x => ESCAPE_LOOKUP[x]);
}

View File

@@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* ノートが指定された時間条件に基づいて非表示対象かどうかを判定する
* @param hiddenBefore 非表示条件(負の値: 作成からの経過秒数、正の値: UNIXタイムスタンプ秒、null: 判定しない)
* @param createdAt ートの作成日時ISO 8601形式の文字列 または Date オブジェクト)
* @returns 非表示にすべき場合は true
*/
export function shouldHideNoteByTime(hiddenBefore: number | null | undefined, createdAt: string | Date): boolean {
if (hiddenBefore == null) {
return false;
}
const createdAtTime = typeof createdAt === 'string' ? new Date(createdAt).getTime() : createdAt.getTime();
if (hiddenBefore <= 0) {
// 負の値: 作成からの経過時間(秒)で判定
const elapsedSeconds = (Date.now() - createdAtTime) / 1000;
const hideAfterSeconds = Math.abs(hiddenBefore);
return elapsedSeconds >= hideAfterSeconds;
} else {
// 正の値: 絶対的なタイムスタンプ(秒)で判定
const createdAtSeconds = createdAtTime / 1000;
return createdAtSeconds <= hiddenBefore;
}
}

View File

@@ -4,15 +4,11 @@
*/
import * as os from 'node:os';
import sysUtils from 'systeminformation';
import type Logger from '@/logger.js';
export async function showMachineInfo(parentLogger: Logger) {
const logger = parentLogger.createSubLogger('machine');
logger.debug(`Hostname: ${os.hostname()}`);
logger.debug(`Platform: ${process.platform} Arch: ${process.arch}`);
const mem = await sysUtils.mem();
const totalmem = (mem.total / 1024 / 1024 / 1024).toFixed(1);
const availmem = (mem.available / 1024 / 1024 / 1024).toFixed(1);
logger.debug(`CPU: ${os.cpus().length} core MEM: ${totalmem}GB (available: ${availmem}GB)`);
logger.debug(`CPU: ${os.cpus().length} core MEM: ${(os.totalmem() / 1024 / 1024 / 1024).toFixed(1)}GB (available: ${(os.freemem() / 1024 / 1024 / 1024).toFixed(1)}GB)`);
}

View File

@@ -157,7 +157,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
}
let Sentry: typeof import('@sentry/node') | undefined;
if (Sentry != null) {
if (this.config.sentryForBackend) {
import('@sentry/node').then((mod) => {
Sentry = mod;
});

View File

@@ -5,21 +5,20 @@
import * as fs from 'node:fs';
import { Writable } from 'node:stream';
import { Inject, Injectable, StreamableFile } from '@nestjs/common';
import { MoreThan } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import { format as dateFormat } from 'date-fns';
import { DI } from '@/di-symbols.js';
import type { ClipNotesRepository, ClipsRepository, MiClip, MiClipNote, MiUser, NotesRepository, PollsRepository, UsersRepository } from '@/models/_.js';
import type { ClipNotesRepository, ClipsRepository, MiClip, MiClipNote, MiUser, PollsRepository, UsersRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js';
import { createTemp } from '@/misc/create-temp.js';
import type { MiPoll } from '@/models/Poll.js';
import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { Packed } from '@/misc/json-schema.js';
import { IdService } from '@/core/IdService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { QueryService } from '@/core/QueryService.js';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js';
@@ -43,6 +42,7 @@ export class ExportClipsProcessorService {
private driveService: DriveService,
private queueLoggerService: QueueLoggerService,
private queryService: QueryService,
private idService: IdService,
private notificationService: NotificationService,
) {
@@ -100,16 +100,16 @@ export class ExportClipsProcessorService {
});
while (true) {
const clips = await this.clipsRepository.find({
where: {
userId: user.id,
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 100,
order: {
id: 1,
},
});
const query = this.clipsRepository.createQueryBuilder('clip')
.where('clip.userId = :userId', { userId: user.id })
.orderBy('clip.id', 'ASC')
.take(100);
if (cursor) {
query.andWhere('clip.id > :cursor', { cursor });
}
const clips = await query.getMany();
if (clips.length === 0) {
job.updateProgress(100);
@@ -124,7 +124,7 @@ export class ExportClipsProcessorService {
const isFirst = exportedClipsCount === 0;
await writer.write(isFirst ? content : ',\n' + content);
await this.processClipNotes(writer, clip.id);
await this.processClipNotes(writer, clip.id, user.id);
await writer.write(']}');
exportedClipsCount++;
@@ -134,22 +134,25 @@ export class ExportClipsProcessorService {
}
}
async processClipNotes(writer: WritableStreamDefaultWriter, clipId: string): Promise<void> {
async processClipNotes(writer: WritableStreamDefaultWriter, clipId: string, userId: string): Promise<void> {
let exportedClipNotesCount = 0;
let cursor: MiClipNote['id'] | null = null;
while (true) {
const clipNotes = await this.clipNotesRepository.find({
where: {
clipId,
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 100,
order: {
id: 1,
},
relations: ['note', 'note.user'],
}) as (MiClipNote & { note: MiNote & { user: MiUser } })[];
const query = this.clipNotesRepository.createQueryBuilder('clipNote')
.leftJoinAndSelect('clipNote.note', 'note')
.leftJoinAndSelect('note.user', 'user')
.where('clipNote.clipId = :clipId', { clipId })
.orderBy('clipNote.id', 'ASC')
.take(100);
if (cursor) {
query.andWhere('clipNote.id > :cursor', { cursor });
}
this.queryService.generateVisibilityQuery(query, { id: userId });
const clipNotes = await query.getMany() as (MiClipNote & { note: MiNote & { user: MiUser } })[];
if (clipNotes.length === 0) {
break;
@@ -158,6 +161,11 @@ export class ExportClipsProcessorService {
cursor = clipNotes.at(-1)?.id ?? null;
for (const clipNote of clipNotes) {
const noteCreatedAt = this.idService.parse(clipNote.note.id).date;
if (shouldHideNoteByTime(clipNote.note.user.makeNotesHiddenBefore, noteCreatedAt)) {
continue;
}
let poll: MiPoll | undefined;
if (clipNote.note.hasPoll) {
poll = await this.pollsRepository.findOneByOrFail({ noteId: clipNote.note.id });

View File

@@ -5,7 +5,6 @@
import * as fs from 'node:fs';
import { Inject, Injectable } from '@nestjs/common';
import { MoreThan } from 'typeorm';
import { format as dateFormat } from 'date-fns';
import { DI } from '@/di-symbols.js';
import type { MiNoteFavorite, NoteFavoritesRepository, PollsRepository, MiUser, UsersRepository } from '@/models/_.js';
@@ -17,6 +16,8 @@ import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { QueryService } from '@/core/QueryService.js';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js';
@@ -37,6 +38,7 @@ export class ExportFavoritesProcessorService {
private driveService: DriveService,
private queueLoggerService: QueueLoggerService,
private queryService: QueryService,
private idService: IdService,
private notificationService: NotificationService,
) {
@@ -83,17 +85,20 @@ export class ExportFavoritesProcessorService {
});
while (true) {
const favorites = await this.noteFavoritesRepository.find({
where: {
userId: user.id,
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 100,
order: {
id: 1,
},
relations: ['note', 'note.user'],
}) as (MiNoteFavorite & { note: MiNote & { user: MiUser } })[];
const query = this.noteFavoritesRepository.createQueryBuilder('favorite')
.leftJoinAndSelect('favorite.note', 'note')
.leftJoinAndSelect('note.user', 'user')
.where('favorite.userId = :userId', { userId: user.id })
.orderBy('favorite.id', 'ASC')
.take(100);
if (cursor) {
query.andWhere('favorite.id > :cursor', { cursor });
}
this.queryService.generateVisibilityQuery(query, { id: user.id });
const favorites = await query.getMany() as (MiNoteFavorite & { note: MiNote & { user: MiUser } })[];
if (favorites.length === 0) {
job.updateProgress(100);
@@ -103,6 +108,11 @@ export class ExportFavoritesProcessorService {
cursor = favorites.at(-1)?.id ?? null;
for (const favorite of favorites) {
const noteCreatedAt = this.idService.parse(favorite.note.id).date;
if (shouldHideNoteByTime(favorite.note.user.makeNotesHiddenBefore, noteCreatedAt)) {
continue;
}
let poll: MiPoll | undefined;
if (favorite.note.hasPoll) {
poll = await this.pollsRepository.findOneByOrFail({ noteId: favorite.note.id });

View File

@@ -13,7 +13,6 @@ import { NodeinfoServerService } from './NodeinfoServerService.js';
import { ServerService } from './ServerService.js';
import { WellKnownServerService } from './WellKnownServerService.js';
import { GetterService } from './api/GetterService.js';
import { ChannelsService } from './api/stream/ChannelsService.js';
import { ActivityPubServerService } from './ActivityPubServerService.js';
import { ApiLoggerService } from './api/ApiLoggerService.js';
import { ApiServerService } from './api/ApiServerService.js';
@@ -25,29 +24,31 @@ import { SignupApiService } from './api/SignupApiService.js';
import { StreamingApiServerService } from './api/StreamingApiServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { ClientServerService } from './web/ClientServerService.js';
import { HtmlTemplateService } from './web/HtmlTemplateService.js';
import { FeedService } from './web/FeedService.js';
import { UrlPreviewService } from './web/UrlPreviewService.js';
import { ClientLoggerService } from './web/ClientLoggerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import { MainChannelService } from './api/stream/channels/main.js';
import { AdminChannelService } from './api/stream/channels/admin.js';
import { AntennaChannelService } from './api/stream/channels/antenna.js';
import { ChannelChannelService } from './api/stream/channels/channel.js';
import { DriveChannelService } from './api/stream/channels/drive.js';
import { GlobalTimelineChannelService } from './api/stream/channels/global-timeline.js';
import { HashtagChannelService } from './api/stream/channels/hashtag.js';
import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js';
import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js';
import { LocalTimelineChannelService } from './api/stream/channels/local-timeline.js';
import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
import { UserListChannelService } from './api/stream/channels/user-list.js';
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
import { ChatUserChannelService } from './api/stream/channels/chat-user.js';
import { ChatRoomChannelService } from './api/stream/channels/chat-room.js';
import { ReversiChannelService } from './api/stream/channels/reversi.js';
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
import MainStreamConnection from '@/server/api/stream/Connection.js';
import { MainChannel } from './api/stream/channels/main.js';
import { AdminChannel } from './api/stream/channels/admin.js';
import { AntennaChannel } from './api/stream/channels/antenna.js';
import { ChannelChannel } from './api/stream/channels/channel.js';
import { DriveChannel } from './api/stream/channels/drive.js';
import { GlobalTimelineChannel } from './api/stream/channels/global-timeline.js';
import { HashtagChannel } from './api/stream/channels/hashtag.js';
import { HomeTimelineChannel } from './api/stream/channels/home-timeline.js';
import { HybridTimelineChannel } from './api/stream/channels/hybrid-timeline.js';
import { LocalTimelineChannel } from './api/stream/channels/local-timeline.js';
import { QueueStatsChannel } from './api/stream/channels/queue-stats.js';
import { ServerStatsChannel } from './api/stream/channels/server-stats.js';
import { UserListChannel } from './api/stream/channels/user-list.js';
import { RoleTimelineChannel } from './api/stream/channels/role-timeline.js';
import { ChatUserChannel } from './api/stream/channels/chat-user.js';
import { ChatRoomChannel } from './api/stream/channels/chat-room.js';
import { ReversiChannel } from './api/stream/channels/reversi.js';
import { ReversiGameChannel } from './api/stream/channels/reversi-game.js';
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
@Module({
@@ -58,6 +59,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
providers: [
ClientServerService,
ClientLoggerService,
HtmlTemplateService,
FeedService,
HealthServerService,
UrlPreviewService,
@@ -67,7 +69,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
ServerService,
WellKnownServerService,
GetterService,
ChannelsService,
MainStreamConnection,
ApiCallService,
ApiLoggerService,
ApiServerService,
@@ -78,24 +80,24 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
SigninService,
SignupApiService,
StreamingApiServerService,
MainChannelService,
AdminChannelService,
AntennaChannelService,
ChannelChannelService,
DriveChannelService,
GlobalTimelineChannelService,
HashtagChannelService,
RoleTimelineChannelService,
ChatUserChannelService,
ChatRoomChannelService,
ReversiChannelService,
ReversiGameChannelService,
HomeTimelineChannelService,
HybridTimelineChannelService,
LocalTimelineChannelService,
QueueStatsChannelService,
ServerStatsChannelService,
UserListChannelService,
MainChannel,
AdminChannel,
AntennaChannel,
ChannelChannel,
DriveChannel,
GlobalTimelineChannel,
HashtagChannel,
RoleTimelineChannel,
ChatUserChannel,
ChatRoomChannel,
ReversiChannel,
ReversiGameChannel,
HomeTimelineChannel,
HybridTimelineChannel,
LocalTimelineChannel,
QueueStatsChannel,
ServerStatsChannel,
UserListChannel,
OpenApiServerService,
OAuth2ProviderService,
],

View File

@@ -75,7 +75,7 @@ export class ServerService implements OnApplicationShutdown {
@bindThis
public async launch(): Promise<void> {
const fastify = Fastify({
trustProxy: this.config.trustProxy ?? true,
trustProxy: this.config.trustProxy,
logger: false,
});
this.#fastify = fastify;

View File

@@ -313,11 +313,14 @@ export class ApiCallService implements OnApplicationShutdown {
}
if (ep.meta.limit) {
// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
let limitActor: string;
let limitActor: string | null = null;
if (user) {
limitActor = user.id;
} else {
} else if (this.config.enableIpRateLimit) {
if (process.env.NODE_ENV === 'production' && (request.ip === '::1' || request.ip === '127.0.0.1')) {
this.logger.warn('Recieved API request from localhost IP address for rate limiting in production environment. This is likely due to an improper trustProxy setting in the config file.');
}
limitActor = getIpHash(request.ip);
}
@@ -330,7 +333,7 @@ export class ApiCallService implements OnApplicationShutdown {
// TODO: 毎リクエスト計算するのもあれだしキャッシュしたい
const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1;
if (factor > 0) {
if (limitActor != null && factor > 0) {
// Rate limit
const rateLimit = await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor);
if (rateLimit != null) {

View File

@@ -15,6 +15,7 @@ import type {
UserSecurityKeysRepository,
UsersRepository,
} from '@/models/_.js';
import type Logger from '@/logger.js';
import type { Config } from '@/config.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import type { MiLocalUser } from '@/models/User.js';
@@ -23,6 +24,7 @@ import { bindThis } from '@/decorators.js';
import { WebAuthnService } from '@/core/WebAuthnService.js';
import { UserAuthService } from '@/core/UserAuthService.js';
import { CaptchaService } from '@/core/CaptchaService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
import { RateLimiterService } from './RateLimiterService.js';
import { SigninService } from './SigninService.js';
@@ -31,6 +33,8 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
@Injectable()
export class SigninApiService {
private logger: Logger;
constructor(
@Inject(DI.config)
private config: Config,
@@ -50,6 +54,7 @@ export class SigninApiService {
@Inject(DI.signinsRepository)
private signinsRepository: SigninsRepository,
private loggerService: LoggerService,
private idService: IdService,
private rateLimiterService: RateLimiterService,
private signinService: SigninService,
@@ -57,6 +62,7 @@ export class SigninApiService {
private webAuthnService: WebAuthnService,
private captchaService: CaptchaService,
) {
this.logger = this.loggerService.getLogger('Signin');
}
@bindThis
@@ -90,16 +96,21 @@ export class SigninApiService {
}
// not more than 1 attempt per second and not more than 10 attempts per hour
const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
if (rateLimit != null) {
reply.code(429);
return {
error: {
message: 'Too many failed attempts to sign in. Try again later.',
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
},
};
if (this.config.enableIpRateLimit) {
if (process.env.NODE_ENV === 'production' && (request.ip === '::1' || request.ip === '127.0.0.1')) {
this.logger.warn('Recieved signin request from localhost IP address for rate limiting in production environment. This is likely due to an improper trustProxy setting in the config file.');
}
const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
if (rateLimit != null) {
reply.code(429);
return {
error: {
message: 'Too many failed attempts to sign in. Try again later.',
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
},
};
}
}
if (typeof username !== 'string') {

View File

@@ -84,19 +84,25 @@ export class SigninWithPasskeyApiService {
return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' });
};
try {
if (this.config.enableIpRateLimit) {
if (process.env.NODE_ENV === 'production' && (request.ip === '::1' || request.ip === '127.0.0.1')) {
this.logger.warn('Recieved signin with passkey request from localhost IP address for rate limiting in production environment. This is likely due to an improper trustProxy setting in the config file.');
}
try {
// Not more than 1 API call per 250ms and not more than 100 attempts per 30min
// NOTE: 1 Sign-in require 2 API calls
await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip));
} catch (err) {
reply.code(429);
return {
error: {
message: 'Too many failed attempts to sign in. Try again later.',
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
},
};
await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip));
} catch (err) {
reply.code(429);
return {
error: {
message: 'Too many failed attempts to sign in. Try again later.',
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
},
};
}
}
// Initiate Passkey Auth challenge with context

View File

@@ -8,18 +8,14 @@ import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import * as WebSocket from 'ws';
import { DI } from '@/di-symbols.js';
import type { UsersRepository, MiAccessToken } from '@/models/_.js';
import { NotificationService } from '@/core/NotificationService.js';
import type { MiAccessToken } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
import { MiLocalUser } from '@/models/User.js';
import { UserService } from '@/core/UserService.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
import MainStreamConnection from './stream/Connection.js';
import { ChannelsService } from './stream/ChannelsService.js';
import MainStreamConnection, { ConnectionRequest } from './stream/Connection.js';
import type * as http from 'node:http';
import { ContextIdFactory, ModuleRef } from '@nestjs/core';
@Injectable()
export class StreamingApiServerService {
@@ -31,16 +27,9 @@ export class StreamingApiServerService {
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private cacheService: CacheService,
private moduleRef: ModuleRef,
private authenticateService: AuthenticateService,
private channelsService: ChannelsService,
private notificationService: NotificationService,
private usersService: UserService,
private channelFollowingService: ChannelFollowingService,
private channelMutingService: ChannelMutingService,
) {
}
@@ -94,14 +83,12 @@ export class StreamingApiServerService {
return;
}
const stream = new MainStreamConnection(
this.channelsService,
this.notificationService,
this.cacheService,
this.channelFollowingService,
this.channelMutingService,
user, app,
);
const contextId = ContextIdFactory.create();
this.moduleRef.registerRequestByContextId<ConnectionRequest>({
user,
token: app,
}, contextId);
const stream = await this.moduleRef.create(MainStreamConnection, contextId);
await stream.init();

View File

@@ -52,18 +52,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => {
const jobs = await this.deliverQueue.getJobs(['delayed']);
const res = [] as [string, number][];
const counts = new Map<string, number>();
for (const job of jobs) {
const host = new URL(job.data.to).host;
if (res.find(x => x[0] === host)) {
res.find(x => x[0] === host)![1]++;
} else {
res.push([host, 1]);
}
counts.set(host, (counts.get(host) ?? 0) + 1);
}
res.sort((a, b) => b[1] - a[1]);
const res = [...counts.entries()].sort((a, b) => b[1] - a[1]);
return res;
});

View File

@@ -52,18 +52,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => {
const jobs = await this.inboxQueue.getJobs(['delayed']);
const res = [] as [string, number][];
const counts = new Map<string, number>();
for (const job of jobs) {
const host = new URL(job.data.signature.keyId).host;
if (res.find(x => x[0] === host)) {
res.find(x => x[0] === host)![1]++;
} else {
res.push([host, 1]);
}
counts.set(host, (counts.get(host) ?? 0) + 1);
}
res.sort((a, b) => b[1] - a[1]);
const res = [...counts.entries()].sort((a, b) => b[1] - a[1]);
return res;
});

View File

@@ -4,7 +4,6 @@
*/
import * as os from 'node:os';
import si from 'systeminformation';
import { Inject, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import * as Redis from 'ioredis';
@@ -112,6 +111,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) {
super(meta, paramDef, async () => {
const si = await import('systeminformation');
const memStats = await si.mem();
const fsStats = await si.fsSize();
const netInterface = await si.networkInterfaceDefault();

View File

@@ -43,7 +43,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private apResolverService: ApResolverService,
) {
super(meta, paramDef, async (ps, me) => {
const resolver = this.apResolverService.createResolver();
const resolver = await this.apResolverService.createResolver();
const object = await resolver.resolve(ps.uri);
return object;
});

View File

@@ -148,7 +148,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (this.utilityService.isSelfHost(host)) return null;
// リモートから一旦オブジェクトフェッチ
const resolver = this.apResolverService.createResolver();
const resolver = await this.apResolverService.createResolver();
// allow ap/show exclusively to lookup URLs that are cross-origin or non-canonical (like https://alice.example.com/@bob@bob.example.com -> https://bob.example.com/@bob)
const object = await resolver.resolve(uri, FetchAllowSoftFailMask.CrossOrigin | FetchAllowSoftFailMask.NonCanonicalId).catch((err) => {
if (err instanceof IdentifiableError) {

View File

@@ -4,7 +4,6 @@
*/
import * as os from 'node:os';
import si from 'systeminformation';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { MiMeta } from '@/models/_.js';
@@ -93,6 +92,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
},
};
const si = await import('systeminformation');
const memStats = await si.mem();
const fsStats = await si.fsSize();

View File

@@ -4,72 +4,54 @@
*/
import { Injectable } from '@nestjs/common';
import { HybridTimelineChannel } from './channels/hybrid-timeline.js';
import { LocalTimelineChannel } from './channels/local-timeline.js';
import { HomeTimelineChannel } from './channels/home-timeline.js';
import { GlobalTimelineChannel } from './channels/global-timeline.js';
import { MainChannel } from './channels/main.js';
import { ChannelChannel } from './channels/channel.js';
import { AdminChannel } from './channels/admin.js';
import { ServerStatsChannel } from './channels/server-stats.js';
import { QueueStatsChannel } from './channels/queue-stats.js';
import { UserListChannel } from './channels/user-list.js';
import { AntennaChannel } from './channels/antenna.js';
import { DriveChannel } from './channels/drive.js';
import { HashtagChannel } from './channels/hashtag.js';
import { RoleTimelineChannel } from './channels/role-timeline.js';
import { ChatUserChannel } from './channels/chat-user.js';
import { ChatRoomChannel } from './channels/chat-room.js';
import { ReversiChannel } from './channels/reversi.js';
import { ReversiGameChannel } from './channels/reversi-game.js';
import type { ChannelConstructor } from './channel.js';
import { bindThis } from '@/decorators.js';
import { HybridTimelineChannelService } from './channels/hybrid-timeline.js';
import { LocalTimelineChannelService } from './channels/local-timeline.js';
import { HomeTimelineChannelService } from './channels/home-timeline.js';
import { GlobalTimelineChannelService } from './channels/global-timeline.js';
import { MainChannelService } from './channels/main.js';
import { ChannelChannelService } from './channels/channel.js';
import { AdminChannelService } from './channels/admin.js';
import { ServerStatsChannelService } from './channels/server-stats.js';
import { QueueStatsChannelService } from './channels/queue-stats.js';
import { UserListChannelService } from './channels/user-list.js';
import { AntennaChannelService } from './channels/antenna.js';
import { DriveChannelService } from './channels/drive.js';
import { HashtagChannelService } from './channels/hashtag.js';
import { RoleTimelineChannelService } from './channels/role-timeline.js';
import { ChatUserChannelService } from './channels/chat-user.js';
import { ChatRoomChannelService } from './channels/chat-room.js';
import { ReversiChannelService } from './channels/reversi.js';
import { ReversiGameChannelService } from './channels/reversi-game.js';
import { type MiChannelService } from './channel.js';
@Injectable()
export class ChannelsService {
constructor(
private mainChannelService: MainChannelService,
private homeTimelineChannelService: HomeTimelineChannelService,
private localTimelineChannelService: LocalTimelineChannelService,
private hybridTimelineChannelService: HybridTimelineChannelService,
private globalTimelineChannelService: GlobalTimelineChannelService,
private userListChannelService: UserListChannelService,
private hashtagChannelService: HashtagChannelService,
private roleTimelineChannelService: RoleTimelineChannelService,
private antennaChannelService: AntennaChannelService,
private channelChannelService: ChannelChannelService,
private driveChannelService: DriveChannelService,
private serverStatsChannelService: ServerStatsChannelService,
private queueStatsChannelService: QueueStatsChannelService,
private adminChannelService: AdminChannelService,
private chatUserChannelService: ChatUserChannelService,
private chatRoomChannelService: ChatRoomChannelService,
private reversiChannelService: ReversiChannelService,
private reversiGameChannelService: ReversiGameChannelService,
) {
}
@bindThis
public getChannelService(name: string): MiChannelService<boolean> {
public getChannelConstructor(name: string): ChannelConstructor<boolean> {
switch (name) {
case 'main': return this.mainChannelService;
case 'homeTimeline': return this.homeTimelineChannelService;
case 'localTimeline': return this.localTimelineChannelService;
case 'hybridTimeline': return this.hybridTimelineChannelService;
case 'globalTimeline': return this.globalTimelineChannelService;
case 'userList': return this.userListChannelService;
case 'hashtag': return this.hashtagChannelService;
case 'roleTimeline': return this.roleTimelineChannelService;
case 'antenna': return this.antennaChannelService;
case 'channel': return this.channelChannelService;
case 'drive': return this.driveChannelService;
case 'serverStats': return this.serverStatsChannelService;
case 'queueStats': return this.queueStatsChannelService;
case 'admin': return this.adminChannelService;
case 'chatUser': return this.chatUserChannelService;
case 'chatRoom': return this.chatRoomChannelService;
case 'reversi': return this.reversiChannelService;
case 'reversiGame': return this.reversiGameChannelService;
case 'main': return MainChannel;
case 'homeTimeline': return HomeTimelineChannel;
case 'localTimeline': return LocalTimelineChannel;
case 'hybridTimeline': return HybridTimelineChannel;
case 'globalTimeline': return GlobalTimelineChannel;
case 'userList': return UserListChannel;
case 'hashtag': return HashtagChannel;
case 'roleTimeline': return RoleTimelineChannel;
case 'antenna': return AntennaChannel;
case 'channel': return ChannelChannel;
case 'drive': return DriveChannel;
case 'serverStats': return ServerStatsChannel;
case 'queueStats': return QueueStatsChannel;
case 'admin': return AdminChannel;
case 'chatUser': return ChatUserChannel;
case 'chatRoom': return ChatRoomChannel;
case 'reversi': return ReversiChannel;
case 'reversiGame': return ReversiGameChannel;
default:
throw new Error(`no such channel: ${name}`);

View File

@@ -6,19 +6,39 @@
import * as WebSocket from 'ws';
import type { MiUser } from '@/models/User.js';
import type { MiAccessToken } from '@/models/AccessToken.js';
import type { Packed } from '@/misc/json-schema.js';
import type { NotificationService } from '@/core/NotificationService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
import { MiFollowing, MiUserProfile } from '@/models/_.js';
import type { GlobalEvents, StreamEventEmitter } from '@/core/GlobalEventService.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { isJsonObject } from '@/misc/json-value.js';
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import type { ChannelsService } from './ChannelsService.js';
import { isJsonObject } from '@/misc/json-value.js';
import type { EventEmitter } from 'events';
import type Channel from './channel.js';
import type { ChannelConstructor } from './channel.js';
import type { ChannelRequest } from './channel.js';
import { ContextIdFactory, ModuleRef, REQUEST } from '@nestjs/core';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { MainChannel } from '@/server/api/stream/channels/main.js';
import { HomeTimelineChannel } from '@/server/api/stream/channels/home-timeline.js';
import { LocalTimelineChannel } from '@/server/api/stream/channels/local-timeline.js';
import { HybridTimelineChannel } from '@/server/api/stream/channels/hybrid-timeline.js';
import { GlobalTimelineChannel } from '@/server/api/stream/channels/global-timeline.js';
import { UserListChannel } from '@/server/api/stream/channels/user-list.js';
import { HashtagChannel } from '@/server/api/stream/channels/hashtag.js';
import { RoleTimelineChannel } from '@/server/api/stream/channels/role-timeline.js';
import { AntennaChannel } from '@/server/api/stream/channels/antenna.js';
import { ChannelChannel } from '@/server/api/stream/channels/channel.js';
import { DriveChannel } from '@/server/api/stream/channels/drive.js';
import { ServerStatsChannel } from '@/server/api/stream/channels/server-stats.js';
import { QueueStatsChannel } from '@/server/api/stream/channels/queue-stats.js';
import { AdminChannel } from '@/server/api/stream/channels/admin.js';
import { ChatUserChannel } from '@/server/api/stream/channels/chat-user.js';
import { ChatRoomChannel } from '@/server/api/stream/channels/chat-room.js';
import { ReversiChannel } from '@/server/api/stream/channels/reversi.js';
import { ReversiGameChannel } from '@/server/api/stream/channels/reversi-game.js';
const MAX_CHANNELS_PER_CONNECTION = 32;
@@ -26,6 +46,7 @@ const MAX_CHANNELS_PER_CONNECTION = 32;
* Main stream connection
*/
// eslint-disable-next-line import/no-default-export
@Injectable({ scope: Scope.TRANSIENT })
export default class Connection {
public user?: MiUser;
public token?: MiAccessToken;
@@ -44,16 +65,16 @@ export default class Connection {
private fetchIntervalId: NodeJS.Timeout | null = null;
constructor(
private channelsService: ChannelsService,
private moduleRef: ModuleRef,
private notificationService: NotificationService,
private cacheService: CacheService,
private channelFollowingService: ChannelFollowingService,
private channelMutingService: ChannelMutingService,
user: MiUser | null | undefined,
token: MiAccessToken | null | undefined,
@Inject(REQUEST)
request: ConnectionRequest,
) {
if (user) this.user = user;
if (token) this.token = token;
if (request.user) this.user = request.user;
if (request.token) this.token = request.token;
}
@bindThis
@@ -232,28 +253,34 @@ export default class Connection {
* チャンネルに接続
*/
@bindThis
public connectChannel(id: string, params: JsonObject | undefined, channel: string, pong = false) {
public async connectChannel(id: string, params: JsonObject | undefined, channel: string, pong = false) {
if (this.channels.length >= MAX_CHANNELS_PER_CONNECTION) {
return;
}
const channelService = this.channelsService.getChannelService(channel);
const channelConstructor = this.getChannelConstructor(channel);
if (channelService.requireCredential && this.user == null) {
if (channelConstructor.requireCredential && this.user == null) {
return;
}
if (this.token && ((channelService.kind && !this.token.permission.some(p => p === channelService.kind))
|| (!channelService.kind && channelService.requireCredential))) {
if (this.token && ((channelConstructor.kind && !this.token.permission.some(p => p === channelConstructor.kind))
|| (!channelConstructor.kind && channelConstructor.requireCredential))) {
return;
}
// 共有可能チャンネルに接続しようとしていて、かつそのチャンネルに既に接続していたら無意味なので無視
if (channelService.shouldShare && this.channels.some(c => c.chName === channel)) {
if (channelConstructor.shouldShare && this.channels.some(c => c.chName === channel)) {
return;
}
const ch: Channel = channelService.create(id, this);
const contextId = ContextIdFactory.create();
this.moduleRef.registerRequestByContextId<ChannelRequest>({
id: id,
connection: this,
}, contextId);
const ch: Channel = await this.moduleRef.create<Channel>(channelConstructor, contextId);
this.channels.push(ch);
ch.init(params ?? {});
@@ -264,6 +291,33 @@ export default class Connection {
}
}
@bindThis
public getChannelConstructor(name: string): ChannelConstructor<boolean> {
switch (name) {
case 'main': return MainChannel;
case 'homeTimeline': return HomeTimelineChannel;
case 'localTimeline': return LocalTimelineChannel;
case 'hybridTimeline': return HybridTimelineChannel;
case 'globalTimeline': return GlobalTimelineChannel;
case 'userList': return UserListChannel;
case 'hashtag': return HashtagChannel;
case 'roleTimeline': return RoleTimelineChannel;
case 'antenna': return AntennaChannel;
case 'channel': return ChannelChannel;
case 'drive': return DriveChannel;
case 'serverStats': return ServerStatsChannel;
case 'queueStats': return QueueStatsChannel;
case 'admin': return AdminChannel;
case 'chatUser': return ChatUserChannel;
case 'chatRoom': return ChatRoomChannel;
case 'reversi': return ReversiChannel;
case 'reversiGame': return ReversiGameChannel;
default:
throw new Error(`no such channel: ${name}`);
}
}
/**
* チャンネルから切断
* @param id チャンネルコネクションID
@@ -306,3 +360,8 @@ export default class Connection {
}
}
}
export interface ConnectionRequest {
user: MiUser | null | undefined,
token: MiAccessToken | null | undefined,
}

View File

@@ -22,7 +22,7 @@ export default abstract class Channel {
public abstract readonly chName: string;
public static readonly shouldShare: boolean;
public static readonly requireCredential: boolean;
public static readonly kind?: string | null;
public static readonly kind: string | null;
protected get user() {
return this.connection.user;
@@ -85,9 +85,9 @@ export default abstract class Channel {
return false;
}
constructor(id: string, connection: Connection) {
this.id = id;
this.connection = connection;
constructor(request: ChannelRequest) {
this.id = request.id;
this.connection = request.connection;
}
public send(payload: { type: string, body: JsonValue }): void;
@@ -111,9 +111,14 @@ export default abstract class Channel {
public onMessage?(type: string, body: JsonValue): void;
}
export type MiChannelService<T extends boolean> = {
export interface ChannelRequest {
id: string,
connection: Connection,
}
export interface ChannelConstructor<T extends boolean> {
new(...args: any[]): Channel;
shouldShare: boolean;
requireCredential: T;
kind: T extends true ? string : string | null | undefined;
create: (id: string, connection: Connection) => Channel;
};
}

View File

@@ -3,17 +3,26 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
class AdminChannel extends Channel {
@Injectable({ scope: Scope.TRANSIENT })
export class AdminChannel extends Channel {
public readonly chName = 'admin';
public static shouldShare = true;
public static requireCredential = true as const;
public static kind = 'read:admin:stream';
constructor(
@Inject(REQUEST)
request: ChannelRequest,
) {
super(request);
}
@bindThis
public async init(params: JsonObject) {
// Subscribe admin stream
@@ -22,22 +31,3 @@ class AdminChannel extends Channel {
});
}
}
@Injectable()
export class AdminChannelService implements MiChannelService<true> {
public readonly shouldShare = AdminChannel.shouldShare;
public readonly requireCredential = AdminChannel.requireCredential;
public readonly kind = AdminChannel.kind;
constructor(
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): AdminChannel {
return new AdminChannel(
id,
connection,
);
}
}

View File

@@ -3,14 +3,16 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
class AntennaChannel extends Channel {
@Injectable({ scope: Scope.TRANSIENT })
export class AntennaChannel extends Channel {
public readonly chName = 'antenna';
public static shouldShare = false;
public static requireCredential = true as const;
@@ -18,12 +20,12 @@ class AntennaChannel extends Channel {
private antennaId: string;
constructor(
private noteEntityService: NoteEntityService,
@Inject(REQUEST)
request: ChannelRequest,
id: string,
connection: Channel['connection'],
private noteEntityService: NoteEntityService,
) {
super(id, connection);
super(request);
//this.onEvent = this.onEvent.bind(this);
}
@@ -55,24 +57,3 @@ class AntennaChannel extends Channel {
this.subscriber.off(`antennaStream:${this.antennaId}`, this.onEvent);
}
}
@Injectable()
export class AntennaChannelService implements MiChannelService<true> {
public readonly shouldShare = AntennaChannel.shouldShare;
public readonly requireCredential = AntennaChannel.requireCredential;
public readonly kind = AntennaChannel.kind;
constructor(
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): AntennaChannel {
return new AntennaChannel(
this.noteEntityService,
id,
connection,
);
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
@@ -11,20 +11,23 @@ import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
class ChannelChannel extends Channel {
@Injectable({ scope: Scope.TRANSIENT })
export class ChannelChannel extends Channel {
public readonly chName = 'channel';
public static shouldShare = false;
public static requireCredential = false as const;
private channelId: string;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private noteEntityService: NoteEntityService,
id: string,
connection: Channel['connection'],
) {
super(id, connection);
super(request);
//this.onNote = this.onNote.bind(this);
}
@@ -92,24 +95,3 @@ class ChannelChannel extends Channel {
this.subscriber.off('notesStream', this.onNote);
}
}
@Injectable()
export class ChannelChannelService implements MiChannelService<false> {
public readonly shouldShare = ChannelChannel.shouldShare;
public readonly requireCredential = ChannelChannel.requireCredential;
public readonly kind = ChannelChannel.kind;
constructor(
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): ChannelChannel {
return new ChannelChannel(
this.noteEntityService,
id,
connection,
);
}
}

View File

@@ -3,14 +3,16 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { JsonObject } from '@/misc/json-value.js';
import { ChatService } from '@/core/ChatService.js';
import Channel, { type MiChannelService } from '../channel.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
class ChatRoomChannel extends Channel {
@Injectable({ scope: Scope.TRANSIENT })
export class ChatRoomChannel extends Channel {
public readonly chName = 'chatRoom';
public static shouldShare = false;
public static requireCredential = true as const;
@@ -18,12 +20,12 @@ class ChatRoomChannel extends Channel {
private roomId: string;
constructor(
private chatService: ChatService,
@Inject(REQUEST)
request: ChannelRequest,
id: string,
connection: Channel['connection'],
private chatService: ChatService,
) {
super(id, connection);
super(request);
}
@bindThis
@@ -55,24 +57,3 @@ class ChatRoomChannel extends Channel {
this.subscriber.off(`chatRoomStream:${this.roomId}`, this.onEvent);
}
}
@Injectable()
export class ChatRoomChannelService implements MiChannelService<true> {
public readonly shouldShare = ChatRoomChannel.shouldShare;
public readonly requireCredential = ChatRoomChannel.requireCredential;
public readonly kind = ChatRoomChannel.kind;
constructor(
private chatService: ChatService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): ChatRoomChannel {
return new ChatRoomChannel(
this.chatService,
id,
connection,
);
}
}

View File

@@ -3,14 +3,16 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { JsonObject } from '@/misc/json-value.js';
import { ChatService } from '@/core/ChatService.js';
import Channel, { type MiChannelService } from '../channel.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
class ChatUserChannel extends Channel {
@Injectable({ scope: Scope.TRANSIENT })
export class ChatUserChannel extends Channel {
public readonly chName = 'chatUser';
public static shouldShare = false;
public static requireCredential = true as const;
@@ -18,12 +20,12 @@ class ChatUserChannel extends Channel {
private otherId: string;
constructor(
private chatService: ChatService,
@Inject(REQUEST)
request: ChannelRequest,
id: string,
connection: Channel['connection'],
private chatService: ChatService,
) {
super(id, connection);
super(request);
}
@bindThis
@@ -55,24 +57,3 @@ class ChatUserChannel extends Channel {
this.subscriber.off(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent);
}
}
@Injectable()
export class ChatUserChannelService implements MiChannelService<true> {
public readonly shouldShare = ChatUserChannel.shouldShare;
public readonly requireCredential = ChatUserChannel.requireCredential;
public readonly kind = ChatUserChannel.kind;
constructor(
private chatService: ChatService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): ChatUserChannel {
return new ChatUserChannel(
this.chatService,
id,
connection,
);
}
}

View File

@@ -3,17 +3,26 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
class DriveChannel extends Channel {
@Injectable({ scope: Scope.TRANSIENT })
export class DriveChannel extends Channel {
public readonly chName = 'drive';
public static shouldShare = true;
public static requireCredential = true as const;
public static kind = 'read:account';
constructor(
@Inject(REQUEST)
request: ChannelRequest,
) {
super(request);
}
@bindThis
public async init(params: JsonObject) {
// Subscribe drive stream
@@ -22,22 +31,3 @@ class DriveChannel extends Channel {
});
}
}
@Injectable()
export class DriveChannelService implements MiChannelService<true> {
public readonly shouldShare = DriveChannel.shouldShare;
public readonly requireCredential = DriveChannel.requireCredential;
public readonly kind = DriveChannel.kind;
constructor(
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): DriveChannel {
return new DriveChannel(
id,
connection,
);
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import type { Packed } from '@/misc/json-schema.js';
import { MetaService } from '@/core/MetaService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
@@ -11,9 +11,11 @@ import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
class GlobalTimelineChannel extends Channel {
@Injectable({ scope: Scope.TRANSIENT })
export class GlobalTimelineChannel extends Channel {
public readonly chName = 'globalTimeline';
public static shouldShare = false;
public static requireCredential = false as const;
@@ -21,14 +23,14 @@ class GlobalTimelineChannel extends Channel {
private withFiles: boolean;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private metaService: MetaService,
private roleService: RoleService,
private noteEntityService: NoteEntityService,
id: string,
connection: Channel['connection'],
) {
super(id, connection);
super(request);
//this.onNote = this.onNote.bind(this);
}
@@ -74,28 +76,3 @@ class GlobalTimelineChannel extends Channel {
this.subscriber.off('notesStream', this.onNote);
}
}
@Injectable()
export class GlobalTimelineChannelService implements MiChannelService<false> {
public readonly shouldShare = GlobalTimelineChannel.shouldShare;
public readonly requireCredential = GlobalTimelineChannel.requireCredential;
public readonly kind = GlobalTimelineChannel.kind;
constructor(
private metaService: MetaService,
private roleService: RoleService,
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): GlobalTimelineChannel {
return new GlobalTimelineChannel(
this.metaService,
this.roleService,
this.noteEntityService,
id,
connection,
);
}
}

View File

@@ -3,28 +3,30 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
class HashtagChannel extends Channel {
@Injectable({ scope: Scope.TRANSIENT })
export class HashtagChannel extends Channel {
public readonly chName = 'hashtag';
public static shouldShare = false;
public static requireCredential = false as const;
private q: string[][];
constructor(
private noteEntityService: NoteEntityService,
@Inject(REQUEST)
request: ChannelRequest,
id: string,
connection: Channel['connection'],
private noteEntityService: NoteEntityService,
) {
super(id, connection);
super(request);
//this.onNote = this.onNote.bind(this);
}
@@ -62,24 +64,3 @@ class HashtagChannel extends Channel {
this.subscriber.off('notesStream', this.onNote);
}
}
@Injectable()
export class HashtagChannelService implements MiChannelService<false> {
public readonly shouldShare = HashtagChannel.shouldShare;
public readonly requireCredential = HashtagChannel.requireCredential;
public readonly kind = HashtagChannel.kind;
constructor(
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): HashtagChannel {
return new HashtagChannel(
this.noteEntityService,
id,
connection,
);
}
}

View File

@@ -3,15 +3,17 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
class HomeTimelineChannel extends Channel {
@Injectable({ scope: Scope.TRANSIENT })
export class HomeTimelineChannel extends Channel {
public readonly chName = 'homeTimeline';
public static shouldShare = false;
public static requireCredential = true as const;
@@ -20,12 +22,12 @@ class HomeTimelineChannel extends Channel {
private withFiles: boolean;
constructor(
private noteEntityService: NoteEntityService,
@Inject(REQUEST)
request: ChannelRequest,
id: string,
connection: Channel['connection'],
private noteEntityService: NoteEntityService,
) {
super(id, connection);
super(request);
//this.onNote = this.onNote.bind(this);
}
@@ -98,24 +100,3 @@ class HomeTimelineChannel extends Channel {
this.subscriber.off('notesStream', this.onNote);
}
}
@Injectable()
export class HomeTimelineChannelService implements MiChannelService<true> {
public readonly shouldShare = HomeTimelineChannel.shouldShare;
public readonly requireCredential = HomeTimelineChannel.requireCredential;
public readonly kind = HomeTimelineChannel.kind;
constructor(
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): HomeTimelineChannel {
return new HomeTimelineChannel(
this.noteEntityService,
id,
connection,
);
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import type { Packed } from '@/misc/json-schema.js';
import { MetaService } from '@/core/MetaService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
@@ -11,9 +11,11 @@ import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
class HybridTimelineChannel extends Channel {
@Injectable({ scope: Scope.TRANSIENT })
export class HybridTimelineChannel extends Channel {
public readonly chName = 'hybridTimeline';
public static shouldShare = false;
public static requireCredential = true as const;
@@ -23,14 +25,14 @@ class HybridTimelineChannel extends Channel {
private withFiles: boolean;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private metaService: MetaService,
private roleService: RoleService,
private noteEntityService: NoteEntityService,
id: string,
connection: Channel['connection'],
) {
super(id, connection);
super(request);
//this.onNote = this.onNote.bind(this);
}
@@ -118,28 +120,3 @@ class HybridTimelineChannel extends Channel {
this.subscriber.off('notesStream', this.onNote);
}
}
@Injectable()
export class HybridTimelineChannelService implements MiChannelService<true> {
public readonly shouldShare = HybridTimelineChannel.shouldShare;
public readonly requireCredential = HybridTimelineChannel.requireCredential;
public readonly kind = HybridTimelineChannel.kind;
constructor(
private metaService: MetaService,
private roleService: RoleService,
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): HybridTimelineChannel {
return new HybridTimelineChannel(
this.metaService,
this.roleService,
this.noteEntityService,
id,
connection,
);
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import type { Packed } from '@/misc/json-schema.js';
import { MetaService } from '@/core/MetaService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
@@ -11,25 +11,27 @@ import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
class LocalTimelineChannel extends Channel {
@Injectable({ scope: Scope.TRANSIENT })
export class LocalTimelineChannel extends Channel {
public readonly chName = 'localTimeline';
public static shouldShare = false;
public static shouldShare = false as const;
public static requireCredential = false as const;
private withRenotes: boolean;
private withReplies: boolean;
private withFiles: boolean;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private metaService: MetaService,
private roleService: RoleService,
private noteEntityService: NoteEntityService,
id: string,
connection: Channel['connection'],
) {
super(id, connection);
super(request);
//this.onNote = this.onNote.bind(this);
}
@@ -84,28 +86,3 @@ class LocalTimelineChannel extends Channel {
this.subscriber.off('notesStream', this.onNote);
}
}
@Injectable()
export class LocalTimelineChannelService implements MiChannelService<false> {
public readonly shouldShare = LocalTimelineChannel.shouldShare;
public readonly requireCredential = LocalTimelineChannel.requireCredential;
public readonly kind = LocalTimelineChannel.kind;
constructor(
private metaService: MetaService,
private roleService: RoleService,
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): LocalTimelineChannel {
return new LocalTimelineChannel(
this.metaService,
this.roleService,
this.noteEntityService,
id,
connection,
);
}
}

View File

@@ -3,26 +3,28 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { isInstanceMuted, isUserFromMutedInstance } from '@/misc/is-instance-muted.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
class MainChannel extends Channel {
@Injectable({ scope: Scope.TRANSIENT })
export class MainChannel extends Channel {
public readonly chName = 'main';
public static shouldShare = true;
public static requireCredential = true as const;
public static kind = 'read:account';
constructor(
private noteEntityService: NoteEntityService,
@Inject(REQUEST)
request: ChannelRequest,
id: string,
connection: Channel['connection'],
private noteEntityService: NoteEntityService,
) {
super(id, connection);
super(request);
}
@bindThis
@@ -61,24 +63,3 @@ class MainChannel extends Channel {
});
}
}
@Injectable()
export class MainChannelService implements MiChannelService<true> {
public readonly shouldShare = MainChannel.shouldShare;
public readonly requireCredential = MainChannel.requireCredential;
public readonly kind = MainChannel.kind;
constructor(
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): MainChannel {
return new MainChannel(
this.noteEntityService,
id,
connection,
);
}
}

View File

@@ -4,21 +4,26 @@
*/
import Xev from 'xev';
import { Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { isJsonObject } from '@/misc/json-value.js';
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
const ev = new Xev();
class QueueStatsChannel extends Channel {
@Injectable({ scope: Scope.TRANSIENT })
export class QueueStatsChannel extends Channel {
public readonly chName = 'queueStats';
public static shouldShare = true;
public static requireCredential = false as const;
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
constructor(
@Inject(REQUEST)
request: ChannelRequest,
) {
super(request);
//this.onStats = this.onStats.bind(this);
//this.onMessage = this.onMessage.bind(this);
}
@@ -56,22 +61,3 @@ class QueueStatsChannel extends Channel {
ev.removeListener('queueStats', this.onStats);
}
}
@Injectable()
export class QueueStatsChannelService implements MiChannelService<false> {
public readonly shouldShare = QueueStatsChannel.shouldShare;
public readonly requireCredential = QueueStatsChannel.requireCredential;
public readonly kind = QueueStatsChannel.kind;
constructor(
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): QueueStatsChannel {
return new QueueStatsChannel(
id,
connection,
);
}
}

View File

@@ -3,31 +3,32 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import type { MiReversiGame } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { ReversiService } from '@/core/ReversiService.js';
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
import { isJsonObject } from '@/misc/json-value.js';
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { reversiUpdateKeys } from 'misskey-js';
import { REQUEST } from '@nestjs/core';
class ReversiGameChannel extends Channel {
@Injectable({ scope: Scope.TRANSIENT })
export class ReversiGameChannel extends Channel {
public readonly chName = 'reversiGame';
public static shouldShare = false;
public static requireCredential = false as const;
private gameId: MiReversiGame['id'] | null = null;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private reversiService: ReversiService,
private reversiGameEntityService: ReversiGameEntityService,
id: string,
connection: Channel['connection'],
) {
super(id, connection);
super(request);
}
@bindThis
@@ -107,25 +108,3 @@ class ReversiGameChannel extends Channel {
}
}
@Injectable()
export class ReversiGameChannelService implements MiChannelService<false> {
public readonly shouldShare = ReversiGameChannel.shouldShare;
public readonly requireCredential = ReversiGameChannel.requireCredential;
public readonly kind = ReversiGameChannel.kind;
constructor(
private reversiService: ReversiService,
private reversiGameEntityService: ReversiGameEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): ReversiGameChannel {
return new ReversiGameChannel(
this.reversiService,
this.reversiGameEntityService,
id,
connection,
);
}
}

View File

@@ -3,22 +3,24 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
class ReversiChannel extends Channel {
@Injectable({ scope: Scope.TRANSIENT })
export class ReversiChannel extends Channel {
public readonly chName = 'reversi';
public static shouldShare = true;
public static requireCredential = true as const;
public static kind = 'read:account';
constructor(
id: string,
connection: Channel['connection'],
@Inject(REQUEST)
request: ChannelRequest,
) {
super(id, connection);
super(request);
}
@bindThis
@@ -32,22 +34,3 @@ class ReversiChannel extends Channel {
this.subscriber.off(`reversiStream:${this.user!.id}`, this.send);
}
}
@Injectable()
export class ReversiChannelService implements MiChannelService<true> {
public readonly shouldShare = ReversiChannel.shouldShare;
public readonly requireCredential = ReversiChannel.requireCredential;
public readonly kind = ReversiChannel.kind;
constructor(
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): ReversiChannel {
return new ReversiChannel(
id,
connection,
);
}
}

View File

@@ -3,28 +3,30 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
class RoleTimelineChannel extends Channel {
@Injectable({ scope: Scope.TRANSIENT })
export class RoleTimelineChannel extends Channel {
public readonly chName = 'roleTimeline';
public static shouldShare = false;
public static requireCredential = false as const;
private roleId: string;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private noteEntityService: NoteEntityService,
private roleservice: RoleService,
id: string,
connection: Channel['connection'],
) {
super(id, connection);
super(request);
//this.onNote = this.onNote.bind(this);
}
@@ -60,26 +62,3 @@ class RoleTimelineChannel extends Channel {
this.subscriber.off(`roleTimelineStream:${this.roleId}`, this.onEvent);
}
}
@Injectable()
export class RoleTimelineChannelService implements MiChannelService<false> {
public readonly shouldShare = RoleTimelineChannel.shouldShare;
public readonly requireCredential = RoleTimelineChannel.requireCredential;
public readonly kind = RoleTimelineChannel.kind;
constructor(
private noteEntityService: NoteEntityService,
private roleservice: RoleService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): RoleTimelineChannel {
return new RoleTimelineChannel(
this.noteEntityService,
this.roleservice,
id,
connection,
);
}
}

View File

@@ -4,21 +4,26 @@
*/
import Xev from 'xev';
import { Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { isJsonObject } from '@/misc/json-value.js';
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
const ev = new Xev();
class ServerStatsChannel extends Channel {
@Injectable({ scope: Scope.TRANSIENT })
export class ServerStatsChannel extends Channel {
public readonly chName = 'serverStats';
public static shouldShare = true;
public static requireCredential = false as const;
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
constructor(
@Inject(REQUEST)
request: ChannelRequest,
) {
super(request);
//this.onStats = this.onStats.bind(this);
//this.onMessage = this.onMessage.bind(this);
}
@@ -54,22 +59,3 @@ class ServerStatsChannel extends Channel {
ev.removeListener('serverStats', this.onStats);
}
}
@Injectable()
export class ServerStatsChannelService implements MiChannelService<false> {
public readonly shouldShare = ServerStatsChannel.shouldShare;
public readonly requireCredential = ServerStatsChannel.requireCredential;
public readonly kind = ServerStatsChannel.kind;
constructor(
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): ServerStatsChannel {
return new ServerStatsChannel(
id,
connection,
);
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
@@ -11,9 +11,11 @@ import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
class UserListChannel extends Channel {
@Injectable({ scope: Scope.TRANSIENT })
export class UserListChannel extends Channel {
public readonly chName = 'userList';
public static shouldShare = false;
public static requireCredential = false as const;
@@ -24,14 +26,18 @@ class UserListChannel extends Channel {
private withRenotes: boolean;
constructor(
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
private userListMembershipsRepository: UserListMembershipsRepository,
private noteEntityService: NoteEntityService,
id: string,
connection: Channel['connection'],
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
@Inject(REQUEST)
request: ChannelRequest,
private noteEntityService: NoteEntityService,
) {
super(id, connection);
super(request);
//this.updateListUsers = this.updateListUsers.bind(this);
//this.onNote = this.onNote.bind(this);
}
@@ -130,32 +136,3 @@ class UserListChannel extends Channel {
clearInterval(this.listUsersClock);
}
}
@Injectable()
export class UserListChannelService implements MiChannelService<false> {
public readonly shouldShare = UserListChannel.shouldShare;
public readonly requireCredential = UserListChannel.requireCredential;
public readonly kind = UserListChannel.kind;
constructor(
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): UserListChannel {
return new UserListChannel(
this.userListsRepository,
this.userListMembershipsRepository,
this.noteEntityService,
id,
connection,
);
}
}

View File

@@ -12,12 +12,9 @@ import ipaddr from 'ipaddr.js';
import oauth2orize, { type OAuth2, AuthorizationError, ValidateFunctionArity2, OAuth2Req, MiddlewareRequest } from 'oauth2orize';
import oauth2Pkce from 'oauth2orize-pkce';
import fastifyCors from '@fastify/cors';
import fastifyView from '@fastify/view';
import pug from 'pug';
import bodyParser from 'body-parser';
import fastifyExpress from '@fastify/express';
import { verifyChallenge } from 'pkce-challenge';
import { mf2 } from 'microformats-parser';
import { permissions as kinds } from 'misskey-js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
@@ -32,6 +29,8 @@ import { MemoryKVCache } from '@/misc/cache.js';
import { LoggerService } from '@/core/LoggerService.js';
import Logger from '@/logger.js';
import { StatusError } from '@/misc/status-error.js';
import { HtmlTemplateService } from '@/server/web/HtmlTemplateService.js';
import { OAuthPage } from '@/server/web/views/oauth.js';
import type { ServerResponse } from 'node:http';
import type { FastifyInstance } from 'fastify';
@@ -98,6 +97,32 @@ interface ClientInformation {
logo: string | null;
}
function parseMicroformats(doc: htmlParser.HTMLElement, baseUrl: string, id: string): { name: string | null; logo: string | null; } {
let name: string | null = null;
let logo: string | null = null;
const hApp = doc.querySelector('.h-app');
if (hApp == null) return { name, logo };
const nameEl = hApp.querySelector('.p-name');
if (nameEl != null) {
const href = nameEl.attributes.href || nameEl.attributes.src;
if (href != null && new URL(href, baseUrl).toString() === new URL(id).toString()) {
name = nameEl.textContent.trim();
}
}
const logoEl = hApp.querySelector('.u-logo');
if (logoEl != null) {
const href = logoEl.attributes.href || logoEl.attributes.src;
if (href != null) {
logo = new URL(href, baseUrl).toString();
}
}
return { name, logo };
}
// https://indieauth.spec.indieweb.org/#client-information-discovery
// "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id,
// and if there is an [h-app] with a url property matching the client_id URL,
@@ -120,24 +145,19 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt
}
const text = await res.text();
const fragment = htmlParser.parse(`<div>${text}</div>`);
const doc = htmlParser.parse(`<div>${text}</div>`);
redirectUris.push(...[...fragment.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.attributes.href));
redirectUris.push(...[...doc.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.attributes.href));
let name = id;
let logo: string | null = null;
if (text) {
const microformats = mf2(text, { baseUrl: res.url });
const correspondingProperties = microformats.items.find(item => item.type?.includes('h-app') && item.properties.url.includes(id));
if (correspondingProperties) {
const nameProperty = correspondingProperties.properties.name?.[0];
if (typeof nameProperty === 'string') {
name = nameProperty;
}
const logoProperty = correspondingProperties.properties.logo?.[0];
if (typeof logoProperty === 'string') {
logo = logoProperty;
}
const microformats = parseMicroformats(doc, res.url, id);
if (typeof microformats.name === 'string') {
name = microformats.name;
}
if (typeof microformats.logo === 'string') {
logo = microformats.logo;
}
}
@@ -253,6 +273,7 @@ export class OAuth2ProviderService {
private usersRepository: UsersRepository,
private cacheService: CacheService,
loggerService: LoggerService,
private htmlTemplateService: HtmlTemplateService,
) {
this.#logger = loggerService.getLogger('oauth');
@@ -386,24 +407,16 @@ export class OAuth2ProviderService {
this.#logger.info(`Rendering authorization page for "${oauth2.client.name}"`);
reply.header('Cache-Control', 'no-store');
return await reply.view('oauth', {
return await HtmlTemplateService.replyHtml(reply, OAuthPage({
...await this.htmlTemplateService.getCommonData(),
transactionId: oauth2.transactionID,
clientName: oauth2.client.name,
clientLogo: oauth2.client.logo,
scope: oauth2.req.scope.join(' '),
});
clientLogo: oauth2.client.logo ?? undefined,
scope: oauth2.req.scope,
}));
});
fastify.post('/decision', async () => { });
fastify.register(fastifyView, {
root: fileURLToPath(new URL('../web/views', import.meta.url)),
engine: { pug },
defaultContext: {
version: this.config.version,
config: this.config,
},
});
await fastify.register(fastifyExpress);
fastify.use('/authorize', this.#server.authorize(((areq, done) => {
(async (): Promise<Parameters<typeof done>> => {

View File

@@ -9,20 +9,16 @@ import { fileURLToPath } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import sharp from 'sharp';
import pug from 'pug';
import { In, IsNull } from 'typeorm';
import fastifyStatic from '@fastify/static';
import fastifyView from '@fastify/view';
import fastifyProxy from '@fastify/http-proxy';
import vary from 'vary';
import type { Config } from '@/config.js';
import { getNoteSummary } from '@/misc/get-note-summary.js';
import { DI } from '@/di-symbols.js';
import * as Acct from '@/misc/acct.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { PageEntityService } from '@/core/entities/PageEntityService.js';
import { MetaEntityService } from '@/core/entities/MetaEntityService.js';
import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js';
import { ClipEntityService } from '@/core/entities/ClipEntityService.js';
import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
@@ -41,14 +37,33 @@ import type {
} from '@/models/_.js';
import type Logger from '@/logger.js';
import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js';
import { htmlSafeJsonStringify } from '@/misc/json-stringify-html-safe.js';
import { bindThis } from '@/decorators.js';
import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
import { RoleService } from '@/core/RoleService.js';
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import { FeedService } from './FeedService.js';
import { UrlPreviewService } from './UrlPreviewService.js';
import { ClientLoggerService } from './ClientLoggerService.js';
import { HtmlTemplateService } from './HtmlTemplateService.js';
import { BasePage } from './views/base.js';
import { UserPage } from './views/user.js';
import { NotePage } from './views/note.js';
import { PagePage } from './views/page.js';
import { ClipPage } from './views/clip.js';
import { FlashPage } from './views/flash.js';
import { GalleryPostPage } from './views/gallery-post.js';
import { ChannelPage } from './views/channel.js';
import { ReversiGamePage } from './views/reversi-game.js';
import { AnnouncementPage } from './views/announcement.js';
import { BaseEmbed } from './views/base-embed.js';
import { InfoCardPage } from './views/info-card.js';
import { BiosPage } from './views/bios.js';
import { CliPage } from './views/cli.js';
import { FlushPage } from './views/flush.js';
import { ErrorPage } from './views/error.js';
import type { FastifyError, FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify';
const _filename = fileURLToPath(import.meta.url);
@@ -62,20 +77,6 @@ const frontendViteOut = `${_dirname}/../../../../../built/_frontend_vite_/`;
const frontendEmbedViteOut = `${_dirname}/../../../../../built/_frontend_embed_vite_/`;
const tarball = `${_dirname}/../../../../../built/tarball/`;
const ESCAPE_LOOKUP = {
'&': '\\u0026',
'>': '\\u003e',
'<': '\\u003c',
'\u2028': '\\u2028',
'\u2029': '\\u2029',
} as Record<string, string>;
const ESCAPE_REGEX = /[&><\u2028\u2029]/g;
function htmlSafeJsonStringify(obj: any): string {
return JSON.stringify(obj).replace(ESCAPE_REGEX, x => ESCAPE_LOOKUP[x]);
}
@Injectable()
export class ClientServerService {
private logger: Logger;
@@ -121,7 +122,6 @@ export class ClientServerService {
private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService,
private pageEntityService: PageEntityService,
private metaEntityService: MetaEntityService,
private galleryPostEntityService: GalleryPostEntityService,
private clipEntityService: ClipEntityService,
private channelEntityService: ChannelEntityService,
@@ -129,7 +129,7 @@ export class ClientServerService {
private announcementEntityService: AnnouncementEntityService,
private urlPreviewService: UrlPreviewService,
private feedService: FeedService,
private roleService: RoleService,
private htmlTemplateService: HtmlTemplateService,
private clientLoggerService: ClientLoggerService,
) {
//this.createServer = this.createServer.bind(this);
@@ -195,38 +195,10 @@ export class ClientServerService {
return (manifest);
}
@bindThis
private async generateCommonPugData(meta: MiMeta) {
return {
instanceName: meta.name ?? 'Misskey',
icon: meta.iconUrl,
appleTouchIcon: meta.app512IconUrl,
themeColor: meta.themeColor,
serverErrorImageUrl: meta.serverErrorImageUrl ?? 'https://xn--931a.moe/assets/error.jpg',
infoImageUrl: meta.infoImageUrl ?? 'https://xn--931a.moe/assets/info.jpg',
notFoundImageUrl: meta.notFoundImageUrl ?? 'https://xn--931a.moe/assets/not-found.jpg',
instanceUrl: this.config.url,
metaJson: htmlSafeJsonStringify(await this.metaEntityService.packDetailed(meta)),
now: Date.now(),
federationEnabled: this.meta.federation !== 'none',
};
}
@bindThis
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
const configUrl = new URL(this.config.url);
fastify.register(fastifyView, {
root: _dirname + '/views',
engine: {
pug: pug,
},
defaultContext: {
version: this.config.version,
config: this.config,
},
});
fastify.addHook('onRequest', (request, reply, done) => {
// クリックジャッキング防止のためiFrameの中に入れられないようにする
reply.header('X-Frame-Options', 'DENY');
@@ -427,16 +399,15 @@ export class ClientServerService {
//#endregion
const renderBase = async (reply: FastifyReply, data: { [key: string]: any } = {}) => {
const renderBase = async (reply: FastifyReply, data: Partial<Parameters<typeof BasePage>[0]> = {}) => {
reply.header('Cache-Control', 'public, max-age=30');
return await reply.view('base', {
img: this.meta.bannerUrl,
url: this.config.url,
return await HtmlTemplateService.replyHtml(reply, BasePage({
img: this.meta.bannerUrl ?? undefined,
title: this.meta.name ?? 'Misskey',
desc: this.meta.description,
...await this.generateCommonPugData(this.meta),
desc: this.meta.description ?? undefined,
...await this.htmlTemplateService.getCommonData(),
...data,
});
}));
};
// URL preview endpoint
@@ -518,11 +489,6 @@ export class ClientServerService {
)
) {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
const me = profile.fields
? profile.fields
.filter(filed => filed.value != null && filed.value.match(/^https?:/))
.map(field => field.value)
: [];
reply.header('Cache-Control', 'public, max-age=15');
if (profile.preventAiLearning) {
@@ -535,15 +501,15 @@ export class ClientServerService {
userProfile: profile,
});
return await reply.view('user', {
user, profile, me,
avatarUrl: _user.avatarUrl,
return await HtmlTemplateService.replyHtml(reply, UserPage({
user: _user,
profile,
sub: request.params.sub,
...await this.generateCommonPugData(this.meta),
clientCtx: htmlSafeJsonStringify({
...await this.htmlTemplateService.getCommonData(),
clientCtxJson: htmlSafeJsonStringify({
user: _user,
}),
});
}));
} else {
// リモートユーザーなので
// モデレータがAPI経由で参照可能にするために404にはしない
@@ -594,17 +560,14 @@ export class ClientServerService {
reply.header('X-Robots-Tag', 'noimageai');
reply.header('X-Robots-Tag', 'noai');
}
return await reply.view('note', {
return await HtmlTemplateService.replyHtml(reply, NotePage({
note: _note,
profile,
avatarUrl: _note.user.avatarUrl,
// TODO: Let locale changeable by instance setting
summary: getNoteSummary(_note),
...await this.generateCommonPugData(this.meta),
clientCtx: htmlSafeJsonStringify({
...await this.htmlTemplateService.getCommonData(),
clientCtxJson: htmlSafeJsonStringify({
note: _note,
}),
});
}));
} else {
return await renderBase(reply);
}
@@ -637,12 +600,11 @@ export class ClientServerService {
reply.header('X-Robots-Tag', 'noimageai');
reply.header('X-Robots-Tag', 'noai');
}
return await reply.view('page', {
return await HtmlTemplateService.replyHtml(reply, PagePage({
page: _page,
profile,
avatarUrl: _page.user.avatarUrl,
...await this.generateCommonPugData(this.meta),
});
...await this.htmlTemplateService.getCommonData(),
}));
} else {
return await renderBase(reply);
}
@@ -662,12 +624,11 @@ export class ClientServerService {
reply.header('X-Robots-Tag', 'noimageai');
reply.header('X-Robots-Tag', 'noai');
}
return await reply.view('flash', {
return await HtmlTemplateService.replyHtml(reply, FlashPage({
flash: _flash,
profile,
avatarUrl: _flash.user.avatarUrl,
...await this.generateCommonPugData(this.meta),
});
...await this.htmlTemplateService.getCommonData(),
}));
} else {
return await renderBase(reply);
}
@@ -687,15 +648,14 @@ export class ClientServerService {
reply.header('X-Robots-Tag', 'noimageai');
reply.header('X-Robots-Tag', 'noai');
}
return await reply.view('clip', {
return await HtmlTemplateService.replyHtml(reply, ClipPage({
clip: _clip,
profile,
avatarUrl: _clip.user.avatarUrl,
...await this.generateCommonPugData(this.meta),
clientCtx: htmlSafeJsonStringify({
...await this.htmlTemplateService.getCommonData(),
clientCtxJson: htmlSafeJsonStringify({
clip: _clip,
}),
});
}));
} else {
return await renderBase(reply);
}
@@ -713,12 +673,11 @@ export class ClientServerService {
reply.header('X-Robots-Tag', 'noimageai');
reply.header('X-Robots-Tag', 'noai');
}
return await reply.view('gallery-post', {
post: _post,
return await HtmlTemplateService.replyHtml(reply, GalleryPostPage({
galleryPost: _post,
profile,
avatarUrl: _post.user.avatarUrl,
...await this.generateCommonPugData(this.meta),
});
...await this.htmlTemplateService.getCommonData(),
}));
} else {
return await renderBase(reply);
}
@@ -733,10 +692,10 @@ export class ClientServerService {
if (channel) {
const _channel = await this.channelEntityService.pack(channel);
reply.header('Cache-Control', 'public, max-age=15');
return await reply.view('channel', {
return await HtmlTemplateService.replyHtml(reply, ChannelPage({
channel: _channel,
...await this.generateCommonPugData(this.meta),
});
...await this.htmlTemplateService.getCommonData(),
}));
} else {
return await renderBase(reply);
}
@@ -751,10 +710,10 @@ export class ClientServerService {
if (game) {
const _game = await this.reversiGameEntityService.packDetail(game);
reply.header('Cache-Control', 'public, max-age=3600');
return await reply.view('reversi-game', {
game: _game,
...await this.generateCommonPugData(this.meta),
});
return await HtmlTemplateService.replyHtml(reply, ReversiGamePage({
reversiGame: _game,
...await this.htmlTemplateService.getCommonData(),
}));
} else {
return await renderBase(reply);
}
@@ -770,10 +729,10 @@ export class ClientServerService {
if (announcement) {
const _announcement = await this.announcementEntityService.pack(announcement);
reply.header('Cache-Control', 'public, max-age=3600');
return await reply.view('announcement', {
return await HtmlTemplateService.replyHtml(reply, AnnouncementPage({
announcement: _announcement,
...await this.generateCommonPugData(this.meta),
});
...await this.htmlTemplateService.getCommonData(),
}));
} else {
return await renderBase(reply);
}
@@ -806,13 +765,13 @@ export class ClientServerService {
const _user = await this.userEntityService.pack(user);
reply.header('Cache-Control', 'public, max-age=3600');
return await reply.view('base-embed', {
return await HtmlTemplateService.replyHtml(reply, BaseEmbed({
title: this.meta.name ?? 'Misskey',
...await this.generateCommonPugData(this.meta),
embedCtx: htmlSafeJsonStringify({
...await this.htmlTemplateService.getCommonData(),
embedCtxJson: htmlSafeJsonStringify({
user: _user,
}),
});
}));
});
fastify.get<{ Params: { note: string; } }>('/embed/notes/:note', async (request, reply) => {
@@ -832,13 +791,13 @@ export class ClientServerService {
const _note = await this.noteEntityService.pack(note, null, { detail: true });
reply.header('Cache-Control', 'public, max-age=3600');
return await reply.view('base-embed', {
return await HtmlTemplateService.replyHtml(reply, BaseEmbed({
title: this.meta.name ?? 'Misskey',
...await this.generateCommonPugData(this.meta),
embedCtx: htmlSafeJsonStringify({
...await this.htmlTemplateService.getCommonData(),
embedCtxJson: htmlSafeJsonStringify({
note: _note,
}),
});
}));
});
fastify.get<{ Params: { clip: string; } }>('/embed/clips/:clip', async (request, reply) => {
@@ -853,48 +812,46 @@ export class ClientServerService {
const _clip = await this.clipEntityService.pack(clip);
reply.header('Cache-Control', 'public, max-age=3600');
return await reply.view('base-embed', {
return await HtmlTemplateService.replyHtml(reply, BaseEmbed({
title: this.meta.name ?? 'Misskey',
...await this.generateCommonPugData(this.meta),
embedCtx: htmlSafeJsonStringify({
...await this.htmlTemplateService.getCommonData(),
embedCtxJson: htmlSafeJsonStringify({
clip: _clip,
}),
});
}));
});
fastify.get('/embed/*', async (request, reply) => {
reply.removeHeader('X-Frame-Options');
reply.header('Cache-Control', 'public, max-age=3600');
return await reply.view('base-embed', {
return await HtmlTemplateService.replyHtml(reply, BaseEmbed({
title: this.meta.name ?? 'Misskey',
...await this.generateCommonPugData(this.meta),
});
...await this.htmlTemplateService.getCommonData(),
}));
});
fastify.get('/_info_card_', async (request, reply) => {
reply.removeHeader('X-Frame-Options');
return await reply.view('info-card', {
return await HtmlTemplateService.replyHtml(reply, InfoCardPage({
version: this.config.version,
host: this.config.host,
config: this.config,
meta: this.meta,
originalUsersCount: await this.usersRepository.countBy({ host: IsNull() }),
originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }),
});
}));
});
//#endregion
fastify.get('/bios', async (request, reply) => {
return await reply.view('bios', {
return await HtmlTemplateService.replyHtml(reply, BiosPage({
version: this.config.version,
});
}));
});
fastify.get('/cli', async (request, reply) => {
return await reply.view('cli', {
return await HtmlTemplateService.replyHtml(reply, CliPage({
version: this.config.version,
});
}));
});
const override = (source: string, target: string, depth = 0) =>
@@ -917,7 +874,7 @@ export class ClientServerService {
reply.header('Clear-Site-Data', '"*"');
}
reply.header('Set-Cookie', 'http-flush-failed=1; Path=/flush; Max-Age=60');
return await reply.view('flush');
return await HtmlTemplateService.replyHtml(reply, FlushPage());
});
// streamingに非WebSocketリクエストが来た場合にbase htmlをキャシュ付きで返すと、Proxy等でそのパスがキャッシュされておかしくなる
@@ -943,10 +900,10 @@ export class ClientServerService {
});
reply.code(500);
reply.header('Cache-Control', 'max-age=10, must-revalidate');
return await reply.view('error', {
return await HtmlTemplateService.replyHtml(reply, ErrorPage({
code: error.code,
id: errId,
});
}));
});
done();

View File

@@ -0,0 +1,105 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promises as fsp } from 'node:fs';
import { languages } from 'i18n/const';
import { Injectable, Inject } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { htmlSafeJsonStringify } from '@/misc/json-stringify-html-safe.js';
import { MetaEntityService } from '@/core/entities/MetaEntityService.js';
import type { FastifyReply } from 'fastify';
import type { Config } from '@/config.js';
import type { MiMeta } from '@/models/Meta.js';
import type { CommonData } from './views/_.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const frontendVitePublic = `${_dirname}/../../../../frontend/public/`;
const frontendEmbedVitePublic = `${_dirname}/../../../../frontend-embed/public/`;
@Injectable()
export class HtmlTemplateService {
private frontendBootloadersFetched = false;
public frontendBootloaderJs: string | null = null;
public frontendBootloaderCss: string | null = null;
public frontendEmbedBootloaderJs: string | null = null;
public frontendEmbedBootloaderCss: string | null = null;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.meta)
private meta: MiMeta,
private metaEntityService: MetaEntityService,
) {
}
@bindThis
private async prepareFrontendBootloaders() {
if (this.frontendBootloadersFetched) return;
this.frontendBootloadersFetched = true;
const [bootJs, bootCss, embedBootJs, embedBootCss] = await Promise.all([
fsp.readFile(`${frontendVitePublic}loader/boot.js`, 'utf-8').catch(() => null),
fsp.readFile(`${frontendVitePublic}loader/style.css`, 'utf-8').catch(() => null),
fsp.readFile(`${frontendEmbedVitePublic}loader/boot.js`, 'utf-8').catch(() => null),
fsp.readFile(`${frontendEmbedVitePublic}loader/style.css`, 'utf-8').catch(() => null),
]);
if (bootJs != null) {
this.frontendBootloaderJs = bootJs;
}
if (bootCss != null) {
this.frontendBootloaderCss = bootCss;
}
if (embedBootJs != null) {
this.frontendEmbedBootloaderJs = embedBootJs;
}
if (embedBootCss != null) {
this.frontendEmbedBootloaderCss = embedBootCss;
}
}
@bindThis
public async getCommonData(): Promise<CommonData> {
await this.prepareFrontendBootloaders();
return {
version: this.config.version,
config: this.config,
langs: [...languages],
instanceName: this.meta.name ?? 'Misskey',
icon: this.meta.iconUrl,
appleTouchIcon: this.meta.app512IconUrl,
themeColor: this.meta.themeColor,
serverErrorImageUrl: this.meta.serverErrorImageUrl ?? 'https://xn--931a.moe/assets/error.jpg',
infoImageUrl: this.meta.infoImageUrl ?? 'https://xn--931a.moe/assets/info.jpg',
notFoundImageUrl: this.meta.notFoundImageUrl ?? 'https://xn--931a.moe/assets/not-found.jpg',
instanceUrl: this.config.url,
metaJson: htmlSafeJsonStringify(await this.metaEntityService.packDetailed(this.meta)),
now: Date.now(),
federationEnabled: this.meta.federation !== 'none',
frontendBootloaderJs: this.frontendBootloaderJs,
frontendBootloaderCss: this.frontendBootloaderCss,
frontendEmbedBootloaderJs: this.frontendEmbedBootloaderJs,
frontendEmbedBootloaderCss: this.frontendEmbedBootloaderCss,
};
}
public static async replyHtml(reply: FastifyReply, html: string | Promise<string>) {
reply.header('Content-Type', 'text/html; charset=utf-8');
const _html = await html;
return reply.send(_html);
}
}

View File

@@ -4,8 +4,7 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { summaly } from '@misskey-dev/summaly';
import { SummalyResult } from '@misskey-dev/summaly/built/summary.js';
import type { SummalyResult } from '@misskey-dev/summaly/built/summary.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
@@ -113,7 +112,7 @@ export class UrlPreviewService {
}
}
private fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
private async fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
const agent = this.config.proxy
? {
http: this.httpRequestService.httpAgent,
@@ -121,6 +120,8 @@ export class UrlPreviewService {
}
: undefined;
const { summaly } = await import('@misskey-dev/summaly');
return summaly(url, {
followRedirects: this.meta.urlPreviewAllowRedirect,
lang: lang ?? 'ja-JP',

View File

@@ -1,208 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
'use strict';
// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
(async () => {
window.onerror = (e) => {
console.error(e);
renderError('SOMETHING_HAPPENED');
};
window.onunhandledrejection = (e) => {
console.error(e);
renderError('SOMETHING_HAPPENED_IN_PROMISE');
};
let forceError = localStorage.getItem('forceError');
if (forceError != null) {
renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.');
return;
}
// パラメータに応じてsplashのスタイルを変更
const params = new URLSearchParams(location.search);
if (params.has('rounded') && params.get('rounded') === 'false') {
document.documentElement.classList.add('norounded');
}
if (params.has('border') && params.get('border') === 'false') {
document.documentElement.classList.add('noborder');
}
//#region Detect language & fetch translations
const supportedLangs = LANGS;
/** @type { string } */
let lang = localStorage.getItem('lang');
if (lang == null || !supportedLangs.includes(lang)) {
if (supportedLangs.includes(navigator.language)) {
lang = navigator.language;
} else {
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
// Fallback
if (lang == null) lang = 'en-US';
}
}
// for https://github.com/misskey-dev/misskey/issues/10202
if (lang == null || lang.toString == null || lang.toString() === 'null') {
console.error('invalid lang value detected!!!', typeof lang, lang);
lang = 'en-US';
}
//#endregion
//#region Script
async function importAppScript() {
await import(CLIENT_ENTRY ? `/embed_vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/embed_vite/src/boot.ts')
.catch(async e => {
console.error(e);
renderError('APP_IMPORT');
});
}
// タイミングによっては、この時点でDOMの構築が済んでいる場合とそうでない場合とがある
if (document.readyState !== 'loading') {
importAppScript();
} else {
window.addEventListener('DOMContentLoaded', () => {
importAppScript();
});
}
//#endregion
async function addStyle(styleText) {
let css = document.createElement('style');
css.appendChild(document.createTextNode(styleText));
document.head.appendChild(css);
}
async function renderError(code) {
// Cannot set property 'innerHTML' of null を回避
if (document.readyState === 'loading') {
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
}
let messages = null;
const bootloaderLocales = localStorage.getItem('bootloaderLocales');
if (bootloaderLocales) {
messages = JSON.parse(bootloaderLocales);
}
if (!messages) {
// older version of misskey does not store bootloaderLocales, stores locale as a whole
const legacyLocale = localStorage.getItem('locale');
if (legacyLocale) {
const parsed = JSON.parse(legacyLocale);
messages = {
...(parsed._bootErrors ?? {}),
reload: parsed.reload,
};
}
}
if (!messages) messages = {};
const title = messages?.title || 'Failed to initialize Misskey';
const reload = messages?.reload || 'Reload';
document.body.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M12 9v4" /><path d="M12 16v.01" /></svg>
<div class="message">${title}</div>
<div class="submessage">Error Code: ${code}</div>
<button onclick="location.reload(!0)">
<div>${reload}</div>
</button>`;
addStyle(`
#misskey_app,
#splash {
display: none !important;
}
html,
body {
margin: 0;
}
body {
position: relative;
color: #dee7e4;
font-family: Hiragino Maru Gothic Pro, BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
line-height: 1.35;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
padding: 24px;
box-sizing: border-box;
overflow: hidden;
border-radius: var(--radius, 12px);
border: 1px solid rgba(231, 255, 251, 0.14);
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #192320;
border-radius: var(--radius, 12px);
z-index: -1;
}
html.embed.norounded body,
html.embed.norounded body::before {
border-radius: 0;
}
html.embed.noborder body {
border: none;
}
.icon {
max-width: 60px;
width: 100%;
height: auto;
margin-bottom: 20px;
color: #dec340;
}
.message {
text-align: center;
font-size: 20px;
font-weight: 700;
margin-bottom: 20px;
}
.submessage {
text-align: center;
font-size: 90%;
margin-bottom: 7.5px;
}
.submessage:last-of-type {
margin-bottom: 20px;
}
button {
padding: 7px 14px;
min-width: 100px;
font-weight: 700;
font-family: Hiragino Maru Gothic Pro, BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
line-height: 1.35;
border-radius: 99rem;
background-color: #b4e900;
color: #192320;
border: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
button:hover {
background-color: #c6ff03;
}`);
}
})();

View File

@@ -1,336 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
'use strict';
// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
(async () => {
window.onerror = (e) => {
console.error(e);
renderError('SOMETHING_HAPPENED', e);
};
window.onunhandledrejection = (e) => {
console.error(e);
renderError('SOMETHING_HAPPENED_IN_PROMISE', e.reason || e);
};
let forceError = localStorage.getItem('forceError');
if (forceError != null) {
renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.');
return;
}
//#region Detect language
const supportedLangs = LANGS;
/** @type { string } */
let lang = localStorage.getItem('lang');
if (lang == null || !supportedLangs.includes(lang)) {
if (supportedLangs.includes(navigator.language)) {
lang = navigator.language;
} else {
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
// Fallback
if (lang == null) lang = 'en-US';
}
}
// for https://github.com/misskey-dev/misskey/issues/10202
if (lang == null || lang.toString == null || lang.toString() === 'null') {
console.error('invalid lang value detected!!!', typeof lang, lang);
lang = 'en-US';
}
//#endregion
//#region Script
async function importAppScript() {
await import(CLIENT_ENTRY ? `/vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/vite/src/_boot_.ts')
.catch(async e => {
console.error(e);
renderError('APP_IMPORT', e);
});
}
// タイミングによっては、この時点でDOMの構築が済んでいる場合とそうでない場合とがある
if (document.readyState !== 'loading') {
importAppScript();
} else {
window.addEventListener('DOMContentLoaded', () => {
importAppScript();
});
}
//#endregion
let isSafeMode = (localStorage.getItem('isSafeMode') === 'true');
if (!isSafeMode) {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('safemode') && urlParams.get('safemode') === 'true') {
localStorage.setItem('isSafeMode', 'true');
isSafeMode = true;
}
}
//#region Theme
if (!isSafeMode) {
const theme = localStorage.getItem('theme');
if (theme) {
for (const [k, v] of Object.entries(JSON.parse(theme))) {
document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
// HTMLの theme-color 適用
if (k === 'htmlThemeColor') {
for (const tag of document.head.children) {
if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
tag.setAttribute('content', v);
break;
}
}
}
}
}
}
const colorScheme = localStorage.getItem('colorScheme');
if (colorScheme) {
document.documentElement.style.setProperty('color-scheme', colorScheme);
}
//#endregion
const fontSize = localStorage.getItem('fontSize');
if (fontSize) {
document.documentElement.classList.add('f-' + fontSize);
}
const useSystemFont = localStorage.getItem('useSystemFont');
if (useSystemFont) {
document.documentElement.classList.add('useSystemFont');
}
if (!isSafeMode) {
const customCss = localStorage.getItem('customCss');
if (customCss && customCss.length > 0) {
const style = document.createElement('style');
style.innerHTML = customCss;
document.head.appendChild(style);
}
}
async function addStyle(styleText) {
let css = document.createElement('style');
css.appendChild(document.createTextNode(styleText));
document.head.appendChild(css);
}
async function renderError(code, details) {
// Cannot set property 'innerHTML' of null を回避
if (document.readyState === 'loading') {
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
}
let messages = null;
const bootloaderLocales = localStorage.getItem('bootloaderLocales');
if (bootloaderLocales) {
messages = JSON.parse(bootloaderLocales);
}
if (!messages) {
// older version of misskey does not store bootloaderLocales, stores locale as a whole
const legacyLocale = localStorage.getItem('locale');
if (legacyLocale) {
const parsed = JSON.parse(legacyLocale);
messages = {
...(parsed._bootErrors ?? {}),
reload: parsed.reload,
};
}
}
if (!messages) messages = {};
messages = Object.assign({
title: 'Failed to initialize Misskey',
solution: 'The following actions may solve the problem.',
solution1: 'Update your os and browser',
solution2: 'Disable an adblocker',
solution3: 'Clear the browser cache',
solution4: '(Tor Browser) Set dom.webaudio.enabled to true',
otherOption: 'Other options',
otherOption1: 'Clear preferences and cache',
otherOption2: 'Start the simple client',
otherOption3: 'Start the repair tool',
otherOption4: 'Start Misskey in safe mode',
reload: 'Reload',
}, messages);
const safeModeUrl = new URL(window.location.href);
safeModeUrl.searchParams.set('safemode', 'true');
let errorsElement = document.getElementById('errors');
if (!errorsElement) {
document.body.innerHTML = `
<svg class="icon-warning" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 9v2m0 4v.01"></path>
<path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"></path>
</svg>
<h1>${messages.title}</h1>
<button class="button-big" onclick="location.reload(true);">
<span class="button-label-big">${messages?.reload}</span>
</button>
<p><b>${messages.solution}</b></p>
<p>${messages.solution1}</p>
<p>${messages.solution2}</p>
<p>${messages.solution3}</p>
<p>${messages.solution4}</p>
<details style="color: #86b300;">
<summary>${messages.otherOption}</summary>
<a href="${safeModeUrl}">
<button class="button-small">
<span class="button-label-small">${messages.otherOption4}</span>
</button>
</a>
<br>
<a href="/flush">
<button class="button-small">
<span class="button-label-small">${messages.otherOption1}</span>
</button>
</a>
<br>
<a href="/cli">
<button class="button-small">
<span class="button-label-small">${messages.otherOption2}</span>
</button>
</a>
<br>
<a href="/bios">
<button class="button-small">
<span class="button-label-small">${messages.otherOption3}</span>
</button>
</a>
</details>
<br>
<div id="errors"></div>
`;
errorsElement = document.getElementById('errors');
}
const detailsElement = document.createElement('details');
detailsElement.id = 'errorInfo';
detailsElement.innerHTML = `
<br>
<summary>
<code>ERROR CODE: ${code}</code>
</summary>
<code>${details.toString()} ${JSON.stringify(details)}</code>`;
errorsElement.appendChild(detailsElement);
addStyle(`
* {
font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
}
#misskey_app,
#splash {
display: none !important;
}
body,
html {
background-color: #222;
color: #dfddcc;
justify-content: center;
margin: auto;
padding: 10px;
text-align: center;
}
button {
border-radius: 999px;
padding: 0px 12px 0px 12px;
border: none;
cursor: pointer;
margin-bottom: 12px;
}
.button-big {
background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0));
line-height: 50px;
}
.button-big:hover {
background: rgb(153, 204, 0);
}
.button-small {
background: #444;
line-height: 40px;
}
.button-small:hover {
background: #555;
}
.button-label-big {
color: #222;
font-weight: bold;
font-size: 1.2em;
padding: 12px;
}
.button-label-small {
color: rgb(153, 204, 0);
font-size: 16px;
padding: 12px;
}
a {
color: rgb(134, 179, 0);
text-decoration: none;
}
p,
li {
font-size: 16px;
}
.icon-warning {
color: #dec340;
height: 4rem;
padding-top: 2rem;
}
h1 {
font-size: 1.5em;
margin: 1em;
}
code {
font-family: Fira, FiraCode, monospace;
}
#errorInfo {
background: #333;
margin-bottom: 2rem;
padding: 0.5rem 1rem;
width: 40rem;
border-radius: 10px;
justify-content: center;
margin: auto;
}
#errorInfo summary {
cursor: pointer;
}
#errorInfo summary > * {
display: inline;
}
@media screen and (max-width: 500px) {
#errorInfo {
width: 50%;
}
}`);
}
})();

View File

@@ -1,78 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
html {
background-color: var(--MI_THEME-bg);
color: var(--MI_THEME-fg);
}
#splash {
position: fixed;
z-index: 10000;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
cursor: wait;
background-color: var(--MI_THEME-bg);
opacity: 1;
transition: opacity 0.5s ease;
}
#splashIcon {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
width: 64px;
height: 64px;
border-radius: 10px;
pointer-events: none;
}
#splashSpinner {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
display: inline-block;
width: 28px;
height: 28px;
transform: translateY(70px);
color: var(--MI_THEME-accent);
}
#splashSpinner > .spinner {
position: absolute;
top: 0;
left: 0;
width: 28px;
height: 28px;
fill-rule: evenodd;
clip-rule: evenodd;
stroke-linecap: round;
stroke-linejoin: round;
stroke-miterlimit: 1.5;
}
#splashSpinner > .spinner.bg {
opacity: 0.275;
}
#splashSpinner > .spinner.fg {
animation: splashSpinner 0.5s linear infinite;
}
@keyframes splashSpinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -1,100 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
html {
background-color: var(--MI_THEME-bg);
color: var(--MI_THEME-fg);
}
html.embed {
box-sizing: border-box;
background-color: transparent;
color-scheme: light dark;
max-width: 500px;
}
#splash {
position: fixed;
z-index: 10000;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
cursor: wait;
background-color: var(--MI_THEME-bg);
opacity: 1;
transition: opacity 0.5s ease;
}
html.embed #splash {
box-sizing: border-box;
min-height: 300px;
border-radius: var(--radius, 12px);
border: 1px solid var(--MI_THEME-divider, #e8e8e8);
}
html.embed.norounded #splash {
border-radius: 0;
}
html.embed.noborder #splash {
border: none;
}
#splashIcon {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
width: 64px;
height: 64px;
border-radius: 10px;
pointer-events: none;
}
#splashSpinner {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
display: inline-block;
width: 28px;
height: 28px;
transform: translateY(70px);
color: var(--MI_THEME-accent);
}
#splashSpinner > .spinner {
position: absolute;
top: 0;
left: 0;
width: 28px;
height: 28px;
fill-rule: evenodd;
clip-rule: evenodd;
stroke-linecap: round;
stroke-linejoin: round;
stroke-miterlimit: 1.5;
}
#splashSpinner > .spinner.bg {
opacity: 0.275;
}
#splashSpinner > .spinner.fg {
animation: splashSpinner 0.5s linear infinite;
}
@keyframes splashSpinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Config } from '@/config.js';
export const comment = `<!--
_____ _ _
| |_|___ ___| |_ ___ _ _
| | | | |_ -|_ -| '_| -_| | |
|_|_|_|_|___|___|_,_|___|_ |
|___|
Thank you for using Misskey!
If you are reading this message... how about joining the development?
https://github.com/misskey-dev/misskey
-->`;
export const defaultDescription = '✨🌎✨ A interplanetary communication platform ✨🚀✨';
export type MinimumCommonData = {
version: string;
config: Config;
};
export type CommonData = MinimumCommonData & {
langs: string[];
instanceName: string;
icon: string | null;
appleTouchIcon: string | null;
themeColor: string | null;
serverErrorImageUrl: string;
infoImageUrl: string;
notFoundImageUrl: string;
instanceUrl: string;
now: number;
federationEnabled: boolean;
frontendBootloaderJs: string | null;
frontendBootloaderCss: string | null;
frontendEmbedBootloaderJs: string | null;
frontendEmbedBootloaderCss: string | null;
metaJson?: string;
clientCtxJson?: string;
};
export type CommonPropsMinimum<T = Record<string, any>> = MinimumCommonData & T;
export type CommonProps<T = Record<string, any>> = CommonData & T;

View File

@@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function Splash(props: {
icon?: string | null;
}) {
return (
<div id="splash">
<img id="splashIcon" src={props.icon || '/static-assets/splash.png'} />
<div id="splashSpinner">
<svg class="spinner bg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
<svg class="spinner fg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
</div>
</div>
);
}

View File

@@ -1,21 +0,0 @@
extends ./base
block vars
- const title = announcement.title;
- const description = announcement.text.length > 100 ? announcement.text.slice(0, 100) + '…' : announcement.text;
- const url = `${config.url}/announcements/${announcement.id}`;
block title
= `${title} | ${instanceName}`
block desc
meta(name='description' content=description)
block og
meta(property='og:type' content='article')
meta(property='og:title' content= title)
meta(property='og:description' content= description)
meta(property='og:url' content= url)
if announcement.imageUrl
meta(property='og:image' content=announcement.imageUrl)
meta(property='twitter:card' content='summary_large_image')

View File

@@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Packed } from '@/misc/json-schema.js';
import type { CommonProps } from '@/server/web/views/_.js';
import { Layout } from '@/server/web/views/base.js';
export function AnnouncementPage(props: CommonProps<{
announcement: Packed<'Announcement'>;
}>) {
const description = props.announcement.text.length > 100 ? props.announcement.text.slice(0, 100) + '…' : props.announcement.text;
function ogBlock() {
return (
<>
<meta property="og:type" content="article" />
<meta property="og:title" content={props.announcement.title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={`${props.config.url}/announcements/${props.announcement.id}`} />
{props.announcement.imageUrl ? (
<>
<meta property="og:image" content={props.announcement.imageUrl} />
<meta property="twitter:card" content="summary_large_image" />
</>
) : null}
</>
);
}
return (
<Layout
{...props}
title={`${props.announcement.title} | ${props.instanceName}`}
desc={description}
ogSlot={ogBlock()}
>
</Layout>
);
}

View File

@@ -1,71 +0,0 @@
block vars
block loadClientEntry
- const entry = config.frontendEmbedEntry;
doctype html
html(class='embed')
head
meta(charset='utf-8')
meta(name='application-name' content='Misskey')
meta(name='referrer' content='origin')
meta(name='theme-color' content= themeColor || '#86b300')
meta(name='theme-color-orig' content= themeColor || '#86b300')
meta(property='og:site_name' content= instanceName || 'Misskey')
meta(property='instance_url' content= instanceUrl)
meta(name='viewport' content='width=device-width, initial-scale=1')
meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no')
link(rel='icon' href= icon || '/favicon.ico')
link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png')
if !config.frontendEmbedManifestExists
script(type="module" src="/embed_vite/@vite/client")
if Array.isArray(entry.css)
each href in entry.css
link(rel='stylesheet' href=`/embed_vite/${href}`)
title
block title
= title || 'Misskey'
block meta
meta(name='robots' content='noindex')
style
include ../style.embed.css
script.
var VERSION = "#{version}";
var CLIENT_ENTRY = !{JSON.stringify(entry.file)};
script(type='application/json' id='misskey_meta' data-generated-at=now)
!= metaJson
script(type='application/json' id='misskey_embedCtx' data-generated-at=now)
!= embedCtx
script
include ../boot.embed.js
body
noscript: p
| JavaScriptを有効にしてください
br
| Please turn on your JavaScript
div#splash
img#splashIcon(src= icon || '/static-assets/splash.png')
div#splashSpinner
<svg class="spinner bg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
<svg class="spinner fg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
block content

View File

@@ -0,0 +1,88 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { comment } from '@/server/web/views/_.js';
import type { CommonProps } from '@/server/web/views/_.js';
import { Splash } from '@/server/web/views/_splash.js';
import type { PropsWithChildren, Children } from '@kitajs/html';
export function BaseEmbed(props: PropsWithChildren<CommonProps<{
title?: string;
noindex?: boolean;
desc?: string;
img?: string;
serverErrorImageUrl?: string;
infoImageUrl?: string;
notFoundImageUrl?: string;
metaJson?: string;
embedCtxJson?: string;
titleSlot?: Children;
metaSlot?: Children;
}>>) {
const now = Date.now();
// 変数名をsafeで始めることでエラーをスキップ
const safeMetaJson = props.metaJson;
const safeEmbedCtxJson = props.embedCtxJson;
return (
<>
{'<!DOCTYPE html>'}
{comment}
<html>
<head>
<meta charset="UTF-8" />
<meta name="application-name" content="Misskey" />
<meta name="referer" content="origin" />
<meta name="theme-color" content={props.themeColor ?? '#86b300'} />
<meta name="theme-color-orig" content={props.themeColor ?? '#86b300'} />
<meta property="og:site_name" content={props.instanceName || 'Misskey'} />
<meta property="instance_url" content={props.instanceUrl} />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no" />
<link rel="icon" href={props.icon ?? '/favicon.ico'} />
<link rel="apple-touch-icon" href={props.appleTouchIcon ?? '/apple-touch-icon.png'} />
{!props.config.frontendEmbedManifestExists ? <script type="module" src="/embed_vite/@vite/client"></script> : null}
{props.config.frontendEmbedEntry.css != null ? props.config.frontendEmbedEntry.css.map((href) => (
<link rel="stylesheet" href={`/embed_vite/${href}`} />
)) : null}
{props.titleSlot ?? <title safe>{props.title || 'Misskey'}</title>}
{props.metaSlot}
<meta name="robots" content="noindex" />
{props.frontendEmbedBootloaderCss != null ? <style safe>{props.frontendEmbedBootloaderCss}</style> : <link rel="stylesheet" href="/embed_vite/loader/style.css" />}
<script>
const VERSION = '{props.version}';
const CLIENT_ENTRY = {JSON.stringify(props.config.frontendEmbedEntry.file)};
const LANGS = {JSON.stringify(props.langs)};
</script>
{safeMetaJson != null ? <script type="application/json" id="misskey_meta" data-generated-at={now}>{safeMetaJson}</script> : null}
{safeEmbedCtxJson != null ? <script type="application/json" id="misskey_embedCtx" data-generated-at={now}>{safeEmbedCtxJson}</script> : null}
{props.frontendEmbedBootloaderJs != null ? <script>{props.frontendEmbedBootloaderJs}</script> : <script src="/embed_vite/loader/boot.js"></script>}
</head>
<body>
<noscript>
<p>
JavaScriptを有効にしてください<br />
Please turn on your JavaScript
</p>
</noscript>
<Splash icon={props.icon} />
{props.children}
</body>
</html>
</>
);
}

View File

@@ -1,100 +0,0 @@
block vars
block loadClientEntry
- const entry = config.frontendEntry;
- const baseUrl = config.url;
doctype html
//
-
_____ _ _
| |_|___ ___| |_ ___ _ _
| | | | |_ -|_ -| '_| -_| | |
|_|_|_|_|___|___|_,_|___|_ |
|___|
Thank you for using Misskey!
If you are reading this message... how about joining the development?
https://github.com/misskey-dev/misskey
html
head
meta(charset='utf-8')
meta(name='application-name' content='Misskey')
meta(name='referrer' content='origin')
meta(name='theme-color' content= themeColor || '#86b300')
meta(name='theme-color-orig' content= themeColor || '#86b300')
meta(property='og:site_name' content= instanceName || 'Misskey')
meta(property='instance_url' content= instanceUrl)
meta(name='viewport' content='width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover')
meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no')
link(rel='icon' href= icon || '/favicon.ico')
link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png')
link(rel='manifest' href='/manifest.json')
link(rel='search' type='application/opensearchdescription+xml' title=(title || "Misskey") href=`${baseUrl}/opensearch.xml`)
link(rel='prefetch' href=serverErrorImageUrl)
link(rel='prefetch' href=infoImageUrl)
link(rel='prefetch' href=notFoundImageUrl)
if !config.frontendManifestExists
script(type="module" src="/vite/@vite/client")
if Array.isArray(entry.css)
each href in entry.css
link(rel='stylesheet' href=`/vite/${href}`)
title
block title
= title || 'Misskey'
if noindex
meta(name='robots' content='noindex')
block desc
meta(name='description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨')
block meta
block og
meta(property='og:title' content= title || 'Misskey')
meta(property='og:description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨')
meta(property='og:image' content= img)
meta(property='twitter:card' content='summary')
style
include ../style.css
script.
var VERSION = "#{version}";
var CLIENT_ENTRY = !{JSON.stringify(entry.file)};
script(type='application/json' id='misskey_meta' data-generated-at=now)
!= metaJson
script(type='application/json' id='misskey_clientCtx' data-generated-at=now)
!= clientCtx
script
include ../boot.js
body
noscript: p
| JavaScriptを有効にしてください
br
| Please turn on your JavaScript
div#splash
img#splashIcon(src= icon || '/static-assets/splash.png')
div#splashSpinner
<svg class="spinner bg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
<svg class="spinner fg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
block content

View File

@@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { comment, defaultDescription } from '@/server/web/views/_.js';
import { Splash } from '@/server/web/views/_splash.js';
import type { CommonProps } from '@/server/web/views/_.js';
import type { PropsWithChildren, Children } from '@kitajs/html';
export function Layout(props: PropsWithChildren<CommonProps<{
title?: string;
noindex?: boolean;
desc?: string;
img?: string;
serverErrorImageUrl?: string;
infoImageUrl?: string;
notFoundImageUrl?: string;
metaJson?: string;
clientCtxJson?: string;
titleSlot?: Children;
descSlot?: Children;
metaSlot?: Children;
ogSlot?: Children;
}>>) {
const now = Date.now();
// 変数名をsafeで始めることでエラーをスキップ
const safeMetaJson = props.metaJson;
const safeClientCtxJson = props.clientCtxJson;
return (
<>
{'<!DOCTYPE html>'}
{comment}
<html>
<head>
<meta charset="UTF-8" />
<meta name="application-name" content="Misskey" />
<meta name="referer" content="origin" />
<meta name="theme-color" content={props.themeColor ?? '#86b300'} />
<meta name="theme-color-orig" content={props.themeColor ?? '#86b300'} />
<meta property="og:site_name" content={props.instanceName || 'Misskey'} />
<meta property="instance_url" content={props.instanceUrl} />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no" />
<link rel="icon" href={props.icon || '/favicon.ico'} />
<link rel="apple-touch-icon" href={props.appleTouchIcon || '/apple-touch-icon.png'} />
<link rel="manifest" href="/manifest.json" />
<link rel="search" type="application/opensearchdescription+xml" title={props.title || 'Misskey'} href={`${props.config.url}/opensearch.xml`} />
{props.serverErrorImageUrl != null ? <link rel="prefetch" as="image" href={props.serverErrorImageUrl} /> : null}
{props.infoImageUrl != null ? <link rel="prefetch" as="image" href={props.infoImageUrl} /> : null}
{props.notFoundImageUrl != null ? <link rel="prefetch" as="image" href={props.notFoundImageUrl} /> : null}
{!props.config.frontendManifestExists ? <script type="module" src="/vite/@vite/client"></script> : null}
{props.config.frontendEntry.css != null ? props.config.frontendEntry.css.map((href) => (
<link rel="stylesheet" href={`/vite/${href}`} />
)) : null}
{props.titleSlot ?? <title safe>{props.title || 'Misskey'}</title>}
{props.noindex ? <meta name="robots" content="noindex" /> : null}
{props.descSlot ?? (props.desc != null ? <meta name="description" content={props.desc || defaultDescription} /> : null)}
{props.metaSlot}
{props.ogSlot ?? (
<>
<meta property="og:title" content={props.title || 'Misskey'} />
<meta property="og:description" content={props.desc || defaultDescription} />
{props.img != null ? <meta property="og:image" content={props.img} /> : null}
<meta property="twitter:card" content="summary" />
</>
)}
{props.frontendBootloaderCss != null ? <style safe>{props.frontendBootloaderCss}</style> : <link rel="stylesheet" href="/vite/loader/style.css" />}
<script>
const VERSION = '{props.version}';
const CLIENT_ENTRY = {JSON.stringify(props.config.frontendEntry.file)};
const LANGS = {JSON.stringify(props.langs)};
</script>
{safeMetaJson != null ? <script type="application/json" id="misskey_meta" data-generated-at={now}>{safeMetaJson}</script> : null}
{safeClientCtxJson != null ? <script type="application/json" id="misskey_clientCtx" data-generated-at={now}>{safeClientCtxJson}</script> : null}
{props.frontendBootloaderJs != null ? <script>{props.frontendBootloaderJs}</script> : <script src="/vite/loader/boot.js"></script>}
</head>
<body>
<noscript>
<p>
JavaScriptを有効にしてください<br />
Please turn on your JavaScript
</p>
</noscript>
<Splash icon={props.icon} />
{props.children}
</body>
</html>
</>
);
}
export { Layout as BasePage };

View File

@@ -1,20 +0,0 @@
doctype html
html
head
meta(charset='utf-8')
meta(name='application-name' content='Misskey')
title Misskey Repair Tool
style
include ../bios.css
script
include ../bios.js
body
header
h1 Misskey Repair Tool #{version}
main
div.tabs
button#ls edit local storage
div#content

View File

@@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function BiosPage(props: {
version: string;
}) {
return (
<>
{'<!DOCTYPE html>'}
<html>
<head>
<meta charset="UTF-8" />
<meta name="application-name" content="Misskey" />
<title>Misskey Repair Tool</title>
<link rel="stylesheet" href="/static-assets/misc/bios.css" />
</head>
<body>
<header>
<h1 safe>Misskey Repair Tool {props.version}</h1>
</header>
<main>
<div class="tabs">
<button id="ls">edit local storage</button>
</div>
<div id="content"></div>
</main>
<script src="/static-assets/misc/bios.js"></script>
</body>
</html>
</>
);
}

View File

@@ -1,19 +0,0 @@
extends ./base
block vars
- const title = channel.name;
- const url = `${config.url}/channels/${channel.id}`;
block title
= `${title} | ${instanceName}`
block desc
meta(name='description' content= channel.description)
block og
meta(property='og:type' content='article')
meta(property='og:title' content= title)
meta(property='og:description' content= channel.description)
meta(property='og:url' content= url)
meta(property='og:image' content= channel.bannerUrl)
meta(property='twitter:card' content='summary')

View File

@@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Packed } from '@/misc/json-schema.js';
import type { CommonProps } from '@/server/web/views/_.js';
import { Layout } from '@/server/web/views/base.js';
export function ChannelPage(props: CommonProps<{
channel: Packed<'Channel'>;
}>) {
function ogBlock() {
return (
<>
<meta property="og:type" content="website" />
<meta property="og:title" content={props.channel.name} />
{props.channel.description != null ? <meta property="og:description" content={props.channel.description} /> : null}
<meta property="og:url" content={`${props.config.url}/channels/${props.channel.id}`} />
{props.channel.bannerUrl ? (
<>
<meta property="og:image" content={props.channel.bannerUrl} />
<meta property="twitter:card" content="summary" />
</>
) : null}
</>
);
}
return (
<Layout
{...props}
title={`${props.channel.name} | ${props.instanceName}`}
desc={props.channel.description ?? undefined}
ogSlot={ogBlock()}
>
</Layout>
);
}

View File

@@ -1,21 +0,0 @@
doctype html
html
head
meta(charset='utf-8')
meta(name='application-name' content='Misskey')
title Misskey Cli
style
include ../cli.css
script
include ../cli.js
body
header
h1 Misskey Cli #{version}
main
div#form
textarea#text
button#submit submit
div#tl

View File

@@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function CliPage(props: {
version: string;
}) {
return (
<>
{'<!DOCTYPE html>'}
<html>
<head>
<meta charset="UTF-8" />
<meta name="application-name" content="Misskey" />
<title>Misskey CLI Tool</title>
<link rel="stylesheet" href="/static-assets/misc/cli.css" />
</head>
<body>
<header>
<h1 safe>Misskey CLI {props.version}</h1>
</header>
<main>
<div id="form">
<textarea id="text"></textarea>
<button id="submit">Submit</button>
</div>
<div id="tl"></div>
</main>
<script src="/static-assets/misc/cli.js"></script>
</body>
</html>
</>
);
}

View File

@@ -1,35 +0,0 @@
extends ./base
block vars
- const user = clip.user;
- const title = clip.name;
- const url = `${config.url}/clips/${clip.id}`;
block title
= `${title} | ${instanceName}`
block desc
meta(name='description' content= clip.description)
block og
meta(property='og:type' content='article')
meta(property='og:title' content= title)
meta(property='og:description' content= clip.description)
meta(property='og:url' content= url)
meta(property='og:image' content= avatarUrl)
meta(property='twitter:card' content='summary')
block meta
if profile.noCrawle
meta(name='robots' content='noindex')
if profile.preventAiLearning
meta(name='robots' content='noimageai')
meta(name='robots' content='noai')
meta(name='misskey:user-username' content=user.username)
meta(name='misskey:user-id' content=user.id)
meta(name='misskey:clip-id' content=clip.id)
// todo
if user.twitter
meta(name='twitter:creator' content=`@${user.twitter.screenName}`)

View File

@@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Packed } from '@/misc/json-schema.js';
import type { MiUserProfile } from '@/models/UserProfile.js';
import type { CommonProps } from '@/server/web/views/_.js';
import { Layout } from '@/server/web/views/base.js';
export function ClipPage(props: CommonProps<{
clip: Packed<'Clip'>;
profile: MiUserProfile;
}>) {
function ogBlock() {
return (
<>
<meta property="og:type" content="article" />
<meta property="og:title" content={props.clip.name} />
{props.clip.description != null ? <meta property="og:description" content={props.clip.description} /> : null}
<meta property="og:url" content={`${props.config.url}/clips/${props.clip.id}`} />
{props.clip.user.avatarUrl ? (
<>
<meta property="og:image" content={props.clip.user.avatarUrl} />
<meta property="twitter:card" content="summary" />
</>
) : null}
</>
);
}
function metaBlock() {
return (
<>
{props.profile.noCrawle ? <meta name="robots" content="noindex" /> : null}
{props.profile.preventAiLearning ? (
<>
<meta name="robots" content="noimageai" />
<meta name="robots" content="noai" />
</>
) : null}
<meta name="misskey:user-username" content={props.clip.user.username} />
<meta name="misskey:user-id" content={props.clip.user.id} />
<meta name="misskey:clip-id" content={props.clip.id} />
</>
);
}
return (
<Layout
{...props}
title={`${props.clip.name} | ${props.instanceName}`}
desc={props.clip.description ?? ''}
metaSlot={metaBlock()}
ogSlot={ogBlock()}
>
</Layout>
);
}

View File

@@ -1,71 +0,0 @@
doctype html
//
-
_____ _ _
| |_|___ ___| |_ ___ _ _
| | | | |_ -|_ -| '_| -_| | |
|_|_|_|_|___|___|_,_|___|_ |
|___|
Thank you for using Misskey!
If you are reading this message... how about joining the development?
https://github.com/misskey-dev/misskey
html
head
meta(charset='utf-8')
meta(name='viewport' content='width=device-width, initial-scale=1')
meta(name='application-name' content='Misskey')
meta(name='referrer' content='origin')
title
block title
= 'An error has occurred... | Misskey'
style
include ../error.css
script
include ../error.js
body
svg.icon-warning(xmlns="http://www.w3.org/2000/svg", viewBox="0 0 24 24", stroke-width="2", stroke="currentColor", fill="none", stroke-linecap="round", stroke-linejoin="round")
path(stroke="none", d="M0 0h24v24H0z", fill="none")
path(d="M12 9v2m0 4v.01")
path(d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75")
h1(data-i18n="title") Failed to initialize Misskey
button.button-big(onclick="location.reload();")
span.button-label-big(data-i18n-reload) Reload
p(data-i18n="serverError") If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.
div#errors
code.
ERROR CODE: #{code}
ERROR ID: #{id}
p
b(data-i18n="solution") The following actions may solve the problem.
p(data-i18n="solution1") Update your os and browser
p(data-i18n="solution2") Disable an adblocker
p(data-i18n="solution3") Clear your browser cache
p(data-i18n="solution4") (Tor Browser) Set dom.webaudio.enabled to true
details(style="color: #86b300;")
summary(data-i18n="otherOption") Other options
a(href="/flush")
button.button-small
span.button-label-small(data-i18n="otherOption1") Clear preferences and cache
br
a(href="/cli")
button.button-small
span.button-label-small(data-i18n="otherOption2") Start the simple client
br
a(href="/bios")
button.button-small
span.button-label-small(data-i18n="otherOption3") Start the repair tool

View File

@@ -0,0 +1,89 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { comment } from '@/server/web/views/_.js';
import type { CommonPropsMinimum } from '@/server/web/views/_.js';
export function ErrorPage(props: {
title?: string;
code: string;
id: string;
}) {
return (
<>
{'<!DOCTYPE html>'}
{comment}
<html>
<head>
<meta charset="UTF-8" />
<meta name="application-name" content="Misskey" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="origin" />
<title safe>{props.title ?? 'An error has occurred... | Misskey'}</title>
<link rel="stylesheet" href="/static-assets/misc/error.css" />
<script src="/static-assets/misc/error.js"></script>
</head>
<body>
<svg
class="icon-warning"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 9v2m0 4v.01" />
<path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75" />
</svg>
<h1 data-i18n="title">Failed to initialize Misskey</h1>
<button class="button-big" onclick="location.reload();">
<span class="button-label-big" data-i18n="reload">Reload</span>
</button>
<p data-i18n="serverError">
If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.
</p>
<div id="errors">
<code safe>
ERROR CODE: {props.code}<br />
ERROR ID: {props.id}
</code>
</div>
<p><b data-i18n="solution">The following actions may solve the problem.</b></p>
<p data-i18n="solution1">Update your os and browser</p>
<p data-i18n="solution2">Disable an adblocker</p>
<p data-i18n="solution3">Clear your browser cache</p>
<p data-i18n="solution4">(Tor Browser) Set dom.webaudio.enabled to true</p>
<details style="color: #86b300;">
<summary data-i18n="otherOption">Other options</summary>
<a href="/flush">
<button class="button-small">
<span class="button-label-small" data-i18n="otherOption1">Clear preferences and cache</span>
</button>
</a>
<a href="/cli">
<button class="button-small">
<span class="button-label-small" data-i18n="otherOption2">Start the simple client</span>
</button>
</a>
<a href="/bios">
<button class="button-small">
<span class="button-label-small" data-i18n="otherOption3">Start the repair tool</span>
</button>
</a>
</details>
</body>
</html>
</>
);
}

View File

@@ -1,35 +0,0 @@
extends ./base
block vars
- const user = flash.user;
- const title = flash.title;
- const url = `${config.url}/play/${flash.id}`;
block title
= `${title} | ${instanceName}`
block desc
meta(name='description' content= flash.summary)
block og
meta(property='og:type' content='article')
meta(property='og:title' content= title)
meta(property='og:description' content= flash.summary)
meta(property='og:url' content= url)
meta(property='og:image' content= avatarUrl)
meta(property='twitter:card' content='summary')
block meta
if profile.noCrawle
meta(name='robots' content='noindex')
if profile.preventAiLearning
meta(name='robots' content='noimageai')
meta(name='robots' content='noai')
meta(name='misskey:user-username' content=user.username)
meta(name='misskey:user-id' content=user.id)
meta(name='misskey:flash-id' content=flash.id)
// todo
if user.twitter
meta(name='twitter:creator' content=`@${user.twitter.screenName}`)

View File

@@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Packed } from '@/misc/json-schema.js';
import type { MiUserProfile } from '@/models/UserProfile.js';
import type { CommonProps } from '@/server/web/views/_.js';
import { Layout } from '@/server/web/views/base.js';
export function FlashPage(props: CommonProps<{
flash: Packed<'Flash'>;
profile: MiUserProfile;
}>) {
function ogBlock() {
return (
<>
<meta property="og:type" content="article" />
<meta property="og:title" content={props.flash.title} />
<meta property="og:description" content={props.flash.summary} />
<meta property="og:url" content={`${props.config.url}/play/${props.flash.id}`} />
{props.flash.user.avatarUrl ? (
<>
<meta property="og:image" content={props.flash.user.avatarUrl} />
<meta property="twitter:card" content="summary" />
</>
) : null}
</>
);
}
function metaBlock() {
return (
<>
{props.profile.noCrawle ? <meta name="robots" content="noindex" /> : null}
{props.profile.preventAiLearning ? (
<>
<meta name="robots" content="noimageai" />
<meta name="robots" content="noai" />
</>
) : null}
<meta name="misskey:user-username" content={props.flash.user.username} />
<meta name="misskey:user-id" content={props.flash.user.id} />
<meta name="misskey:flash-id" content={props.flash.id} />
</>
);
}
return (
<Layout
{...props}
title={`${props.flash.title} | ${props.instanceName}`}
desc={props.flash.summary}
metaSlot={metaBlock()}
ogSlot={ogBlock()}
>
</Layout>
);
}

View File

@@ -1,51 +0,0 @@
doctype html
html
#msg
script.
const msg = document.getElementById('msg');
const successText = `\nSuccess Flush! <a href="/">Back to Misskey</a>\n成功しました。<a href="/">Misskeyを開き直してください。</a>`;
if (!document.cookie) {
message('Your site data is fully cleared by your browser.');
message(successText);
} else {
message('Your browser does not support Clear-Site-Data header. Start opportunistic flushing.');
(async function() {
try {
localStorage.clear();
message('localStorage cleared.');
const idbPromises = ['MisskeyClient', 'keyval-store'].map((name, i, arr) => new Promise((res, rej) => {
const delidb = indexedDB.deleteDatabase(name);
delidb.onsuccess = () => res(message(`indexedDB "${name}" cleared. (${i + 1}/${arr.length})`));
delidb.onerror = e => rej(e)
}));
await Promise.all(idbPromises);
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage('clear');
await navigator.serviceWorker.getRegistrations()
.then(registrations => {
return Promise.all(registrations.map(registration => registration.unregister()));
})
.catch(e => { throw new Error(e) });
}
message(successText);
} catch (e) {
message(`\n${e}\n\nFlush Failed. <a href="/flush">Please retry.</a>\n失敗しました。<a href="/flush">もう一度試してみてください。</a>`);
message(`\nIf you retry more than 3 times, try manually clearing the browser cache or contact to instance admin.\n3回以上試しても失敗する場合、ブラウザのキャッシュを手動で消去し、それでもだめならインスタンス管理者に連絡してみてください。\n`)
console.error(e);
setTimeout(() => {
location = '/';
}, 10000)
}
})();
}
function message(text) {
msg.insertAdjacentHTML('beforeend', `<p>[${(new Date()).toString()}] ${text.replace(/\n/g,'<br>')}</p>`)
}

View File

@@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function FlushPage(props?: {}) {
return (
<>
{'<!DOCTYPE html>'}
<html>
<head>
<meta charset="UTF-8" />
<meta name="application-name" content="Misskey" />
<title>Clear preferences and cache</title>
</head>
<body>
<div id="msg"></div>
<script src="/static-assets/misc/flush.js"></script>
</body>
</html>
</>
);
}

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