Compare commits

..

5 commits

Author SHA1 Message Date
aa76c974f3
Skip rendering private data in privateMode
Co-authored-by: Francis Dinh <normandy@biribiri.dev>
2022-10-15 12:10:32 -04:00
61b7c8ca53
Add secure mode settings to Security tab 2022-10-15 12:10:25 -04:00
840227a901
In private mode, block access to many public APIs 2022-10-15 12:06:42 -04:00
9acd4bc855
Add Secure Mode and Private Mode
- Add instance actor
- Add private mode, which uses an allowlist
- Add Secure Mode, restricts access to blocked instances

Co-authored-by: Francis Dinh <normandy@biribiri.dev>
2022-10-15 12:06:41 -04:00
8bd41f5c9e
Add migration for allowedHosts, secureMode, privateMode 2022-10-15 12:06:37 -04:00
314 changed files with 4360 additions and 2383 deletions

View file

@ -124,9 +124,6 @@ redis:
# Upload or download file size limits (bytes) # Upload or download file size limits (bytes)
#maxFileSize: 262144000 #maxFileSize: 262144000
# Max note text length (in characters)
#maxNoteTextLength: 3000
#allowedPrivateNetworks: [ #allowedPrivateNetworks: [
# '127.0.0.1/32' # '127.0.0.1/32'
#] #]

View file

@ -1,4 +1,6 @@
.autogen .autogen
.github
.travis
.vscode .vscode
.config .config
Dockerfile Dockerfile
@ -10,3 +12,4 @@ elasticsearch/
node_modules/ node_modules/
redis/ redis/
files/ files/
misskey-assets/

View file

@ -11,85 +11,38 @@ Unreleased changes should not be listed in this file.
Instead, run `git shortlog --format='%h %s' --group=trailer:changelog <last tag>..` to see unreleased changes; replace `<last tag>` with the tag you wish to compare from. Instead, run `git shortlog --format='%h %s' --group=trailer:changelog <last tag>..` to see unreleased changes; replace `<last tag>` with the tag you wish to compare from.
If you are a contributor, please read [CONTRIBUTING.md, section "Changelog Trailer"](./CONTRIBUTING.md#changelog-trailer) on what to do instead. If you are a contributor, please read [CONTRIBUTING.md, section "Changelog Trailer"](./CONTRIBUTING.md#changelog-trailer) on what to do instead.
## 13.0.0-preview2 - 2022-10-16 ## Unreleased
### Security
- server: Update `multer` dependency to resolve [CVE-2022-24434](https://nvd.nist.gov/vuln/detail/CVE-2022-24434)
- server: Update `file-type`, `got`, and `sharp` dependencies to fix various security issues
### Added ### Added
- allow to mute only renotes of a user - Client: Show instance info in ticker
- allow to export only selected custom emoji - Client: Readded group pages
- client: improve emoji picker search - Client: add re-collapsing to quoted notes
- client: Extend Emoji list
- client: show alt text in image viewer
- client: Show instance info in ticker
- client: Readded group pages
- client: add re-collapsing to quoted notes
- server: allow files storage path to be set explicitly
- server: refactor expiring data and expire signins after 60 days
- server: send delete activity to all known instances
- server: add automatic dead instance detection
### Changed ### Changed
- foundkey-js: Sync possible endpoints from backend - Client: Use consistent date formatting based on language setting
- foundkey-js: update LiteInstanceMetadata fields - Client: Add threshold to reduce occurances of "future" timestamps
- meta: use parallel and incremental builds - Pages have been considerably simplified, several of the very complex features have been removed.
- meta: update WORKDIR to foundkey
- meta: update dependencies
- client: consolidate about & notifications pages
- client: include renote in visibility computation
- client: make emoji amount slider more intuitive
- client: sort emojis by query similarity in fuzzy picker
- client: discard drafts that are just the default state
- client: Use consistent date formatting based on language setting
- client: Add threshold to reduce occurances of "future" timestamps
- server: mute notifications in muted threads
- server: allow for source lang to be overridden in note/translate
- server: allow redis family to be specified as a string
- server: increase image description limit to 2048 characters
- server: Pages have been considerably simplified, several of the very complex features have been removed.
Pages are now MFM only. Pages are now MFM only.
**For admins:** There is a migration in place to convert page contents to text, but not everything can be migrated. **For admins:** There is a migration in place to convert page contents to text, but not everything can be migrated.
You might want to check if you have any more complex pages on your instance and ask users to migrate them by hand. You might want to check if you have any more complex pages on your instance and ask users to migrate them by hand.
Or generally advise all users to simplify their pages to only text. Or generally advise all users to simplify their pages to only text.
### Fixed
- client: alt text dialog properly handles non-images
- client: Fix style scoping in MkMention
- client: default instance ticker name to instance's domain name
- client: improve error message for empty gallery posts
- client: fix default-selected reply scopes
- client: Make MFM cheatsheet interactive again
- client: Fix reports not showing in control panel
- client: make hard coded strings in emoji admin panel internationalized
- client: Notifications for ended polls can now be turned off
- client: improve emoji picker performance
- server: Blocking remote accounts
- server: fix table name used in toHtml
- server: Fix appendChildren TypeError
- server: ensure only own notifications can be marked as read
- server: render HTML mentions correctly
- server: increase requestId max size for GNU Social
- server: fix HTTP GET parameters in OpenAPI docs
- server: proper error messages for creating accounts
- server: Fix thread muting queries
- docker: add built foundkey-js files to container
- service worker: Remove fetch handler from service worker
### Removed ### Removed
- remove misskey-assets submodule - Okteto config and Helm chart
- server: remove room data from user - Client: acrylic styling
- client: remove ai mode - Client: Twitter embeds, the standard URL preview is used instead.
- client: remove "Disable AiScript on Pages" setting - Promotion entities and endpoints
- client: acrylic styling - Server: The configuration item `signToActivityPubGet` has been removed and will be ignored if set explicitly.
- client: Twitter embeds, the standard URL preview is used instead.
- foundkey-js: remove room api endpoints
- server: remove unusable setting to send error reports
- server: ignore detail parameter on meta endpoint
- server: Promotion entities and endpoints
- server: The configuration item `signToActivityPubGet` has been removed and will be ignored if set explicitly.
Foundkey will now work as if it was set to `true`. Foundkey will now work as if it was set to `true`.
### Fixed
- Client: Notifications for ended polls can now be turned off
- Client: Emoji picker should load faster now
- Server: Blocking remote accounts
### Security
- Server: Update `multer` dependency to resolve [CVE-2022-24434](https://nvd.nist.gov/vuln/detail/CVE-2022-24434)
- Server: Update `file-type`, `got`, and `sharp` dependencies to fix various security issues
## 13.0.0-preview1 - 2022-08-05 ## 13.0.0-preview1 - 2022-08-05
### Added ### Added
- Server: Replies can now be fetched recursively. - Server: Replies can now be fetched recursively.

View file

@ -139,14 +139,6 @@ To generate the changelog, we use a standard shortlog command: `git shortlog --f
The person performing the release process should build the next CHANGELOG section based on this output, not use it as-is. The person performing the release process should build the next CHANGELOG section based on this output, not use it as-is.
Full releases should also remove any pre-release CHANGELOG sections. Full releases should also remove any pre-release CHANGELOG sections.
Here is the step by step checklist:
1. If **stable** release, announce the comment period. Restart the comment period if a blocker bug is found and fixed.
2. Edit various `package.json`s to the new version.
3. Write a new entry into the changelog.
You should use the `git shortlog --format='%h %s' --group=trailer:changelog LAST_TAG..` command to get general data,
then rewrite it in a human way.
4. Tag the commit with the changes in 2 and 3 (if together, else the latter).
## Translation ## Translation
[![Translation status](http://translate.akkoma.dev/widgets/foundkey/-/svg-badge.svg)](http://translate.akkoma.dev/engage/foundkey/) [![Translation status](http://translate.akkoma.dev/widgets/foundkey/-/svg-badge.svg)](http://translate.akkoma.dev/engage/foundkey/)
@ -297,11 +289,8 @@ PostgreSQL array indices **start at 1**.
When `IN` is performed on a column that may contain `NULL` values, use `OR` or similar to handle `NULL` values. When `IN` is performed on a column that may contain `NULL` values, use `OR` or similar to handle `NULL` values.
### creating migrations ### creating migrations
First make changes to the entity files in `packages/backend/src/models/entities/`. In `packages/backend`, run:
Then, in `packages/backend`, run:
```sh ```sh
yarn build
npx typeorm migration:generate -d ormconfig.js -o <migration name> npx typeorm migration:generate -d ormconfig.js -o <migration name>
``` ```

View file

@ -1,9 +1,9 @@
# Reporting Security Issues # Reporting Security Issues
If you discover a security issue in Foundkey, please report it by sending an If you discover a security issue in Misskey, please report it by sending an
email to [johann@qwertqwefsday.eu](mailto:johann@qwertqwefsday.eu). email to [syuilotan@yahoo.co.jp](mailto:syuilotan@yahoo.co.jp).
This will allow us to assess the risk, and make a fix available before we add a This will allow us to assess the risk, and make a fix available before we add a
bug report to the repository. bug report to the GitHub repository.
Thanks for helping make Foundkey safe for everyone. Thanks for helping make Misskey safe for everyone.

View file

@ -40,9 +40,6 @@ git merge tags/v13.0.0-preview2 --squash
# you are now on the "next" release # you are now on the "next" release
``` ```
## Making sure modern Yarn works
Foundkey uses Modern Yarn instead of Classic (1.x). To make sure the `yarn` command will work going forward, run `corepack enable`.
## Rebuilding and running database migrations ## Rebuilding and running database migrations
This will be pretty much the same as a regular update of Misskey. Note that `yarn install` may take a while since dependency versions have been updated or removed and we use a newer version of Yarn. This will be pretty much the same as a regular update of Misskey. Note that `yarn install` may take a while since dependency versions have been updated or removed and we use a newer version of Yarn.
```sh ```sh

View file

@ -190,9 +190,7 @@ charts: "Charts"
perHour: "Per Hour" perHour: "Per Hour"
perDay: "Per Day" perDay: "Per Day"
stopActivityDelivery: "Stop sending activities" stopActivityDelivery: "Stop sending activities"
stopActivityDeliveryDescription: "Local activities will not be sent to this instance. Receiving activities works as before."
blockThisInstance: "Block this instance" blockThisInstance: "Block this instance"
blockThisInstanceDescription: "Local activites will not be sent to this instance. Activites from this instance will be discarded."
operations: "Operations" operations: "Operations"
software: "Software" software: "Software"
version: "Version" version: "Version"
@ -831,6 +829,13 @@ middle: "Medium"
low: "Low" low: "Low"
emailNotConfiguredWarning: "Email address not set." emailNotConfiguredWarning: "Email address not set."
ratio: "Ratio" ratio: "Ratio"
secureMode: "Secure Mode (Authorized Fetch)"
instanceSecurity: "Instance Security"
secureModeInfo: "Requests from other instances must be signed, otherwise notes won't be returned."
privateMode: "Private Mode"
privateModeInfo: "When enabled, only authorized instances may fetch notes. Hides all notes from public."
allowedInstances: "Allowed Instances"
allowedInstancesDescription: "Set the hosts of the instances you want to allow, separated by line. Valid in private mode only."
previewNoteText: "Show preview" previewNoteText: "Show preview"
customCss: "Custom CSS" customCss: "Custom CSS"
customCssWarn: "This setting should only be used if you know what it does. Entering\ customCssWarn: "This setting should only be used if you know what it does. Entering\

View file

@ -771,6 +771,13 @@ middle: "中"
low: "低" low: "低"
emailNotConfiguredWarning: "メールアドレスの設定がされていません。" emailNotConfiguredWarning: "メールアドレスの設定がされていません。"
ratio: "比率" ratio: "比率"
secureMode: "セキュアモード (Authorized Fetch)"
instanceSecurity: "インスタンスのセキュリティー"
secureModeInfo: "他のインスタンスからリクエストするときに、証明を付けなければ返送しません。"
privateMode: "非公開モード"
privateModeInfo: "有効にして、許可されているインスタンスのみがリクエストできます。すべてのノートが公開に非表示にします。"
allowedInstances: "許可されたインスタンス"
allowedInstancesDescription: "許可したいインスタンスのホストを改行で区切って設定します。非公開モードだけで有効です。"
previewNoteText: "本文をプレビュー" previewNoteText: "本文をプレビュー"
customCss: "カスタムCSS" customCss: "カスタムCSS"
customCssWarn: "この設定は必ず知識のある方が行ってください。不適切な設定を行うとクライアントが正常に使用できなくなる恐れがあります。" customCssWarn: "この設定は必ず知識のある方が行ってください。不適切な設定を行うとクライアントが正常に使用できなくなる恐れがあります。"

View file

@ -1,6 +1,6 @@
{ {
"name": "foundkey", "name": "foundkey",
"version": "13.0.0-preview2", "version": "13.0.0-preview.1",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://akkoma.dev/FoundKeyGang/FoundKey.git" "url": "https://akkoma.dev/FoundKeyGang/FoundKey.git"

View file

@ -0,0 +1,15 @@
export class allowlistSecureMode1626733991004 {
name = 'allowlistSecureMode1626733991004';
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "allowedHosts" character varying(256) [] default '{}'`);
await queryRunner.query(`ALTER TABLE "meta" ADD "secureMode" bool default false`);
await queryRunner.query(`ALTER TABLE "meta" ADD "privateMode" bool default false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "allowedHosts"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "secureMode"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "privateMode"`);
}
}

View file

@ -1,5 +1,5 @@
export class removeAds1657570176749 { export class removeAds1657570176749 {
name = 'removeAds1657570176749'; name = 'removeAds1657570176749'
async up(queryRunner) { async up(queryRunner) {
await queryRunner.query(`DROP TABLE "ad"`); await queryRunner.query(`DROP TABLE "ad"`);

View file

@ -1,44 +0,0 @@
export class sync1667503570994 {
name = 'sync1667503570994'
async up(queryRunner) {
await Promise.all([
// the migration for renote mutes added the index to the wrong table
queryRunner.query(`DROP INDEX "public"."IDX_renote_muting_createdAt"`),
queryRunner.query(`DROP INDEX "public"."IDX_renote_muting_muteeId"`),
queryRunner.query(`DROP INDEX "public"."IDX_renote_muting_muterId"`),
queryRunner.query(`CREATE INDEX "IDX_d1259a2c2b7bb413ff449e8711" ON "renote_muting" ("createdAt") `),
queryRunner.query(`CREATE INDEX "IDX_7eac97594bcac5ffcf2068089b" ON "renote_muting" ("muteeId") `),
queryRunner.query(`CREATE INDEX "IDX_7aa72a5fe76019bfe8e5e0e8b7" ON "renote_muting" ("muterId") `),
queryRunner.query(`COMMENT ON COLUMN "renote_muting"."createdAt" IS 'The created date of the Muting.'`),
queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muteeId" IS 'The mutee user ID.'`),
queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muterId" IS 'The muter user ID.'`),
queryRunner.query(`ALTER TABLE "page" ALTER COLUMN "text" SET NOT NULL`),
queryRunner.query(`ALTER TABLE "page" ALTER COLUMN "text" SET DEFAULT ''`),
queryRunner.query(`CREATE UNIQUE INDEX "IDX_0d801c609cec4e9eb4b6b4490c" ON "renote_muting" ("muterId", "muteeId") `),
queryRunner.query(`ALTER TABLE "renote_muting" ADD CONSTRAINT "FK_7eac97594bcac5ffcf2068089b6" FOREIGN KEY ("muteeId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`),
queryRunner.query(`ALTER TABLE "renote_muting" ADD CONSTRAINT "FK_7aa72a5fe76019bfe8e5e0e8b7d" FOREIGN KEY ("muterId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`),
]);
}
async down(queryRunner) {
await Promise.all([
queryRunner.query(`ALTER TABLE "renote_muting" DROP CONSTRAINT "FK_7aa72a5fe76019bfe8e5e0e8b7d"`),
queryRunner.query(`ALTER TABLE "renote_muting" DROP CONSTRAINT "FK_7eac97594bcac5ffcf2068089b6"`),
queryRunner.query(`DROP INDEX "public"."IDX_0d801c609cec4e9eb4b6b4490c"`),
queryRunner.query(`ALTER TABLE "page" ALTER COLUMN "text" DROP DEFAULT`),
queryRunner.query(`ALTER TABLE "page" ALTER COLUMN "text" DROP NOT NULL`),
queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muterId" IS NULL`),
queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muteeId" IS NULL`),
queryRunner.query(`COMMENT ON COLUMN "renote_muting"."createdAt" IS NULL`),
queryRunner.query(`DROP INDEX "public"."IDX_7aa72a5fe76019bfe8e5e0e8b7"`),
queryRunner.query(`DROP INDEX "public"."IDX_7eac97594bcac5ffcf2068089b"`),
queryRunner.query(`DROP INDEX "public"."IDX_d1259a2c2b7bb413ff449e8711"`),
queryRunner.query(`CREATE INDEX "IDX_renote_muting_muterId" ON "muting" ("muterId") `),
queryRunner.query(`CREATE INDEX "IDX_renote_muting_muteeId" ON "muting" ("muteeId") `),
queryRunner.query(`CREATE INDEX "IDX_renote_muting_createdAt" ON "muting" ("createdAt") `),
]);
}
}

View file

@ -1,6 +1,6 @@
{ {
"name": "backend", "name": "backend",
"version": "13.0.0-preview2", "version": "13.0.0-preview1",
"main": "./index.js", "main": "./index.js",
"private": true, "private": true,
"type": "module", "type": "module",
@ -15,8 +15,8 @@
"test": "npm run mocha" "test": "npm run mocha"
}, },
"dependencies": { "dependencies": {
"@bull-board/api": "^4.3.1", "@bull-board/api": "^4.2.2",
"@bull-board/koa": "^4.3.1", "@bull-board/koa": "4.0.0",
"@discordapp/twemoji": "14.0.2", "@discordapp/twemoji": "14.0.2",
"@elastic/elasticsearch": "7.11.0", "@elastic/elasticsearch": "7.11.0",
"@koa/cors": "3.1.0", "@koa/cors": "3.1.0",
@ -96,7 +96,7 @@
"rss-parser": "3.12.0", "rss-parser": "3.12.0",
"sanitize-html": "2.7.0", "sanitize-html": "2.7.0",
"semver": "7.3.7", "semver": "7.3.7",
"sharp": "0.31.2", "sharp": "0.30.7",
"speakeasy": "2.0.0", "speakeasy": "2.0.0",
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0", "stringz": "2.1.0",

View file

@ -38,8 +38,6 @@ export default function load(): Config {
config.port = config.port || parseInt(process.env.PORT || '', 10); config.port = config.port || parseInt(process.env.PORT || '', 10);
if (!config.maxNoteTextLength) config.maxNoteTextLength = 3000;
mixin.version = meta.version; mixin.version = meta.version;
mixin.host = url.host; mixin.host = url.host;
mixin.hostname = url.hostname; mixin.hostname = url.hostname;

View file

@ -10,7 +10,7 @@ function getRedisFamily(family?: string | number): number {
dual: 0, dual: 0,
}; };
if (typeof family === 'string' && family in familyMap) { if (typeof family === 'string' && family in familyMap) {
return familyMap[family as keyof typeof familyMap]; return familyMap[family];
} else if (typeof family === 'number' && Object.values(familyMap).includes(family)) { } else if (typeof family === 'number' && Object.values(familyMap).includes(family)) {
return family; return family;
} }

View file

@ -24,7 +24,7 @@ export type Source = {
db?: number; db?: number;
prefix?: string; prefix?: string;
}; };
elasticsearch?: { elasticsearch: {
host: string; host: string;
port: number; port: number;
ssl?: boolean; ssl?: boolean;
@ -41,8 +41,6 @@ export type Source = {
maxFileSize?: number; maxFileSize?: number;
maxNoteTextLength?: number;
accesslog?: string; accesslog?: string;
clusterLimit?: number; clusterLimit?: number;

View file

@ -1,3 +1,5 @@
export const MAX_NOTE_TEXT_LENGTH = 3000;
// Time constants // Time constants
export const SECOND = 1000; export const SECOND = 1000;
export const MINUTE = 60 * SECOND; export const MINUTE = 60 * SECOND;

View file

@ -62,21 +62,22 @@ export function fromHtml(html: string, hashtagNames?: string[]): string {
const rel = node.attrs.find(x => x.name === 'rel'); const rel = node.attrs.find(x => x.name === 'rel');
const href = node.attrs.find(x => x.name === 'href'); const href = node.attrs.find(x => x.name === 'href');
// hashtags // ハッシュタグ
if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) { if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) {
text += txt; text += txt;
// mentions // メンション
} else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) { } else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) {
const part = txt.split('@'); const part = txt.split('@');
if (part.length === 2 && href) { if (part.length === 2 && href) {
// restore the host name part //#region ホスト名部分が省略されているので復元する
const acct = `${txt}@${(new URL(href.value)).hostname}`; const acct = `${txt}@${(new URL(href.value)).hostname}`;
text += acct; text += acct;
//#endregion
} else if (part.length === 3) { } else if (part.length === 3) {
text += txt; text += txt;
} }
// other // その他
} else { } else {
const generateLink = () => { const generateLink = () => {
if (!href && !txt) { if (!href && !txt) {

View file

@ -1,12 +1,10 @@
export class Cache<T> { export class Cache<T> {
public cache: Map<string | null, { date: number; value: T; }>; public cache: Map<string | null, { date: number; value: T; }>;
private lifetime: number; private lifetime: number;
public fetcher: (key: string | null) => Promise<T | undefined>;
constructor(lifetime: number, fetcher: Cache<T>['fetcher']) { constructor(lifetime: Cache<never>['lifetime']) {
this.cache = new Map(); this.cache = new Map();
this.lifetime = lifetime; this.lifetime = lifetime;
this.fetcher = fetcher;
} }
public set(key: string | null, value: T): void { public set(key: string | null, value: T): void {
@ -19,13 +17,10 @@ export class Cache<T> {
public get(key: string | null): T | undefined { public get(key: string | null): T | undefined {
const cached = this.cache.get(key); const cached = this.cache.get(key);
if (cached == null) return undefined; if (cached == null) return undefined;
// discard if past the cache lifetime
if ((Date.now() - cached.date) > this.lifetime) { if ((Date.now() - cached.date) > this.lifetime) {
this.cache.delete(key); this.cache.delete(key);
return undefined; return undefined;
} }
return cached.value; return cached.value;
} }
@ -34,22 +29,52 @@ export class Cache<T> {
} }
/** /**
* If the value is cached, it is returned. Otherwise the fetcher is * fetcherを呼び出して結果をキャッシュ&
* run to get the value. If the fetcher returns undefined, it is * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
* returned but not cached.
*/ */
public async fetch(key: string | null): Promise<T | undefined> { public async fetch(key: string | null, fetcher: () => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> {
const cached = this.get(key); const cachedValue = this.get(key);
if (cached !== undefined) { if (cachedValue !== undefined) {
return cached; if (validator) {
if (validator(cachedValue)) {
// Cache HIT
return cachedValue;
}
} else { } else {
const value = await this.fetcher(key); // Cache HIT
return cachedValue;
}
}
// don't cache undefined // Cache MISS
if (value !== undefined) const value = await fetcher();
this.set(key, value); this.set(key, value);
return value; return value;
} }
/**
* fetcherを呼び出して結果をキャッシュ&
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
*/
public async fetchMaybe(key: string | null, fetcher: () => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> {
const cachedValue = this.get(key);
if (cachedValue !== undefined) {
if (validator) {
if (validator(cachedValue)) {
// Cache HIT
return cachedValue;
}
} else {
// Cache HIT
return cachedValue;
}
}
// Cache MISS
const value = await fetcher();
if (value !== undefined) {
this.set(key, value);
}
return value;
} }
} }

View file

@ -3,26 +3,22 @@ import { Note } from '@/models/entities/note.js';
import { User } from '@/models/entities/user.js'; import { User } from '@/models/entities/user.js';
import { UserListJoinings, UserGroupJoinings, Blockings } from '@/models/index.js'; import { UserListJoinings, UserGroupJoinings, Blockings } from '@/models/index.js';
import * as Acct from '@/misc/acct.js'; import * as Acct from '@/misc/acct.js';
import { MINUTE } from '@/const.js';
import { getFullApAccount } from './convert-host.js'; import { getFullApAccount } from './convert-host.js';
import { Packed } from './schema.js'; import { Packed } from './schema.js';
import { Cache } from './cache.js'; import { Cache } from './cache.js';
const blockingCache = new Cache<User['id'][]>( const blockingCache = new Cache<User['id'][]>(1000 * 60 * 5);
5 * MINUTE,
(blockerId) => Blockings.findBy({ blockerId }).then(res => res.map(x => x.blockeeId)),
);
// designation for users you follow, list users and groups is disabled for performance reasons // NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
/** /**
* either noteUserFollowers or antennaUserFollowing must be specified * noteUserFollowers / antennaUserFollowing
*/ */
export async function checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }, noteUserFollowers?: User['id'][], antennaUserFollowing?: User['id'][]): Promise<boolean> { export async function checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }, noteUserFollowers?: User['id'][], antennaUserFollowing?: User['id'][]): Promise<boolean> {
if (note.visibility === 'specified') return false; if (note.visibility === 'specified') return false;
// skip if the antenna creator is blocked by the note author // アンテナ作成者がノート作成者にブロックされていたらスキップ
const blockings = await blockingCache.fetch(noteUser.id); const blockings = await blockingCache.fetch(noteUser.id, () => Blockings.findBy({ blockerId: noteUser.id }).then(res => res.map(x => x.blockeeId)));
if (blockings.some(blocking => blocking === antenna.userId)) return false; if (blockings.some(blocking => blocking === antenna.userId)) return false;
if (note.visibility === 'followers') { if (note.visibility === 'followers') {

View file

@ -1,44 +1,44 @@
import push from 'web-push';
import { db } from '@/db/postgre.js'; import { db } from '@/db/postgre.js';
import { Meta } from '@/models/entities/meta.js'; import { Meta } from '@/models/entities/meta.js';
import { getFetchInstanceMetadataLock } from '@/misc/app-lock.js';
let cache: Meta; let cache: Meta;
/** export async function fetchMeta(noCache = false): Promise<Meta> {
* Performs the primitive database operation to set the server configuration if (!noCache && cache) return cache;
*/
export async function setMeta(meta: Meta): Promise<void> {
const unlock = await getFetchInstanceMetadataLock('localhost');
// try to mitigate older bugs where multiple meta entries may have been created return await db.transaction(async transactionalEntityManager => {
db.manager.clear(Meta); // 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する
db.manager.insert(Meta, meta); const metas = await transactionalEntityManager.find(Meta, {
unlock();
}
/**
* Performs the primitive database operation to fetch server configuration.
* Writes to `cache` instead of returning.
*/
async function getMeta(): Promise<void> {
const unlock = await getFetchInstanceMetadataLock('localhost');
// new IDs are prioritised because multiple records may have been created due to past bugs
cache = db.manager.findOne(Meta, {
order: { order: {
id: 'DESC', id: 'DESC',
}, },
}); });
unlock(); const meta = metas[0];
if (meta) {
cache = meta;
return meta;
} else {
// metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う
const saved = await transactionalEntityManager
.upsert(
Meta,
{
id: 'x',
},
['id'],
)
.then((x) => transactionalEntityManager.findOneByOrFail(Meta, x.identifiers[0]));
cache = saved;
return saved;
}
});
} }
export async function fetchMeta(noCache = false): Promise<Meta> { setInterval(() => {
if (!noCache && cache) return cache; fetchMeta(true).then(meta => {
cache = meta;
await getMeta(); });
}, 1000 * 10);
return cache;
}

View file

@ -11,4 +11,4 @@ export const DB_MAX_NOTE_TEXT_LENGTH = 8192;
* Maximum image description length that can be stored in DB. * Maximum image description length that can be stored in DB.
* Surrogate pairs count as one * Surrogate pairs count as one
*/ */
export const DB_MAX_IMAGE_COMMENT_LENGTH = 2048; export const DB_MAX_IMAGE_COMMENT_LENGTH = 512;

View file

@ -1,18 +1,19 @@
const locales = await import('../../../../locales/index.js').then(mod => mod.default); export class I18n<T extends Record<string, any>> {
public locale: T;
export class I18n { constructor(locale: T) {
public ts: Record<string, any>; this.locale = locale;
constructor(locale: string) { //#region BIND
this.ts = locales[locale];
this.t = this.t.bind(this); this.t = this.t.bind(this);
//#endregion
} }
// string にしているのは、ドット区切りでのパス指定を許可するため // string にしているのは、ドット区切りでのパス指定を許可するため
// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも // なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
public t(key: string, args?: Record<string, any>): string { public t(key: string, args?: Record<string, any>): string {
try { try {
let str = key.split('.').reduce((o, i) => o[i], this.ts) as string; let str = key.split('.').reduce((o, i) => o[i], this.locale) as string;
if (args) { if (args) {
for (const [k, v] of Object.entries(args)) { for (const [k, v] of Object.entries(args)) {

View file

@ -3,11 +3,8 @@ import { User } from '@/models/entities/user.js';
import { UserKeypair } from '@/models/entities/user-keypair.js'; import { UserKeypair } from '@/models/entities/user-keypair.js';
import { Cache } from './cache.js'; import { Cache } from './cache.js';
const cache = new Cache<UserKeypair>( const cache = new Cache<UserKeypair>(Infinity);
Infinity,
(userId) => UserKeypairs.findOneByOrFail({ userId }),
);
export async function getUserKeypair(userId: User['id']): Promise<UserKeypair> { export async function getUserKeypair(userId: User['id']): Promise<UserKeypair> {
return await cache.fetch(userId); return await cache.fetch(userId, () => UserKeypairs.findOneByOrFail({ userId }));
} }

View file

@ -4,27 +4,14 @@ import { Emojis } from '@/models/index.js';
import { Emoji } from '@/models/entities/emoji.js'; import { Emoji } from '@/models/entities/emoji.js';
import { Note } from '@/models/entities/note.js'; import { Note } from '@/models/entities/note.js';
import { query } from '@/prelude/url.js'; import { query } from '@/prelude/url.js';
import { HOUR } from '@/const.js';
import { Cache } from './cache.js'; import { Cache } from './cache.js';
import { isSelfHost, toPunyNullable } from './convert-host.js'; import { isSelfHost, toPunyNullable } from './convert-host.js';
import { decodeReaction } from './reaction-lib.js'; import { decodeReaction } from './reaction-lib.js';
/** const cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12);
* composite cache key: `${host ?? ''}:${name}`
*/
const cache = new Cache<Emoji | null>(
12 * HOUR,
async (key) => {
const [host, name] = key.split(':');
return (await Emojis.findOneBy({
name,
host: host || IsNull(),
})) || null;
},
);
/** /**
* Information needed to attach in ActivityPub *
*/ */
type PopulatedEmoji = { type PopulatedEmoji = {
name: string; name: string;
@ -49,22 +36,28 @@ function parseEmojiStr(emojiName: string, noteUserHost: string | null) {
const name = match[1]; const name = match[1];
// ホスト正規化
const host = toPunyNullable(normalizeHost(match[2], noteUserHost)); const host = toPunyNullable(normalizeHost(match[2], noteUserHost));
return { name, host }; return { name, host };
} }
/** /**
* Resolve emoji information from ActivityPub attachment. *
* @param emojiName custom emoji names attached to notes, user profiles or in rections. Colons should not be included. Localhost is denote by @. (see also `decodeReaction`) * @param emojiName (:, @. (decodeReactionで可能))
* @param noteUserHost host that the content is from, to default to * @param noteUserHost
* @returns emoji information. `null` means not found. * @returns , nullは未マッチを意味する
*/ */
export async function populateEmoji(emojiName: string, noteUserHost: string | null): Promise<PopulatedEmoji | null> { export async function populateEmoji(emojiName: string, noteUserHost: string | null): Promise<PopulatedEmoji | null> {
const { name, host } = parseEmojiStr(emojiName, noteUserHost); const { name, host } = parseEmojiStr(emojiName, noteUserHost);
if (name == null) return null; if (name == null) return null;
const emoji = await cache.fetch(`${host ?? ''}:${name}`); const queryOrNull = async () => (await Emojis.findOneBy({
name,
host: host ?? IsNull(),
})) || null;
const emoji = await cache.fetch(`${name} ${host}`, queryOrNull);
if (emoji == null) return null; if (emoji == null) return null;
@ -79,7 +72,7 @@ export async function populateEmoji(emojiName: string, noteUserHost: string | nu
} }
/** /**
* Retrieve list of emojis from the cache. Uncached emoji are dropped. * (, )
*/ */
export async function populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<PopulatedEmoji[]> { export async function populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<PopulatedEmoji[]> {
const emojis = await Promise.all(emojiNames.map(x => populateEmoji(x, noteUserHost))); const emojis = await Promise.all(emojiNames.map(x => populateEmoji(x, noteUserHost)));
@ -110,20 +103,11 @@ export function aggregateNoteEmojis(notes: Note[]) {
} }
/** /**
* Query list of emojis in bulk and add them to the cache. *
*/ */
export async function prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> { export async function prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> {
const notCachedEmojis = emojis.filter(emoji => { const notCachedEmojis = emojis.filter(emoji => cache.get(`${emoji.name} ${emoji.host}`) == null);
// check if the cache has this emoji
return cache.get(`${emoji.host ?? ''}:${emoji.name}`) == null;
});
// check if there even are any uncached emoji to handle
if (notCachedEmojis.length === 0) return;
// query all uncached emoji
const emojisQuery: any[] = []; const emojisQuery: any[] = [];
// group by hosts to try to reduce query size
const hosts = new Set(notCachedEmojis.map(e => e.host)); const hosts = new Set(notCachedEmojis.map(e => e.host));
for (const host of hosts) { for (const host of hosts) {
emojisQuery.push({ emojisQuery.push({
@ -131,14 +115,11 @@ export async function prefetchEmojis(emojis: { name: string; host: string | null
host: host ?? IsNull(), host: host ?? IsNull(),
}); });
} }
const _emojis = emojisQuery.length > 0 ? await Emojis.find({
await Emojis.find({
where: emojisQuery, where: emojisQuery,
select: ['name', 'host', 'originalUrl', 'publicUrl'], select: ['name', 'host', 'originalUrl', 'publicUrl'],
}).then(emojis => { }) : [];
// store all emojis into the cache for (const emoji of _emojis) {
emojis.forEach(emoji => { cache.set(`${emoji.name} ${emoji.host}`, emoji);
cache.set(`${emoji.host ?? ''}:${emoji.name}`, emoji); }
});
});
} }

View file

@ -1,5 +1,5 @@
import { Note } from '@/models/entities/note.js'; import { Note } from '@/models/entities/note.js';
export function isPureRenote(note: Note): note is Note & { renoteId: string, text: null, fileIds: null | never[], hasPoll: false } { export function isPureRenote(note: Note): boolean {
return note.renoteId != null && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !note.hasPoll; return note.renoteId != null && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !note.hasPoll;
} }

View file

@ -1,55 +0,0 @@
import { Brackets } from 'typeorm';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { Instances } from '@/models/index.js';
import { Instance } from '@/models/entities/instance.js';
import { DAY } from '@/const.js';
// Threshold from last contact after which an instance will be considered
// "dead" and should no longer get activities delivered to it.
const deadThreshold = 7 * DAY;
/**
* Returns the subset of hosts which should be skipped.
*
* @param hosts array of punycoded instance hosts
* @returns array of punycoed instance hosts that should be skipped (subset of hosts parameter)
*/
export async function skippedInstances(hosts: Array<Instace['host']>): Array<Instance['host']> {
// first check for blocked instances since that info may already be in memory
const { blockedHosts } = await fetchMeta();
const skipped = hosts.filter(host => blockedHosts.includes(host));
// if possible return early and skip accessing the database
if (skipped.length === hosts.length) return hosts;
const deadTime = new Date(Date.now() - deadThreshold);
return skipped.concat(
await Instances.createQueryBuilder('instance')
.where('instance.host in (:...hosts)', {
// don't check hosts again that we already know are suspended
// also avoids adding duplicates to the list
hosts: hosts.filter(host => !skipped.includes(host)),
})
.andWhere(new Brackets(qb => { qb
.where('instance.isSuspended')
.orWhere('instance.lastCommunicatedAt < :deadTime', { deadTime })
.orWhere('instance.latestStatus = 410');
}))
.select('host')
.getRawMany()
);
}
/**
* Returns whether a specific host (punycoded) should be skipped.
* Convenience wrapper around skippedInstances which should only be used if there is a single host to check.
* If you have multiple hosts, consider using skippedInstances instead to do a bulk check.
*
* @param host punycoded instance host
* @returns whether the given host should be skipped
*/
export async function shouldSkipInstance(host: Instance['host']): boolean {
const skipped = await skippedInstances([host]);
return skipped.length > 0;
}

View file

@ -62,8 +62,7 @@ export class DriveFile {
public size: number; public size: number;
@Column('varchar', { @Column('varchar', {
length: 2048, length: 512, nullable: true,
nullable: true,
comment: 'The comment of the DriveFile.', comment: 'The comment of the DriveFile.',
}) })
public comment: string | null; public comment: string | null;

View file

@ -7,7 +7,7 @@ export class Instance {
public id: string; public id: string;
/** /**
* Date and time this instance was first seen. *
*/ */
@Index() @Index()
@Column('timestamp with time zone', { @Column('timestamp with time zone', {
@ -16,7 +16,7 @@ export class Instance {
public caughtAt: Date; public caughtAt: Date;
/** /**
* Hostname *
*/ */
@Index({ unique: true }) @Index({ unique: true })
@Column('varchar', { @Column('varchar', {
@ -26,7 +26,7 @@ export class Instance {
public host: string; public host: string;
/** /**
* Number of users on this instance. *
*/ */
@Column('integer', { @Column('integer', {
default: 0, default: 0,
@ -35,7 +35,7 @@ export class Instance {
public usersCount: number; public usersCount: number;
/** /**
* Number of notes on this instance. * 稿
*/ */
@Column('integer', { @Column('integer', {
default: 0, default: 0,
@ -44,7 +44,7 @@ export class Instance {
public notesCount: number; public notesCount: number;
/** /**
* Number of local users who are followed by users from this instance. *
*/ */
@Column('integer', { @Column('integer', {
default: 0, default: 0,
@ -52,7 +52,7 @@ export class Instance {
public followingCount: number; public followingCount: number;
/** /**
* Number of users from this instance who are followed by local users. *
*/ */
@Column('integer', { @Column('integer', {
default: 0, default: 0,
@ -60,7 +60,7 @@ export class Instance {
public followersCount: number; public followersCount: number;
/** /**
* Timestamp of the latest outgoing HTTP request. *
*/ */
@Column('timestamp with time zone', { @Column('timestamp with time zone', {
nullable: true, nullable: true,
@ -68,7 +68,7 @@ export class Instance {
public latestRequestSentAt: Date | null; public latestRequestSentAt: Date | null;
/** /**
* HTTP status code that was received for the last outgoing HTTP request. * HTTPステータスコード
*/ */
@Column('integer', { @Column('integer', {
nullable: true, nullable: true,
@ -76,7 +76,7 @@ export class Instance {
public latestStatus: number | null; public latestStatus: number | null;
/** /**
* Timestamp of the latest incoming HTTP request. *
*/ */
@Column('timestamp with time zone', { @Column('timestamp with time zone', {
nullable: true, nullable: true,
@ -84,13 +84,13 @@ export class Instance {
public latestRequestReceivedAt: Date | null; public latestRequestReceivedAt: Date | null;
/** /**
* Timestamp of last communication with this instance (incoming or outgoing). *
*/ */
@Column('timestamp with time zone') @Column('timestamp with time zone')
public lastCommunicatedAt: Date; public lastCommunicatedAt: Date;
/** /**
* Whether this instance seems unresponsive. *
*/ */
@Column('boolean', { @Column('boolean', {
default: false, default: false,
@ -98,7 +98,7 @@ export class Instance {
public isNotResponding: boolean; public isNotResponding: boolean;
/** /**
* Whether sending activities to this instance has been suspended. *
*/ */
@Index() @Index()
@Column('boolean', { @Column('boolean', {

View file

@ -77,6 +77,21 @@ export class Meta {
}) })
public blockedHosts: string[]; public blockedHosts: string[];
@Column('boolean', {
default: false
})
public secureMode: boolean;
@Column('boolean', {
default: false
})
public privateMode: boolean;
@Column('varchar', {
length: 256, array: true, default: '{}'
})
public allowedHosts: string[];
@Column('varchar', { @Column('varchar', {
length: 512, array: true, default: '{/featured,/channels,/explore,/pages,/about-foundkey}', length: 512, array: true, default: '{/featured,/channels,/explore,/pages,/about-foundkey}',
}) })

View file

@ -6,16 +6,13 @@ import { Packed } from '@/misc/schema.js';
import { awaitAll, Promiseable } from '@/prelude/await-all.js'; import { awaitAll, Promiseable } from '@/prelude/await-all.js';
import { populateEmojis } from '@/misc/populate-emojis.js'; import { populateEmojis } from '@/misc/populate-emojis.js';
import { getAntennas } from '@/misc/antenna-cache.js'; import { getAntennas } from '@/misc/antenna-cache.js';
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD, HOUR } from '@/const.js'; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
import { Cache } from '@/misc/cache.js'; import { Cache } from '@/misc/cache.js';
import { db } from '@/db/postgre.js'; import { db } from '@/db/postgre.js';
import { Instance } from '../entities/instance.js'; import { Instance } from '../entities/instance.js';
import { Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, RenoteMutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, AntennaNotes, ChannelFollowings, Instances, DriveFiles } from '../index.js'; import { Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, RenoteMutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, AntennaNotes, ChannelFollowings, Instances, DriveFiles } from '../index.js';
const userInstanceCache = new Cache<Instance | null>( const userInstanceCache = new Cache<Instance | null>(1000 * 60 * 60 * 3);
3 * HOUR,
(host) => Instances.findOneBy({ host }).then(x => x ?? undefined),
);
type IsUserDetailed<Detailed extends boolean> = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>; type IsUserDetailed<Detailed extends boolean> = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>;
type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends boolean> = type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends boolean> =
@ -30,7 +27,7 @@ const ajv = new Ajv();
const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const; const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const;
const passwordSchema = { type: 'string', minLength: 1 } as const; const passwordSchema = { type: 'string', minLength: 1 } as const;
const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
const descriptionSchema = { type: 'string', minLength: 1, maxLength: 2048 } as const; const descriptionSchema = { type: 'string', minLength: 1, maxLength: 500 } as const;
const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1) } as const; const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1) } as const;
@ -312,15 +309,17 @@ export const UserRepository = db.getRepository(User).extend({
isModerator: user.isModerator || falsy, isModerator: user.isModerator || falsy,
isBot: user.isBot || falsy, isBot: user.isBot || falsy,
isCat: user.isCat || falsy, isCat: user.isCat || falsy,
instance: !user.host ? undefined : userInstanceCache.fetch(user.host) instance: user.host ? userInstanceCache.fetch(user.host,
.then(instance => !instance ? undefined : { () => Instances.findOneBy({ host: user.host! }),
v => v != null,
).then(instance => instance ? {
name: instance.name, name: instance.name,
softwareName: instance.softwareName, softwareName: instance.softwareName,
softwareVersion: instance.softwareVersion, softwareVersion: instance.softwareVersion,
iconUrl: instance.iconUrl, iconUrl: instance.iconUrl,
faviconUrl: instance.faviconUrl, faviconUrl: instance.faviconUrl,
themeColor: instance.themeColor, themeColor: instance.themeColor,
}), } : undefined) : undefined,
emojis: populateEmojis(user.emojis, user.host), emojis: populateEmojis(user.emojis, user.host),
onlineStatus: this.getOnlineStatus(user), onlineStatus: this.getOnlineStatus(user),

View file

@ -6,20 +6,45 @@ import Logger from '@/services/logger.js';
import { Instances } from '@/models/index.js'; import { Instances } from '@/models/index.js';
import { apRequestChart, federationChart, instanceChart } from '@/services/chart/index.js'; import { apRequestChart, federationChart, instanceChart } from '@/services/chart/index.js';
import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js'; import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { toPuny } from '@/misc/convert-host.js'; import { toPuny } from '@/misc/convert-host.js';
import { Cache } from '@/misc/cache.js';
import { Instance } from '@/models/entities/instance.js';
import { StatusError } from '@/misc/fetch.js'; import { StatusError } from '@/misc/fetch.js';
import { shouldSkipInstance } from '@/misc/skipped-instances.js';
import { DeliverJobData } from '@/queue/types.js'; import { DeliverJobData } from '@/queue/types.js';
const logger = new Logger('deliver'); const logger = new Logger('deliver');
let latest: string | null = null; let latest: string | null = null;
const suspendedHostsCache = new Cache<Instance[]>(1000 * 60 * 60);
export default async (job: Bull.Job<DeliverJobData>) => { export default async (job: Bull.Job<DeliverJobData>) => {
const { host } = new URL(job.data.to); const { host } = new URL(job.data.to);
const puny = toPuny(host);
if (await shouldSkipInstance(puny)) return 'skip'; // ブロックしてたら中断
const meta = await fetchMeta();
if (meta.blockedHosts.includes(toPuny(host))) {
return 'skip (blocked)';
}
if (meta.privateMode && !meta.allowedHosts.includes(toPuny(host))) {
return 'skip (not allowed)';
}
// isSuspendedなら中断
let suspendedHosts = suspendedHostsCache.get(null);
if (suspendedHosts == null) {
suspendedHosts = await Instances.find({
where: {
isSuspended: true,
},
});
suspendedHostsCache.set(null, suspendedHosts);
}
if (suspendedHosts.map(x => x.host).includes(toPuny(host))) {
return 'skip (suspended)';
}
try { try {
if (latest !== (latest = JSON.stringify(job.data.content, null, 2))) { if (latest !== (latest = JSON.stringify(job.data.content, null, 2))) {
@ -62,8 +87,8 @@ export default async (job: Bull.Job<DeliverJobData>) => {
if (res instanceof StatusError) { if (res instanceof StatusError) {
// 4xx // 4xx
if (res.isClientError) { if (res.isClientError) {
// A client error means that something is wrong with the request we are making, // HTTPステータスコード4xxはクライアントエラーであり、それはつまり
// which means that retrying it makes no sense. // 何回再送しても成功することはないということなのでエラーにはしないでおく
return `${res.statusCode} ${res.statusMessage}`; return `${res.statusCode} ${res.statusMessage}`;
} }

View file

@ -39,6 +39,11 @@ export default async (job: Bull.Job<InboxJobData>): Promise<string> => {
return `Blocked request: ${host}`; return `Blocked request: ${host}`;
} }
// Only permitted instances if in private mode.
if (meta.privateMode && !meta.allowedHosts.includes(host)) {
return `Blocked request: ${host}`;
}
const keyIdLower = signature.keyId.toLowerCase(); const keyIdLower = signature.keyId.toLowerCase();
if (keyIdLower.startsWith('acct:')) { if (keyIdLower.startsWith('acct:')) {
return `Old keyId is no longer supported. ${keyIdLower}`; return `Old keyId is no longer supported. ${keyIdLower}`;

View file

@ -1,6 +1,6 @@
import Bull from 'bull'; import Bull from 'bull';
import { In, LessThan } from 'typeorm'; import { In, LessThan } from 'typeorm';
import { AttestationChallenges, Mutings, PasswordResetRequests, Signins } from '@/models/index.js'; import { AttestationChallenges, Mutings, Signins } from '@/models/index.js';
import { publishUserEvent } from '@/services/stream.js'; import { publishUserEvent } from '@/services/stream.js';
import { MINUTE, DAY } from '@/const.js'; import { MINUTE, DAY } from '@/const.js';
import { queueLogger } from '@/queue/logger.js'; import { queueLogger } from '@/queue/logger.js';
@ -35,11 +35,6 @@ export async function checkExpired(job: Bull.Job<Record<string, unknown>>, done:
createdAt: LessThan(new Date(new Date().getTime() - 5 * MINUTE)), createdAt: LessThan(new Date(new Date().getTime() - 5 * MINUTE)),
}); });
await PasswordResetRequests.delete({
// this timing should be the same as in @/server/api/endpoints/reset-password.ts
createdAt: LessThan(new Date(new Date().getTime() - 30 * MINUTE)),
});
logger.succ('Deleted expired mutes, signins and attestation challenges.'); logger.succ('Deleted expired mutes, signins and attestation challenges.');
done(); done();

View file

@ -0,0 +1,69 @@
import config from '@/config/index.js';
import { IncomingMessage } from 'http';
import { fetchMeta } from '@/misc/fetch-meta.js';
import httpSignature from '@peertube/http-signature';
import { URL } from 'url';
import { toPuny } from '@/misc/convert-host.js';
import DbResolver from '@/remote/activitypub/db-resolver.js';
import { getApId } from '@/remote/activitypub/type.js';
export default async function checkFetch(req: IncomingMessage): Promise<number> {
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
let signature;
try {
signature = httpSignature.parseRequest(req, { 'headers': [] });
} catch (e) {
return 401;
}
const keyId = new URL(signature.keyId);
const host = toPuny(keyId.hostname);
if (meta.blockedHosts.includes(host)) {
return 403;
}
if (meta.privateMode && host !== config.host && !meta.allowedHosts.includes(host)) {
return 403;
}
const keyIdLower = signature.keyId.toLowerCase();
if (keyIdLower.startsWith('acct:')) {
// Old keyId is no longer supported.
return 401;
}
const dbResolver = new DbResolver();
// Get user from database based on HTTP-Signature keyId
let authUser = await dbResolver.getAuthUserFromKeyId(signature.keyId);
// If keyid is unknown, try resolving it
if (authUser == null) {
try {
keyId.hash = '';
authUser = await dbResolver.getAuthUserFromApId(getApId(keyId.toString()));
} catch (e) {
return 403;
}
}
if (authUser?.key == null) {
return 403;
}
if (authUser.user.host !== host) {
return 403;
}
// HTTP-Signature validation
const httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem);
if (!httpSignatureValidated) {
return 403;
}
}
return 200;
}

View file

@ -10,14 +10,8 @@ import { uriPersonCache, userByIdCache } from '@/services/user-cache.js';
import { IObject, getApId } from './type.js'; import { IObject, getApId } from './type.js';
import { resolvePerson } from './models/person.js'; import { resolvePerson } from './models/person.js';
const publicKeyCache = new Cache<UserPublickey>( const publicKeyCache = new Cache<UserPublickey | null>(Infinity);
Infinity, const publicKeyByUserIdCache = new Cache<UserPublickey | null>(Infinity);
(keyId) => UserPublickeys.findOneBy({ keyId }).then(x => x ?? undefined),
);
const publicKeyByUserIdCache = new Cache<UserPublickey>(
Infinity,
(userId) => UserPublickeys.findOneBy({ userId }).then(x => x ?? undefined),
);
export type UriParseResult = { export type UriParseResult = {
/** wether the URI was generated by us */ /** wether the URI was generated by us */
@ -105,9 +99,13 @@ export default class DbResolver {
if (parsed.local) { if (parsed.local) {
if (parsed.type !== 'users') return null; if (parsed.type !== 'users') return null;
return await userByIdCache.fetch(parsed.id) ?? null; return await userByIdCache.fetchMaybe(parsed.id, () => Users.findOneBy({
id: parsed.id,
}).then(x => x ?? undefined)) ?? null;
} else { } else {
return await uriPersonCache.fetch(parsed.uri) ?? null; return await uriPersonCache.fetch(parsed.uri, () => Users.findOneBy({
uri: parsed.uri,
}));
} }
} }
@ -118,12 +116,20 @@ export default class DbResolver {
user: CacheableRemoteUser; user: CacheableRemoteUser;
key: UserPublickey; key: UserPublickey;
} | null> { } | null> {
const key = await publicKeyCache.fetch(keyId); const key = await publicKeyCache.fetch(keyId, async () => {
const key = await UserPublickeys.findOneBy({
keyId,
});
if (key == null) return null;
return key;
}, key => key != null);
if (key == null) return null; if (key == null) return null;
return { return {
user: await userByIdCache.fetch(key.userId) as CacheableRemoteUser, user: await userByIdCache.fetch(key.userId, () => Users.findOneByOrFail({ id: key.userId })) as CacheableRemoteUser,
key, key,
}; };
} }
@ -139,7 +145,7 @@ export default class DbResolver {
if (user == null) return null; if (user == null) return null;
const key = await publicKeyByUserIdCache.fetch(user.id); const key = await publicKeyByUserIdCache.fetch(user.id, () => UserPublickeys.findOneBy({ userId: user.id }), v => v != null);
return { return {
user, user,

View file

@ -2,17 +2,12 @@ import { IsNull, Not } from 'typeorm';
import { ILocalUser, IRemoteUser, User } from '@/models/entities/user.js'; import { ILocalUser, IRemoteUser, User } from '@/models/entities/user.js';
import { Users, Followings } from '@/models/index.js'; import { Users, Followings } from '@/models/index.js';
import { deliver } from '@/queue/index.js'; import { deliver } from '@/queue/index.js';
import { skippedInstances } from '@/misc/skipped-instances.js';
//#region types //#region types
interface IRecipe { interface IRecipe {
type: string; type: string;
} }
interface IEveryoneRecipe extends IRecipe {
type: 'Everyone';
}
interface IFollowersRecipe extends IRecipe { interface IFollowersRecipe extends IRecipe {
type: 'Followers'; type: 'Followers';
} }
@ -22,9 +17,6 @@ interface IDirectRecipe extends IRecipe {
to: IRemoteUser; to: IRemoteUser;
} }
const isEveryone = (recipe: any): recipe is IEveryoneRecipe =>
recipe.type === 'Everyone';
const isFollowers = (recipe: any): recipe is IFollowersRecipe => const isFollowers = (recipe: any): recipe is IFollowersRecipe =>
recipe.type === 'Followers'; recipe.type === 'Followers';
@ -71,13 +63,6 @@ export default class DeliverManager {
this.addRecipe(recipe); this.addRecipe(recipe);
} }
/**
* Add recipe to send this activity to all known sharedInboxes
*/
public addEveryone() {
this.addRecipe({ type: 'Everyone' } as IEveryoneRecipe);
}
/** /**
* Add recipe * Add recipe
* @param recipe Recipe * @param recipe Recipe
@ -97,40 +82,31 @@ export default class DeliverManager {
/* /*
build inbox list build inbox list
Processing order matters to avoid duplication. Process follower recipes first to avoid duplication when processing
direct recipes later.
*/ */
if (this.recipes.some(r => isEveryone(r))) {
// deliver to all of known network
const sharedInboxes = await Users.createQueryBuilder('users')
.select('users.sharedInbox', 'sharedInbox')
// so we don't have to make our inboxes Set work as hard
.distinct(true)
// can't deliver to unknown shared inbox
.where('users.sharedInbox IS NOT NULL')
// don't deliver to ourselves
.andWhere('users.host IS NOT NULL')
.getRawMany();
for (const inbox of sharedInboxes) {
inboxes.add(inbox.sharedInbox);
}
}
if (this.recipes.some(r => isFollowers(r))) { if (this.recipes.some(r => isFollowers(r))) {
// followers deliver // followers deliver
const followers = await Followings.createQueryBuilder('followings') // TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう
// return either the shared inbox (if available) or the individual inbox // ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう
.select('COALESCE(followings.followerSharedInbox, followings.followerInbox)', 'inbox') const followers = await Followings.find({
// so we don't have to make our inboxes Set work as hard where: {
.distinct(true) followeeId: this.actor.id,
// ...for the specific actors followers followerHost: Not(IsNull()),
.where('followings.followeeId = :actorId', { actorId: this.actor.id }) },
// don't deliver to ourselves select: {
.andWhere('followings.followerHost IS NOT NULL') followerSharedInbox: true,
.getRawMany(); followerInbox: true,
},
}) as {
followerSharedInbox: string | null;
followerInbox: string;
}[];
followers.forEach(({ inbox }) => inboxes.add(inbox)); for (const following of followers) {
const inbox = following.followerSharedInbox || following.followerInbox;
inboxes.add(inbox);
}
} }
this.recipes.filter((recipe): recipe is IDirectRecipe => this.recipes.filter((recipe): recipe is IDirectRecipe =>
@ -143,19 +119,8 @@ export default class DeliverManager {
) )
.forEach(recipe => inboxes.add(recipe.to.inbox!)); .forEach(recipe => inboxes.add(recipe.to.inbox!));
const instancesToSkip = await skippedInstances(
// get (unique) list of hosts
Array.from(new Set(
Array.from(inboxes)
.map(inbox => new URL(inbox).host)
))
);
// deliver // deliver
for (const inbox of inboxes) { for (const inbox of inboxes) {
// skip instances as indicated
if (instancesToSkip.includes(new URL(inbox).host)) continue;
deliver(this.actor, this.activity, inbox); deliver(this.actor, this.activity, inbox);
} }
} }

View file

@ -1,5 +1,5 @@
import { CacheableRemoteUser } from '@/models/entities/user.js'; import { CacheableRemoteUser } from '@/models/entities/user.js';
import { createReaction } from '@/services/note/reaction/create.js'; import create from '@/services/note/reaction/create.js';
import { ILike, getApId } from '../type.js'; import { ILike, getApId } from '../type.js';
import { fetchNote, extractEmojis } from '../models/note.js'; import { fetchNote, extractEmojis } from '../models/note.js';
@ -11,7 +11,7 @@ export default async (actor: CacheableRemoteUser, activity: ILike) => {
await extractEmojis(activity.tag || [], actor.host).catch(() => null); await extractEmojis(activity.tag || [], actor.host).catch(() => null);
return await createReaction(actor, note, activity._misskey_reaction || activity.content || activity.name).catch(e => { return await create(actor, note, activity._misskey_reaction || activity.content || activity.name).catch(e => {
if (e.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') { if (e.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') {
return 'skip: already reacted'; return 'skip: already reacted';
} else { } else {

View file

@ -1,5 +1,5 @@
import { CacheableRemoteUser } from '@/models/entities/user.js'; import { CacheableRemoteUser } from '@/models/entities/user.js';
import { deleteReaction } from '@/services/note/reaction/delete.js'; import deleteReaction from '@/services/note/reaction/delete.js';
import { ILike, getApId } from '@/remote/activitypub/type.js'; import { ILike, getApId } from '@/remote/activitypub/type.js';
import { fetchNote } from '@/remote/activitypub/models/note.js'; import { fetchNote } from '@/remote/activitypub/models/note.js';

View file

@ -4,7 +4,7 @@ import config from '@/config/index.js';
import post from '@/services/note/create.js'; import post from '@/services/note/create.js';
import { CacheableRemoteUser } from '@/models/entities/user.js'; import { CacheableRemoteUser } from '@/models/entities/user.js';
import { unique, toArray, toSingle } from '@/prelude/array.js'; import { unique, toArray, toSingle } from '@/prelude/array.js';
import { vote } from '@/services/note/polls/vote.js'; import vote from '@/services/note/polls/vote.js';
import { DriveFile } from '@/models/entities/drive-file.js'; import { DriveFile } from '@/models/entities/drive-file.js';
import { deliverQuestionUpdate } from '@/services/note/polls/update.js'; import { deliverQuestionUpdate } from '@/services/note/polls/update.js';
import { extractDbHost, toPuny } from '@/misc/convert-host.js'; import { extractDbHost, toPuny } from '@/misc/convert-host.js';

View file

@ -34,7 +34,7 @@ export default async (user: { id: User['id'] }, url: string, object: any) => {
* @param user http-signature user * @param user http-signature user
* @param url URL to fetch * @param url URL to fetch
*/ */
export async function signedGet(url: string, user: { id: User['id'] }): Promise<any> { export async function signedGet(url: string, user: { id: User['id'] }) {
const keypair = await getUserKeypair(user.id); const keypair = await getUserKeypair(user.id);
const req = createSignedGet({ const req = createSignedGet({

View file

@ -72,7 +72,11 @@ export default class Resolver {
throw new Error('Instance is blocked'); throw new Error('Instance is blocked');
} }
if (!this.user) { if (meta.privateMode && config.host !== host && !meta.allowedHosts.includes(host)) {
throw new Error('Instance is not allowed');
}
if (config.signToActivityPubGet && !this.user) {
this.user = await getInstanceActor(); this.user = await getInstanceActor();
} }

View file

@ -9,11 +9,14 @@ import renderKey from '@/remote/activitypub/renderer/key.js';
import { renderPerson } from '@/remote/activitypub/renderer/person.js'; import { renderPerson } from '@/remote/activitypub/renderer/person.js';
import renderEmoji from '@/remote/activitypub/renderer/emoji.js'; import renderEmoji from '@/remote/activitypub/renderer/emoji.js';
import { inbox as processInbox } from '@/queue/index.js'; import { inbox as processInbox } from '@/queue/index.js';
import { isSelfHost } from '@/misc/convert-host.js'; import { isSelfHost, toPuny } from '@/misc/convert-host.js';
import { Notes, Users, Emojis, NoteReactions } from '@/models/index.js'; import { Notes, Users, Emojis, NoteReactions } from '@/models/index.js';
import { ILocalUser, User } from '@/models/entities/user.js'; import { ILocalUser, User } from '@/models/entities/user.js';
import { renderLike } from '@/remote/activitypub/renderer/like.js'; import { renderLike } from '@/remote/activitypub/renderer/like.js';
import { getUserKeypair } from '@/misc/keypair-store.js'; import { getUserKeypair } from '@/misc/keypair-store.js';
import checkFetch from '@/remote/activitypub/check-fetch.js';
import { getInstanceActor } from '@/services/instance-actor.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import renderFollow from '@/remote/activitypub/renderer/follow.js'; import renderFollow from '@/remote/activitypub/renderer/follow.js';
import Outbox, { packActivity } from './activitypub/outbox.js'; import Outbox, { packActivity } from './activitypub/outbox.js';
import Followers from './activitypub/followers.js'; import Followers from './activitypub/followers.js';
@ -23,6 +26,8 @@ import Featured from './activitypub/featured.js';
// Init router // Init router
const router = new Router(); const router = new Router();
//#region Routing
function inbox(ctx: Router.RouterContext) { function inbox(ctx: Router.RouterContext) {
let signature; let signature;
@ -43,8 +48,6 @@ const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystr
function isActivityPubReq(ctx: Router.RouterContext) { function isActivityPubReq(ctx: Router.RouterContext) {
ctx.response.vary('Accept'); ctx.response.vary('Accept');
// if no accept header is supplied, koa returns the 1st, so html is used as a dummy
// i.e. activitypub requests must be explicit
const accepted = ctx.accepts('html', ACTIVITY_JSON, LD_JSON); const accepted = ctx.accepts('html', ACTIVITY_JSON, LD_JSON);
return typeof accepted === 'string' && !accepted.match(/html/); return typeof accepted === 'string' && !accepted.match(/html/);
} }
@ -66,6 +69,12 @@ router.post('/users/:user/inbox', json(), inbox);
router.get('/notes/:note', async (ctx, next) => { router.get('/notes/:note', async (ctx, next) => {
if (!isActivityPubReq(ctx)) return await next(); if (!isActivityPubReq(ctx)) return await next();
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const note = await Notes.findOneBy({ const note = await Notes.findOneBy({
id: ctx.params.note, id: ctx.params.note,
visibility: In(['public' as const, 'home' as const]), visibility: In(['public' as const, 'home' as const]),
@ -77,8 +86,8 @@ router.get('/notes/:note', async (ctx, next) => {
return; return;
} }
// redirect if remote // リモートだったらリダイレクト
if (note.userHost != null) { if (note.userHost !== null) {
if (note.uri == null || isSelfHost(note.userHost)) { if (note.uri == null || isSelfHost(note.userHost)) {
ctx.status = 500; ctx.status = 500;
return; return;
@ -88,18 +97,21 @@ router.get('/notes/:note', async (ctx, next) => {
} }
ctx.body = renderActivity(await renderNote(note, false)); ctx.body = renderActivity(await renderNote(note, false));
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set('Cache-Control', 'no-store');
} else {
ctx.set('Cache-Control', 'public, max-age=180'); ctx.set('Cache-Control', 'public, max-age=180');
}
setResponseType(ctx); setResponseType(ctx);
}); });
// note activity // note activity
router.get('/notes/:note/activity', async ctx => { router.get('/notes/:note/activity', async ctx => {
if (!isActivityPubReq(ctx)) { const verify = await checkFetch(ctx.req);
/* if (verify !== 200) {
Redirect to the human readable page. in this case using next is not possible, ctx.status = verify;
since there is no human readable page explicitly for the activity.
*/
ctx.redirect(`/notes/${ctx.params.note}`);
return; return;
} }
@ -116,7 +128,12 @@ router.get('/notes/:note/activity', async ctx => {
} }
ctx.body = renderActivity(await packActivity(note)); ctx.body = renderActivity(await packActivity(note));
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set('Cache-Control', 'no-store');
} else {
ctx.set('Cache-Control', 'public, max-age=180'); ctx.set('Cache-Control', 'public, max-age=180');
}
setResponseType(ctx); setResponseType(ctx);
}); });
@ -134,6 +151,20 @@ router.get('/users/:user/collections/featured', Featured);
// publickey // publickey
router.get('/users/:user/publickey', async ctx => { router.get('/users/:user/publickey', async ctx => {
const instanceActor = await getInstanceActor();
if (ctx.params.user === instanceActor.id) {
ctx.body = renderActivity(renderKey(instanceActor, await getUserKeypair(instanceActor.id)));
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
return;
}
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const userId = ctx.params.user; const userId = ctx.params.user;
const user = await Users.findOneBy({ const user = await Users.findOneBy({
@ -150,7 +181,12 @@ router.get('/users/:user/publickey', async ctx => {
if (Users.isLocalUser(user)) { if (Users.isLocalUser(user)) {
ctx.body = renderActivity(renderKey(user, keypair)); ctx.body = renderActivity(renderKey(user, keypair));
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set('Cache-Control', 'no-store');
} else {
ctx.set('Cache-Control', 'public, max-age=180'); ctx.set('Cache-Control', 'public, max-age=180');
}
setResponseType(ctx); setResponseType(ctx);
} else { } else {
ctx.status = 400; ctx.status = 400;
@ -165,13 +201,30 @@ async function userInfo(ctx: Router.RouterContext, user: User | null) {
} }
ctx.body = renderActivity(await renderPerson(user as ILocalUser)); ctx.body = renderActivity(await renderPerson(user as ILocalUser));
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set('Cache-Control', 'no-store');
} else {
ctx.set('Cache-Control', 'public, max-age=180'); ctx.set('Cache-Control', 'public, max-age=180');
}
setResponseType(ctx); setResponseType(ctx);
} }
router.get('/users/:user', async (ctx, next) => { router.get('/users/:user', async (ctx, next) => {
if (!isActivityPubReq(ctx)) return await next(); if (!isActivityPubReq(ctx)) return await next();
const instanceActor = await getInstanceActor();
if (ctx.params.user === instanceActor.id) {
await userInfo(ctx, instanceActor);
return;
}
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const userId = ctx.params.user; const userId = ctx.params.user;
const user = await Users.findOneBy({ const user = await Users.findOneBy({
@ -186,6 +239,18 @@ router.get('/users/:user', async (ctx, next) => {
router.get('/@:user', async (ctx, next) => { router.get('/@:user', async (ctx, next) => {
if (!isActivityPubReq(ctx)) return await next(); if (!isActivityPubReq(ctx)) return await next();
if (ctx.params.user === 'instance.actor') {
const instanceActor = await getInstanceActor();
await userInfo(ctx, instanceActor);
return;
}
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const user = await Users.findOneBy({ const user = await Users.findOneBy({
usernameLower: ctx.params.user.toLowerCase(), usernameLower: ctx.params.user.toLowerCase(),
host: IsNull(), host: IsNull(),
@ -195,6 +260,12 @@ router.get('/@:user', async (ctx, next) => {
await userInfo(ctx, user); await userInfo(ctx, user);
}); });
router.get('/actor', async (ctx, next) => {
const instanceActor = await getInstanceActor();
await userInfo(ctx, instanceActor);
});
//#endregion
// emoji // emoji
router.get('/emojis/:emoji', async ctx => { router.get('/emojis/:emoji', async ctx => {
const emoji = await Emojis.findOneBy({ const emoji = await Emojis.findOneBy({
@ -208,12 +279,23 @@ router.get('/emojis/:emoji', async ctx => {
} }
ctx.body = renderActivity(await renderEmoji(emoji)); ctx.body = renderActivity(await renderEmoji(emoji));
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set('Cache-Control', 'no-store');
} else {
ctx.set('Cache-Control', 'public, max-age=180'); ctx.set('Cache-Control', 'public, max-age=180');
}
setResponseType(ctx); setResponseType(ctx);
}); });
// like // like
router.get('/likes/:like', async ctx => { router.get('/likes/:like', async ctx => {
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const note = await Notes.findOneBy({ const note = await Notes.findOneBy({
id: reaction.noteId, id: reaction.noteId,
visibility: In(['public' as const, 'home' as const]), visibility: In(['public' as const, 'home' as const]),
@ -232,12 +314,22 @@ router.get('/likes/:like', async ctx => {
} }
ctx.body = renderActivity(await renderLike(reaction, note)); ctx.body = renderActivity(await renderLike(reaction, note));
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set('Cache-Control', 'no-store');
} else {
ctx.set('Cache-Control', 'public, max-age=180'); ctx.set('Cache-Control', 'public, max-age=180');
}
setResponseType(ctx); setResponseType(ctx);
}); });
// follow // follow
router.get('/follows/:follower/:followee', async ctx => { router.get('/follows/:follower/:followee', async ctx => {
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
// This may be used before the follow is completed, so we do not // This may be used before the follow is completed, so we do not
// check if the following exists. // check if the following exists.
@ -258,7 +350,12 @@ router.get('/follows/:follower/:followee', async ctx => {
} }
ctx.body = renderActivity(renderFollow(follower, followee)); ctx.body = renderActivity(renderFollow(follower, followee));
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set('Cache-Control', 'no-store');
} else {
ctx.set('Cache-Control', 'public, max-age=180'); ctx.set('Cache-Control', 'public, max-age=180');
}
setResponseType(ctx); setResponseType(ctx);
}); });

View file

@ -6,8 +6,17 @@ import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-colle
import renderNote from '@/remote/activitypub/renderer/note.js'; import renderNote from '@/remote/activitypub/renderer/note.js';
import { Users, Notes, UserNotePinings } from '@/models/index.js'; import { Users, Notes, UserNotePinings } from '@/models/index.js';
import { setResponseType } from '../activitypub.js'; import { setResponseType } from '../activitypub.js';
import { IsNull } from 'typeorm';
import checkFetch from '@/remote/activitypub/check-fetch.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
export default async (ctx: Router.RouterContext) => { export default async (ctx: Router.RouterContext) => {
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const userId = ctx.params.user; const userId = ctx.params.user;
const user = await Users.findOneBy({ const user = await Users.findOneBy({
@ -36,6 +45,12 @@ export default async (ctx: Router.RouterContext) => {
); );
ctx.body = renderActivity(rendered); ctx.body = renderActivity(rendered);
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set('Cache-Control', 'no-store');
} else {
ctx.set('Cache-Control', 'public, max-age=180'); ctx.set('Cache-Control', 'public, max-age=180');
}
setResponseType(ctx); setResponseType(ctx);
}; };

View file

@ -9,12 +9,20 @@ import renderFollowUser from '@/remote/activitypub/renderer/follow-user.js';
import { Users, Followings, UserProfiles } from '@/models/index.js'; import { Users, Followings, UserProfiles } from '@/models/index.js';
import { Following } from '@/models/entities/following.js'; import { Following } from '@/models/entities/following.js';
import { setResponseType } from '../activitypub.js'; import { setResponseType } from '../activitypub.js';
import checkFetch from '@/remote/activitypub/check-fetch.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
export default async (ctx: Router.RouterContext) => { export default async (ctx: Router.RouterContext) => {
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const userId = ctx.params.user; const userId = ctx.params.user;
const cursor = ctx.request.query.cursor; const cursor = ctx.request.query.cursor;
if (cursor != null && typeof cursor !== 'string') { if (cursor !== null && typeof cursor !== 'string') {
ctx.status = 400; ctx.status = 400;
return; return;
} }
@ -89,7 +97,12 @@ export default async (ctx: Router.RouterContext) => {
// index page // index page
const rendered = renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`); const rendered = renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`);
ctx.body = renderActivity(rendered); ctx.body = renderActivity(rendered);
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx); setResponseType(ctx);
} }
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set('Cache-Control', 'no-store');
} else {
ctx.set('Cache-Control', 'public, max-age=180');
}
}; };

View file

@ -9,12 +9,20 @@ import renderFollowUser from '@/remote/activitypub/renderer/follow-user.js';
import { Users, Followings, UserProfiles } from '@/models/index.js'; import { Users, Followings, UserProfiles } from '@/models/index.js';
import { Following } from '@/models/entities/following.js'; import { Following } from '@/models/entities/following.js';
import { setResponseType } from '../activitypub.js'; import { setResponseType } from '../activitypub.js';
import checkFetch from '@/remote/activitypub/check-fetch.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
export default async (ctx: Router.RouterContext) => { export default async (ctx: Router.RouterContext) => {
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const userId = ctx.params.user; const userId = ctx.params.user;
const cursor = ctx.request.query.cursor; const cursor = ctx.request.query.cursor;
if (cursor != null && typeof cursor !== 'string') { if (cursor !== null && typeof cursor !== 'string') {
ctx.status = 400; ctx.status = 400;
return; return;
} }
@ -89,7 +97,12 @@ export default async (ctx: Router.RouterContext) => {
// index page // index page
const rendered = renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`); const rendered = renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`);
ctx.body = renderActivity(rendered); ctx.body = renderActivity(rendered);
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx); setResponseType(ctx);
} }
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set('Cache-Control', 'no-store');
} else {
ctx.set('Cache-Control', 'public, max-age=180');
}
}; };

View file

@ -14,25 +14,33 @@ import { Note } from '@/models/entities/note.js';
import { isPureRenote } from '@/misc/renote.js'; import { isPureRenote } from '@/misc/renote.js';
import { makePaginationQuery } from '../api/common/make-pagination-query.js'; import { makePaginationQuery } from '../api/common/make-pagination-query.js';
import { setResponseType } from '../activitypub.js'; import { setResponseType } from '../activitypub.js';
import checkFetch from '@/remote/activitypub/check-fetch.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
export default async (ctx: Router.RouterContext) => { export default async (ctx: Router.RouterContext) => {
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const userId = ctx.params.user; const userId = ctx.params.user;
const sinceId = ctx.request.query.since_id; const sinceId = ctx.request.query.since_id;
if (sinceId != null && typeof sinceId !== 'string') { if (sinceId !== null && typeof sinceId !== 'string') {
ctx.status = 400; ctx.status = 400;
return; return;
} }
const untilId = ctx.request.query.until_id; const untilId = ctx.request.query.until_id;
if (untilId != null && typeof untilId !== 'string') { if (untilId !== null && typeof untilId !== 'string') {
ctx.status = 400; ctx.status = 400;
return; return;
} }
const page = ctx.request.query.page === 'true'; const page = ctx.request.query.page === 'true';
if (countIf(x => x != null, [sinceId, untilId]) > 1) { if (countIf(x => x !== null, [sinceId, untilId]) > 1) {
ctx.status = 400; ctx.status = 400;
return; return;
} }
@ -90,9 +98,15 @@ export default async (ctx: Router.RouterContext) => {
`${partOf}?page=true&since_id=000000000000000000000000`, `${partOf}?page=true&since_id=000000000000000000000000`,
); );
ctx.body = renderActivity(rendered); ctx.body = renderActivity(rendered);
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx); setResponseType(ctx);
} }
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set('Cache-Control', 'no-store');
} else {
ctx.set('Cache-Control', 'public, max-age=180');
}
}; };
/** /**

View file

@ -5,51 +5,59 @@ import authenticate, { AuthenticationError } from './authenticate.js';
import call from './call.js'; import call from './call.js';
import { ApiError } from './error.js'; import { ApiError } from './error.js';
export async function handler(endpoint: IEndpoint, ctx: Koa.Context): Promise<void> { export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise<void>((res) => {
const body = ctx.is('multipart/form-data') const body = ctx.is('multipart/form-data')
? (ctx.request as any).body ? (ctx.request as any).body
: ctx.method === 'GET' : ctx.method === 'GET'
? ctx.query ? ctx.query
: ctx.request.body; : ctx.request.body;
const error = (e: ApiError): void => { const reply = (x?: any, y?: ApiError) => {
ctx.status = e.httpStatusCode; if (x == null) {
if (e.httpStatusCode === 401) { ctx.status = 204;
ctx.response.set('WWW-Authenticate', 'Bearer'); } else if (typeof x === 'number' && y) {
} ctx.status = x;
ctx.body = { ctx.body = {
error: { error: {
message: e!.message, message: y!.message,
code: e!.code, code: y!.code,
...(e!.info ? { info: e!.info } : {}), id: y!.id,
endpoint: endpoint.name, kind: y!.kind,
...(y!.info ? { info: y!.info } : {}),
}, },
}; };
} else {
// 文字列を返す場合は、JSON.stringify通さないとJSONと認識されない
ctx.body = typeof x === 'string' ? JSON.stringify(x) : x;
}
res();
}; };
// Authentication // Authentication
// for GET requests, do not even pass on the body parameter as it is considered unsafe // for GET requests, do not even pass on the body parameter as it is considered unsafe
await authenticate(ctx.headers.authorization, ctx.method === 'GET' ? null : body['i']).then(async ([user, app]) => { authenticate(ctx.headers.authorization, ctx.method === 'GET' ? null : body['i']).then(([user, app]) => {
// API invoking // API invoking
await call(endpoint.name, user, app, body, ctx).then((res: any) => { call(endpoint.name, user, app, body, ctx).then((res: any) => {
if (ctx.method === 'GET' && endpoint.meta.cacheSec && !body['i'] && !user) { if (ctx.method === 'GET' && endpoint.meta.cacheSec && !body['i'] && !user) {
ctx.set('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`); ctx.set('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`);
} }
if (res == null) { reply(res);
ctx.status = 204;
} else {
ctx.status = 200;
// If a string is returned, it must be passed through JSON.stringify to be recognized as JSON.
ctx.body = typeof res === 'string' ? JSON.stringify(res) : res;
}
}).catch((e: ApiError) => { }).catch((e: ApiError) => {
error(e); reply(e.httpStatusCode ? e.httpStatusCode : e.kind === 'client' ? 400 : 500, e);
}); });
}).catch(e => { }).catch(e => {
if (e instanceof AuthenticationError) { if (e instanceof AuthenticationError) {
error(new ApiError('AUTHENTICATION_FAILED', e.message)); ctx.response.status = 403;
ctx.response.set('WWW-Authenticate', 'Bearer');
ctx.response.body = {
message: 'Authentication failed: ' + e.message,
code: 'AUTHENTICATION_FAILED',
id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
kind: 'client',
};
res();
} else { } else {
error(new ApiError()); reply(500, new ApiError());
} }
}); });
} });

View file

@ -3,13 +3,10 @@ import { Users, AccessTokens, Apps } from '@/models/index.js';
import { AccessToken } from '@/models/entities/access-token.js'; import { AccessToken } from '@/models/entities/access-token.js';
import { Cache } from '@/misc/cache.js'; import { Cache } from '@/misc/cache.js';
import { App } from '@/models/entities/app.js'; import { App } from '@/models/entities/app.js';
import { userByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js'; import { localUserByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js';
import isNativeToken from './common/is-native-token.js'; import isNativeToken from './common/is-native-token.js';
const appCache = new Cache<App>( const appCache = new Cache<App>(Infinity);
Infinity,
(id) => Apps.findOneByOrFail({ id }),
);
export class AuthenticationError extends Error { export class AuthenticationError extends Error {
constructor(message: string) { constructor(message: string) {
@ -18,8 +15,8 @@ export class AuthenticationError extends Error {
} }
} }
export default async (authorization: string | null | undefined, bodyToken: string | null | undefined): Promise<[CacheableLocalUser | null | undefined, AccessToken | null | undefined]> => { export default async (authorization: string | null | undefined, bodyToken: string | null): Promise<[CacheableLocalUser | null | undefined, AccessToken | null | undefined]> => {
let maybeToken: string | null = null; let token: string | null = null;
// check if there is an authorization header set // check if there is an authorization header set
if (authorization != null) { if (authorization != null) {
@ -30,19 +27,19 @@ export default async (authorization: string | null | undefined, bodyToken: strin
// check if OAuth 2.0 Bearer tokens are being used // check if OAuth 2.0 Bearer tokens are being used
// Authorization schemes are case insensitive // Authorization schemes are case insensitive
if (authorization.substring(0, 7).toLowerCase() === 'bearer ') { if (authorization.substring(0, 7).toLowerCase() === 'bearer ') {
maybeToken = authorization.substring(7); token = authorization.substring(7);
} else { } else {
throw new AuthenticationError('unsupported authentication scheme'); throw new AuthenticationError('unsupported authentication scheme');
} }
} else if (bodyToken != null) { } else if (bodyToken != null) {
maybeToken = bodyToken; token = bodyToken;
} else { } else {
return [null, null]; return [null, null];
} }
const token: string = maybeToken;
if (isNativeToken(token)) { if (isNativeToken(token)) {
const user = await localUserByNativeTokenCache.fetch(token); const user = await localUserByNativeTokenCache.fetch(token,
() => Users.findOneBy({ token }) as Promise<ILocalUser | null>);
if (user == null) { if (user == null) {
throw new AuthenticationError('unknown token'); throw new AuthenticationError('unknown token');
@ -66,13 +63,14 @@ export default async (authorization: string | null | undefined, bodyToken: strin
lastUsedAt: new Date(), lastUsedAt: new Date(),
}); });
const user = await userByIdCache.fetch(accessToken.userId); const user = await localUserByIdCache.fetch(accessToken.userId,
() => Users.findOneBy({
// can't authorize remote users id: accessToken.userId,
if (!Users.isLocalUser(user)) return [null, null]; }) as Promise<ILocalUser>);
if (accessToken.appId) { if (accessToken.appId) {
const app = await appCache.fetch(accessToken.appId); const app = await appCache.fetch(accessToken.appId,
() => Apps.findOneByOrFail({ id: accessToken.appId! }));
return [user, { return [user, {
id: accessToken.id, id: accessToken.id,

View file

@ -7,6 +7,14 @@ import { limiter } from './limiter.js';
import endpoints, { IEndpointMeta } from './endpoints.js'; import endpoints, { IEndpointMeta } from './endpoints.js';
import { ApiError } from './error.js'; import { ApiError } from './error.js';
import { apiLogger } from './logger.js'; import { apiLogger } from './logger.js';
import { AccessToken } from '@/models/entities/access-token.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
const accessDenied = {
message: 'Access denied.',
code: 'ACCESS_DENIED',
id: '56f35758-7dd5-468b-8439-5d6fb8ec9b8e',
};
export default async (endpoint: string, user: CacheableLocalUser | null | undefined, token: AccessToken | null | undefined, data: any, ctx?: Koa.Context) => { export default async (endpoint: string, user: CacheableLocalUser | null | undefined, token: AccessToken | null | undefined, data: any, ctx?: Koa.Context) => {
const isSecure = user != null && token == null; const isSecure = user != null && token == null;
@ -14,10 +22,17 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
const ep = endpoints.find(e => e.name === endpoint); const ep = endpoints.find(e => e.name === endpoint);
if (ep == null) throw new ApiError('NO_SUCH_ENDPOINT'); if (ep == null) {
throw new ApiError({
message: 'No such endpoint.',
code: 'NO_SUCH_ENDPOINT',
id: 'f8080b67-5f9c-4eb7-8c18-7f1eeae8f709',
httpStatusCode: 404,
});
}
if (ep.meta.secure && !isSecure) { if (ep.meta.secure && !isSecure) {
throw new ApiError('ACCESS_DENIED', 'This operation can only be performed with a native token.'); throw new ApiError(accessDenied);
} }
if (ep.meta.limit && !isModerator) { if (ep.meta.limit && !isModerator) {
@ -36,29 +51,59 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
} }
// Rate limit // Rate limit
await limiter(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor).catch(() => { await limiter(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor).catch(e => {
throw new ApiError('RATE_LIMIT_EXCEEDED'); throw new ApiError({
message: 'Rate limit exceeded. Please try again later.',
code: 'RATE_LIMIT_EXCEEDED',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
httpStatusCode: 429,
});
}); });
} }
if (ep.meta.requireCredential && user == null) { if (ep.meta.requireCredential && user == null) {
throw new ApiError('AUTHENTICATION_REQUIRED'); throw new ApiError({
message: 'Credential required.',
code: 'CREDENTIAL_REQUIRED',
id: '1384574d-a912-4b81-8601-c7b1c4085df1',
httpStatusCode: 401,
});
} }
if (ep.meta.requireCredential && user!.isSuspended) { if (ep.meta.requireCredential && user!.isSuspended) {
throw new ApiError('SUSPENDED'); throw new ApiError({
message: 'Your account has been suspended.',
code: 'YOUR_ACCOUNT_SUSPENDED',
id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370',
httpStatusCode: 403,
});
} }
if (ep.meta.requireAdmin && !user!.isAdmin) { if (ep.meta.requireAdmin && !user!.isAdmin) {
throw new ApiError('ACCESS_DENIED', 'This operation requires administrator privileges.'); throw new ApiError(accessDenied, { reason: 'You are not the admin.' });
} }
if (ep.meta.requireModerator && !isModerator) { if (ep.meta.requireModerator && !isModerator) {
throw new ApiError('ACCESS_DENIED', 'This operation requires moderator privileges.'); throw new ApiError(accessDenied, { reason: 'You are not a moderator.' });
} }
if (token && ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) { if (token && ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) {
throw new ApiError('ACCESS_DENIED', 'This operation requires privileges which this token does not grant.'); throw new ApiError({
message: 'Your app does not have the necessary permissions to use this endpoint.',
code: 'PERMISSION_DENIED',
id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838',
});
}
// private mode
const meta = await fetchMeta();
if (meta.privateMode && ep.meta.requireCredentialPrivateMode && user == null) {
throw new ApiError({
message: 'Credential required.',
code: 'CREDENTIAL_REQUIRED',
id: '1384574d-a912-4b81-8601-c7b1c4085df1',
httpStatusCode: 401
});
} }
// Cast non JSON input // Cast non JSON input
@ -69,7 +114,11 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
try { try {
data[k] = JSON.parse(data[k]); data[k] = JSON.parse(data[k]);
} catch (e) { } catch (e) {
throw new ApiError('INVALID_PARAM', { throw new ApiError({
message: 'Invalid param.',
code: 'INVALID_PARAM',
id: '0b5f1631-7c1a-41a6-b399-cce335f34d85',
}, {
param: k, param: k,
reason: `cannot cast to ${param.type}`, reason: `cannot cast to ${param.type}`,
}); });
@ -93,7 +142,7 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
stack: e.stack, stack: e.stack,
}, },
}); });
throw new ApiError('INTERNAL_ERROR', { throw new ApiError(null, {
e: { e: {
message: e.message, message: e.message,
code: e.name, code: e.name,

View file

@ -24,13 +24,25 @@ export async function signup(opts: {
// Validate username // Validate username
if (!Users.validateLocalUsername(username)) { if (!Users.validateLocalUsername(username)) {
throw new ApiError('INVALID_USERNAME'); throw new ApiError({
message: 'This username is invalid.',
code: 'INVALID_USERNAME',
id: 'ece89f3c-d845-4d9a-850b-1735285e8cd4',
kind: 'client',
httpStatusCode: 400,
});
} }
if (password != null && passwordHash == null) { if (password != null && passwordHash == null) {
// Validate password // Validate password
if (!Users.validatePassword(password)) { if (!Users.validatePassword(password)) {
throw new ApiError('INVALID_PASSWORD'); throw new ApiError({
message: 'This password is invalid.',
code: 'INVALID_PASSWORD',
id: 'a941905b-fe7b-43e2-8ecd-50ad3a2287ab',
kind: 'client',
httpStatusCode: 400,
});
} }
// Generate hash of password // Generate hash of password
@ -41,14 +53,22 @@ export async function signup(opts: {
// Generate secret // Generate secret
const secret = generateUserToken(); const secret = generateUserToken();
const duplicateUsernameError = {
message: 'This username is not available.',
code: 'USED_USERNAME',
id: '7ddd595e-6860-4593-93c5-9fdbcb80cd81',
kind: 'client',
httpStatusCode: 409,
};
// Check username duplication // Check username duplication
if (await Users.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) { if (await Users.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) {
throw new ApiError('USED_USERNAME'); throw new ApiError(duplicateUsernameError);
} }
// Check deleted username duplication // Check deleted username duplication
if (await UsedUsernames.findOneBy({ username: username.toLowerCase() })) { if (await UsedUsernames.findOneBy({ username: username.toLowerCase() })) {
throw new ApiError('USED_USERNAME'); throw new ApiError(duplicateUsernameError);
} }
const keyPair = await new Promise<string[]>((res, rej) => const keyPair = await new Promise<string[]>((res, rej) =>
@ -77,7 +97,7 @@ export async function signup(opts: {
host: IsNull(), host: IsNull(),
}); });
if (exist) throw new ApiError('USED_USERNAME'); if (exist) throw new ApiError(duplicateUsernameError);
account = await transactionalEntityManager.save(new User({ account = await transactionalEntityManager.save(new User({
id: genId(), id: genId(),

View file

@ -28,16 +28,22 @@ export default function <T extends IEndpointMeta, Ps extends Schema>(meta: T, pa
fs.unlink(file.path, () => {}); fs.unlink(file.path, () => {});
} }
if (meta.requireFile && file == null) { if (meta.requireFile && file == null) return Promise.reject(new ApiError({
return Promise.reject(new ApiError('FILE_REQUIRED')); message: 'File required.',
} code: 'FILE_REQUIRED',
id: '4267801e-70d1-416a-b011-4ee502885d8b',
}));
const valid = validate(params); const valid = validate(params);
if (!valid) { if (!valid) {
if (file) cleanup(); if (file) cleanup();
const errors = validate.errors!; const errors = validate.errors!;
const err = new ApiError('INVALID_PARAM', { const err = new ApiError({
message: 'Invalid param.',
code: 'INVALID_PARAM',
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
}, {
param: errors[0].schemaPath, param: errors[0].schemaPath,
reason: errors[0].message, reason: errors[0].message,
}); });

View file

@ -1,5 +1,4 @@
import { Schema } from '@/misc/schema.js'; import { Schema } from '@/misc/schema.js';
import { errors } from './error.js';
import * as ep___admin_meta from './endpoints/admin/meta.js'; import * as ep___admin_meta from './endpoints/admin/meta.js';
import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.js'; import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.js';
@ -271,12 +270,14 @@ import * as ep___serverInfo from './endpoints/server-info.js';
import * as ep___stats from './endpoints/stats.js'; import * as ep___stats from './endpoints/stats.js';
import * as ep___sw_register from './endpoints/sw/register.js'; import * as ep___sw_register from './endpoints/sw/register.js';
import * as ep___sw_unregister from './endpoints/sw/unregister.js'; import * as ep___sw_unregister from './endpoints/sw/unregister.js';
import * as ep___test from './endpoints/test.js';
import * as ep___username_available from './endpoints/username/available.js'; import * as ep___username_available from './endpoints/username/available.js';
import * as ep___users from './endpoints/users.js'; import * as ep___users from './endpoints/users.js';
import * as ep___users_clips from './endpoints/users/clips.js'; import * as ep___users_clips from './endpoints/users/clips.js';
import * as ep___users_followers from './endpoints/users/followers.js'; import * as ep___users_followers from './endpoints/users/followers.js';
import * as ep___users_following from './endpoints/users/following.js'; import * as ep___users_following from './endpoints/users/following.js';
import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js'; import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js';
import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js';
import * as ep___users_groups_create from './endpoints/users/groups/create.js'; import * as ep___users_groups_create from './endpoints/users/groups/create.js';
import * as ep___users_groups_delete from './endpoints/users/groups/delete.js'; import * as ep___users_groups_delete from './endpoints/users/groups/delete.js';
import * as ep___users_groups_invitations_accept from './endpoints/users/groups/invitations/accept.js'; import * as ep___users_groups_invitations_accept from './endpoints/users/groups/invitations/accept.js';
@ -579,12 +580,14 @@ const eps = [
['stats', ep___stats], ['stats', ep___stats],
['sw/register', ep___sw_register], ['sw/register', ep___sw_register],
['sw/unregister', ep___sw_unregister], ['sw/unregister', ep___sw_unregister],
['test', ep___test],
['username/available', ep___username_available], ['username/available', ep___username_available],
['users', ep___users], ['users', ep___users],
['users/clips', ep___users_clips], ['users/clips', ep___users_clips],
['users/followers', ep___users_followers], ['users/followers', ep___users_followers],
['users/following', ep___users_following], ['users/following', ep___users_following],
['users/gallery/posts', ep___users_gallery_posts], ['users/gallery/posts', ep___users_gallery_posts],
['users/get-frequently-replied-users', ep___users_getFrequentlyRepliedUsers],
['users/groups/create', ep___users_groups_create], ['users/groups/create', ep___users_groups_create],
['users/groups/delete', ep___users_groups_delete], ['users/groups/delete', ep___users_groups_delete],
['users/groups/invitations/accept', ep___users_groups_invitations_accept], ['users/groups/invitations/accept', ep___users_groups_invitations_accept],
@ -622,7 +625,13 @@ export interface IEndpointMeta {
readonly tags?: ReadonlyArray<string>; readonly tags?: ReadonlyArray<string>;
readonly errors?: ReadonlyArray<keyof typeof errors>; readonly errors?: {
readonly [key: string]: {
readonly message: string;
readonly code: string;
readonly id: string;
};
};
readonly res?: Schema; readonly res?: Schema;
@ -683,6 +692,12 @@ export interface IEndpointMeta {
*/ */
readonly secure?: boolean; readonly secure?: boolean;
/**
* If in private mode, whether credentials are required when making a request to this endpoint.
* If omitted, this is interpreted as false.
*/
readonly requireCredentialPrivateMode?: boolean;
/** /**
* *
* *

View file

@ -8,7 +8,13 @@ export const meta = {
requireCredential: true, requireCredential: true,
requireModerator: true, requireModerator: true,
errors: ['NO_SUCH_ANNOUNCEMENT'], errors: {
noSuchAnnouncement: {
message: 'No such announcement.',
code: 'NO_SUCH_ANNOUNCEMENT',
id: 'ecad8040-a276-4e85-bda9-015a708d291e',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -23,7 +29,7 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, me) => { export default define(meta, paramDef, async (ps, me) => {
const announcement = await Announcements.findOneBy({ id: ps.id }); const announcement = await Announcements.findOneBy({ id: ps.id });
if (announcement == null) throw new ApiError('NO_SUCH_ANNOUNCEMENT'); if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement);
await Announcements.delete(announcement.id); await Announcements.delete(announcement.id);
}); });

View file

@ -8,7 +8,13 @@ export const meta = {
requireCredential: true, requireCredential: true,
requireModerator: true, requireModerator: true,
errors: ['NO_SUCH_ANNOUNCEMENT'], errors: {
noSuchAnnouncement: {
message: 'No such announcement.',
code: 'NO_SUCH_ANNOUNCEMENT',
id: 'd3aae5a7-6372-4cb4-b61c-f511ffc2d7cc',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -26,7 +32,7 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, me) => { export default define(meta, paramDef, async (ps, me) => {
const announcement = await Announcements.findOneBy({ id: ps.id }); const announcement = await Announcements.findOneBy({ id: ps.id });
if (announcement == null) throw new ApiError('NO_SUCH_ANNOUNCEMENT'); if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement);
await Announcements.update(announcement.id, { await Announcements.update(announcement.id, {
updatedAt: new Date(), updatedAt: new Date(),

View file

@ -8,7 +8,13 @@ export const meta = {
requireCredential: true, requireCredential: true,
requireModerator: true, requireModerator: true,
errors: ['NO_SUCH_FILE'], errors: {
noSuchFile: {
message: 'No such file.',
code: 'NO_SUCH_FILE',
id: 'caf3ca38-c6e5-472e-a30c-b05377dcc240',
},
},
res: { res: {
type: 'object', type: 'object',
@ -174,7 +180,9 @@ export default define(meta, paramDef, async (ps, me) => {
}], }],
}); });
if (file == null) throw new ApiError('NO_SUCH_FILE'); if (file == null) {
throw new ApiError(meta.errors.noSuchFile);
}
return file; return file;
}); });

View file

@ -13,7 +13,13 @@ export const meta = {
requireCredential: true, requireCredential: true,
requireModerator: true, requireModerator: true,
errors: ['NO_SUCH_FILE'], errors: {
noSuchFile: {
message: 'No such file.',
code: 'MO_SUCH_FILE',
id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -28,7 +34,7 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, me) => { export default define(meta, paramDef, async (ps, me) => {
const file = await DriveFiles.findOneBy({ id: ps.fileId }); const file = await DriveFiles.findOneBy({ id: ps.fileId });
if (file == null) throw new ApiError('NO_SUCH_FILE'); if (file == null) throw new ApiError(meta.errors.noSuchFile);
const name = file.name.split('.')[0].match(/^[a-z0-9_]+$/) ? file.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`; const name = file.name.split('.')[0].match(/^[a-z0-9_]+$/) ? file.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`;

View file

@ -13,7 +13,13 @@ export const meta = {
requireCredential: true, requireCredential: true,
requireModerator: true, requireModerator: true,
errors: ['NO_SUCH_EMOJI', 'INTERNAL_ERROR'], errors: {
noSuchEmoji: {
message: 'No such emoji.',
code: 'NO_SUCH_EMOJI',
id: 'e2785b66-dca3-4087-9cac-b93c541cc425',
},
},
res: { res: {
type: 'object', type: 'object',
@ -40,7 +46,9 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, me) => { export default define(meta, paramDef, async (ps, me) => {
const emoji = await Emojis.findOneBy({ id: ps.emojiId }); const emoji = await Emojis.findOneBy({ id: ps.emojiId });
if (emoji == null) throw new ApiError('NO_SUCH_EMOJI'); if (emoji == null) {
throw new ApiError(meta.errors.noSuchEmoji);
}
let driveFile: DriveFile; let driveFile: DriveFile;
@ -48,7 +56,7 @@ export default define(meta, paramDef, async (ps, me) => {
// Create file // Create file
driveFile = await uploadFromUrl({ url: emoji.originalUrl, user: null, force: true }); driveFile = await uploadFromUrl({ url: emoji.originalUrl, user: null, force: true });
} catch (e) { } catch (e) {
throw new ApiError('INTERNAL_ERROR', e); throw new ApiError();
} }
const copied = await Emojis.insert({ const copied = await Emojis.insert({

View file

@ -10,7 +10,13 @@ export const meta = {
requireCredential: true, requireCredential: true,
requireModerator: true, requireModerator: true,
errors: ['NO_SUCH_EMOJI'], errors: {
noSuchEmoji: {
message: 'No such emoji.',
code: 'NO_SUCH_EMOJI',
id: 'be83669b-773a-44b7-b1f8-e5e5170ac3c2',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -25,7 +31,7 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, me) => { export default define(meta, paramDef, async (ps, me) => {
const emoji = await Emojis.findOneBy({ id: ps.id }); const emoji = await Emojis.findOneBy({ id: ps.id });
if (emoji == null) throw new ApiError('NO_SUCH_EMOJI'); if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji);
await Emojis.delete(emoji.id); await Emojis.delete(emoji.id);

View file

@ -9,7 +9,13 @@ export const meta = {
requireCredential: true, requireCredential: true,
requireModerator: true, requireModerator: true,
errors: ['NO_SUCH_EMOJI'], errors: {
noSuchEmoji: {
message: 'No such emoji.',
code: 'NO_SUCH_EMOJI',
id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -33,7 +39,7 @@ export const paramDef = {
export default define(meta, paramDef, async (ps) => { export default define(meta, paramDef, async (ps) => {
const emoji = await Emojis.findOneBy({ id: ps.id }); const emoji = await Emojis.findOneBy({ id: ps.id });
if (emoji == null) throw new ApiError('NO_SUCH_EMOJI'); if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji);
await Emojis.update(emoji.id, { await Emojis.update(emoji.id, {
updatedAt: new Date(), updatedAt: new Date(),

View file

@ -1,5 +1,6 @@
import config from '@/config/index.js'; import config from '@/config/index.js';
import { fetchMeta } from '@/misc/fetch-meta.js'; import { fetchMeta } from '@/misc/fetch-meta.js';
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import define from '../../define.js'; import define from '../../define.js';
export const meta = { export const meta = {
@ -152,6 +153,22 @@ export const meta = {
optional: false, nullable: false, optional: false, nullable: false,
}, },
}, },
allowedHosts: {
type: 'array',
optional: true, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
privateMode: {
type: 'boolean',
optional: false, nullable: false,
},
secureMode: {
type: 'boolean',
optional: false, nullable: false,
},
hcaptchaSecretKey: { hcaptchaSecretKey: {
type: 'string', type: 'string',
optional: true, nullable: true, optional: true, nullable: true,
@ -309,7 +326,7 @@ export default define(meta, paramDef, async (ps, me) => {
iconUrl: instance.iconUrl, iconUrl: instance.iconUrl,
backgroundImageUrl: instance.backgroundImageUrl, backgroundImageUrl: instance.backgroundImageUrl,
logoImageUrl: instance.logoImageUrl, logoImageUrl: instance.logoImageUrl,
maxNoteTextLength: config.maxNoteTextLength, maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため
defaultLightTheme: instance.defaultLightTheme, defaultLightTheme: instance.defaultLightTheme,
defaultDarkTheme: instance.defaultDarkTheme, defaultDarkTheme: instance.defaultDarkTheme,
enableEmail: instance.enableEmail, enableEmail: instance.enableEmail,
@ -326,6 +343,9 @@ export default define(meta, paramDef, async (ps, me) => {
pinnedUsers: instance.pinnedUsers, pinnedUsers: instance.pinnedUsers,
hiddenTags: instance.hiddenTags, hiddenTags: instance.hiddenTags,
blockedHosts: instance.blockedHosts, blockedHosts: instance.blockedHosts,
allowedHosts: instance.allowedHosts,
privateMode: instance.privateMode,
secureMode: instance.secureMode,
hcaptchaSecretKey: instance.hcaptchaSecretKey, hcaptchaSecretKey: instance.hcaptchaSecretKey,
recaptchaSecretKey: instance.recaptchaSecretKey, recaptchaSecretKey: instance.recaptchaSecretKey,
proxyAccountId: instance.proxyAccountId, proxyAccountId: instance.proxyAccountId,

View file

@ -9,7 +9,13 @@ export const meta = {
requireCredential: true, requireCredential: true,
requireModerator: true, requireModerator: true,
errors: ['INVALID_URL'], errors: {
invalidUrl: {
message: 'Invalid URL',
code: 'INVALID_URL',
id: 'fb8c92d3-d4e5-44e7-b3d4-800d5cef8b2c',
},
},
res: { res: {
type: 'object', type: 'object',
@ -52,8 +58,8 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
try { try {
if (new URL(ps.inbox).protocol !== 'https:') throw new Error('https only'); if (new URL(ps.inbox).protocol !== 'https:') throw new Error('https only');
} catch (e) { } catch {
throw new ApiError('INVALID_URL', e); throw new ApiError(meta.errors.invalidUrl);
} }
return await addRelay(ps.inbox); return await addRelay(ps.inbox);

View file

@ -1,5 +1,6 @@
import { Meta } from '@/models/entities/meta.js';
import { insertModerationLog } from '@/services/insert-moderation-log.js'; import { insertModerationLog } from '@/services/insert-moderation-log.js';
import { fetchMeta, setMeta } from '@/misc/fetch-meta.js'; import { db } from '@/db/postgre.js';
import define from '../../define.js'; import define from '../../define.js';
export const meta = { export const meta = {
@ -25,6 +26,11 @@ export const paramDef = {
blockedHosts: { type: 'array', nullable: true, items: { blockedHosts: { type: 'array', nullable: true, items: {
type: 'string', type: 'string',
} }, } },
allowedHosts: { type: 'array', nullable: true, items: {
type: 'string',
} },
secureMode: { type: 'boolean', nullable: true },
privateMode: { type: 'boolean', nullable: true },
themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' }, themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' },
bannerUrl: { type: 'string', nullable: true }, bannerUrl: { type: 'string', nullable: true },
iconUrl: { type: 'string', nullable: true }, iconUrl: { type: 'string', nullable: true },
@ -130,6 +136,18 @@ export default define(meta, paramDef, async (ps, me) => {
set.themeColor = ps.themeColor; set.themeColor = ps.themeColor;
} }
if (Array.isArray(ps.allowedHosts)) {
set.allowedHosts = ps.allowedHosts.filter(Boolean);
}
if (typeof ps.privateMode === 'boolean') {
set.privateMode = ps.privateMode;
}
if (typeof ps.secureMode === 'boolean') {
set.secureMode = ps.secureMode;
}
if (ps.bannerUrl !== undefined) { if (ps.bannerUrl !== undefined) {
set.bannerUrl = ps.bannerUrl; set.bannerUrl = ps.bannerUrl;
} }
@ -374,10 +392,20 @@ export default define(meta, paramDef, async (ps, me) => {
set.deeplIsPro = ps.deeplIsPro; set.deeplIsPro = ps.deeplIsPro;
} }
const meta = await fetchMeta(); await db.transaction(async transactionalEntityManager => {
await setMeta({ const metas = await transactionalEntityManager.find(Meta, {
...meta, order: {
...set, id: 'DESC',
},
});
const meta = metas[0];
if (meta) {
await transactionalEntityManager.update(Meta, meta.id, set);
} else {
await transactionalEntityManager.save(Meta, set);
}
}); });
insertModerationLog(me, 'updateMeta'); insertModerationLog(me, 'updateMeta');

View file

@ -6,6 +6,7 @@ export const meta = {
tags: ['meta'], tags: ['meta'],
requireCredential: false, requireCredential: false,
requireCredentialPrivateMode: true,
res: { res: {
type: 'array', type: 'array',

View file

@ -11,7 +11,19 @@ export const meta = {
kind: 'write:account', kind: 'write:account',
errors: ['NO_SUCH_USER_LIST', 'NO_SUCH_GROUP'], errors: {
noSuchUserList: {
message: 'No such user list.',
code: 'NO_SUCH_USER_LIST',
id: '95063e93-a283-4b8b-9aa5-bcdb8df69a7f',
},
noSuchUserGroup: {
message: 'No such user group.',
code: 'NO_SUCH_USER_GROUP',
id: 'aa3c0b9a-8cae-47c0-92ac-202ce5906682',
},
},
res: { res: {
type: 'object', type: 'object',
@ -59,14 +71,18 @@ export default define(meta, paramDef, async (ps, user) => {
userId: user.id, userId: user.id,
}); });
if (userList == null) throw new ApiError('NO_SUCH_USER_LIST'); if (userList == null) {
throw new ApiError(meta.errors.noSuchUserList);
}
} else if (ps.src === 'group' && ps.userGroupId) { } else if (ps.src === 'group' && ps.userGroupId) {
userGroupJoining = await UserGroupJoinings.findOneBy({ userGroupJoining = await UserGroupJoinings.findOneBy({
userGroupId: ps.userGroupId, userGroupId: ps.userGroupId,
userId: user.id, userId: user.id,
}); });
if (userGroupJoining == null) throw new ApiError('NO_SUCH_GROUP'); if (userGroupJoining == null) {
throw new ApiError(meta.errors.noSuchUserGroup);
}
} }
const antenna = await Antennas.insert({ const antenna = await Antennas.insert({

View file

@ -10,7 +10,13 @@ export const meta = {
kind: 'write:account', kind: 'write:account',
errors: ['NO_SUCH_ANTENNA'], errors: {
noSuchAntenna: {
message: 'No such antenna.',
code: 'NO_SUCH_ANTENNA',
id: 'b34dcf9d-348f-44bb-99d0-6c9314cfe2df',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -28,7 +34,9 @@ export default define(meta, paramDef, async (ps, user) => {
userId: user.id, userId: user.id,
}); });
if (antenna == null) throw new ApiError('NO_SUCH_ANTENNA'); if (antenna == null) {
throw new ApiError(meta.errors.noSuchAntenna);
}
await Antennas.delete(antenna.id); await Antennas.delete(antenna.id);

View file

@ -1,4 +1,4 @@
import { readNote } from '@/services/note/read.js'; import readNote from '@/services/note/read.js';
import { Antennas, Notes, AntennaNotes } from '@/models/index.js'; import { Antennas, Notes, AntennaNotes } from '@/models/index.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js'; import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
@ -14,7 +14,13 @@ export const meta = {
kind: 'read:account', kind: 'read:account',
errors: ['NO_SUCH_ANTENNA'], errors: {
noSuchAntenna: {
message: 'No such antenna.',
code: 'NO_SUCH_ANTENNA',
id: '850926e0-fd3b-49b6-b69a-b28a5dbd82fe',
},
},
res: { res: {
type: 'array', type: 'array',
@ -47,7 +53,9 @@ export default define(meta, paramDef, async (ps, user) => {
userId: user.id, userId: user.id,
}); });
if (antenna == null) throw new ApiError('NO_SUCH_ANTENNA'); if (antenna == null) {
throw new ApiError(meta.errors.noSuchAntenna);
}
const query = makePaginationQuery(Notes.createQueryBuilder('note'), const query = makePaginationQuery(Notes.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)

View file

@ -9,7 +9,13 @@ export const meta = {
kind: 'read:account', kind: 'read:account',
errors: ['NO_SUCH_ANTENNA'], errors: {
noSuchAntenna: {
message: 'No such antenna.',
code: 'NO_SUCH_ANTENNA',
id: 'c06569fb-b025-4f23-b22d-1fcd20d2816b',
},
},
res: { res: {
type: 'object', type: 'object',
@ -34,7 +40,9 @@ export default define(meta, paramDef, async (ps, me) => {
userId: me.id, userId: me.id,
}); });
if (antenna == null) throw new ApiError('NO_SUCH_ANTENNA'); if (antenna == null) {
throw new ApiError(meta.errors.noSuchAntenna);
}
return await Antennas.pack(antenna); return await Antennas.pack(antenna);
}); });

View file

@ -10,7 +10,25 @@ export const meta = {
kind: 'write:account', kind: 'write:account',
errors: ['NO_SUCH_ANTENNA', 'NO_SUCH_USER_LIST', 'NO_SUCH_GROUP'], errors: {
noSuchAntenna: {
message: 'No such antenna.',
code: 'NO_SUCH_ANTENNA',
id: '10c673ac-8852-48eb-aa1f-f5b67f069290',
},
noSuchUserList: {
message: 'No such user list.',
code: 'NO_SUCH_USER_LIST',
id: '1c6b35c9-943e-48c2-81e4-2844989407f7',
},
noSuchUserGroup: {
message: 'No such user group.',
code: 'NO_SUCH_USER_GROUP',
id: '109ed789-b6eb-456e-b8a9-6059d567d385',
},
},
res: { res: {
type: 'object', type: 'object',
@ -56,7 +74,9 @@ export default define(meta, paramDef, async (ps, user) => {
userId: user.id, userId: user.id,
}); });
if (antenna == null) throw new ApiError('NO_SUCH_ANTENNA'); if (antenna == null) {
throw new ApiError(meta.errors.noSuchAntenna);
}
let userList; let userList;
let userGroupJoining; let userGroupJoining;
@ -67,14 +87,18 @@ export default define(meta, paramDef, async (ps, user) => {
userId: user.id, userId: user.id,
}); });
if (userList == null) throw new ApiError('NO_SUCH_USER_LIST'); if (userList == null) {
throw new ApiError(meta.errors.noSuchUserList);
}
} else if (ps.src === 'group' && ps.userGroupId) { } else if (ps.src === 'group' && ps.userGroupId) {
userGroupJoining = await UserGroupJoinings.findOneBy({ userGroupJoining = await UserGroupJoinings.findOneBy({
userGroupId: ps.userGroupId, userGroupId: ps.userGroupId,
userId: user.id, userId: user.id,
}); });
if (userGroupJoining == null) throw new ApiError('NO_SUCH_GROUP'); if (userGroupJoining == null) {
throw new ApiError(meta.errors.noSuchUserGroup);
}
} }
await Antennas.update(antenna.id, { await Antennas.update(antenna.id, {

View file

@ -12,6 +12,9 @@ export const meta = {
max: 30, max: 30,
}, },
errors: {
},
res: { res: {
type: 'object', type: 'object',
optional: false, nullable: false, optional: false, nullable: false,

View file

@ -24,7 +24,13 @@ export const meta = {
max: 30, max: 30,
}, },
errors: ['NO_SUCH_OBJECT'], errors: {
noSuchObject: {
message: 'No such object.',
code: 'NO_SUCH_OBJECT',
id: 'dc94d745-1262-4e63-a17d-fecaa57efc82',
},
},
res: { res: {
optional: false, nullable: false, optional: false, nullable: false,
@ -77,7 +83,7 @@ export default define(meta, paramDef, async (ps, me) => {
if (object) { if (object) {
return object; return object;
} else { } else {
throw new ApiError('NO_SUCH_OBJECT'); throw new ApiError(meta.errors.noSuchObject);
} }
}); });

View file

@ -2,7 +2,6 @@ import { Apps } from '@/models/index.js';
import { genId } from '@/misc/gen-id.js'; import { genId } from '@/misc/gen-id.js';
import { unique } from '@/prelude/array.js'; import { unique } from '@/prelude/array.js';
import { secureRndstr } from '@/misc/secure-rndstr.js'; import { secureRndstr } from '@/misc/secure-rndstr.js';
import { kinds } from '@/misc/api-permissions.js';
import define from '../../define.js'; import define from '../../define.js';
export const meta = { export const meta = {
@ -22,14 +21,10 @@ export const paramDef = {
properties: { properties: {
name: { type: 'string' }, name: { type: 'string' },
description: { type: 'string' }, description: { type: 'string' },
permission: { permission: { type: 'array', uniqueItems: true, items: {
type: 'array',
uniqueItems: true,
items: {
type: 'string', type: 'string',
enum: kinds, // FIXME: add enum of possible permissions
}, } },
},
callbackUrl: { type: 'string', nullable: true }, callbackUrl: { type: 'string', nullable: true },
}, },
required: ['name', 'description', 'permission'], required: ['name', 'description', 'permission'],

View file

@ -5,7 +5,13 @@ import { ApiError } from '../../error.js';
export const meta = { export const meta = {
tags: ['app'], tags: ['app'],
errors: ['NO_SUCH_APP'], errors: {
noSuchApp: {
message: 'No such app.',
code: 'NO_SUCH_APP',
id: 'dce83913-2dc6-4093-8a7b-71dbb11718a3',
},
},
res: { res: {
type: 'object', type: 'object',
@ -27,12 +33,14 @@ export default define(meta, paramDef, async (ps, user, token) => {
const isSecure = user != null && token == null; const isSecure = user != null && token == null;
// Lookup app // Lookup app
const app = await Apps.findOneBy({ id: ps.appId }); const ap = await Apps.findOneBy({ id: ps.appId });
if (app == null) throw new ApiError('NO_SUCH_APP'); if (ap == null) {
throw new ApiError(meta.errors.noSuchApp);
}
return await Apps.pack(app, user, { return await Apps.pack(ap, user, {
detail: true, detail: true,
includeSecret: isSecure && (app.userId === user!.id), includeSecret: isSecure && (ap.userId === user!.id),
}); });
}); });

View file

@ -12,7 +12,13 @@ export const meta = {
secure: true, secure: true,
errors: ['NO_SUCH_SESSION'], errors: {
noSuchSession: {
message: 'No such session.',
code: 'NO_SUCH_SESSION',
id: '9c72d8de-391a-43c1-9d06-08d29efde8df',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -29,7 +35,9 @@ export default define(meta, paramDef, async (ps, user) => {
const session = await AuthSessions const session = await AuthSessions
.findOneBy({ token: ps.token }); .findOneBy({ token: ps.token });
if (session == null) throw new ApiError('NO_SUCH_SESSION'); if (session == null) {
throw new ApiError(meta.errors.noSuchSession);
}
// Generate access token // Generate access token
const accessToken = secureRndstr(32, true); const accessToken = secureRndstr(32, true);

View file

@ -26,7 +26,13 @@ export const meta = {
}, },
}, },
errors: ['NO_SUCH_APP'], errors: {
noSuchApp: {
message: 'No such app.',
code: 'NO_SUCH_APP',
id: '92f93e63-428e-4f2f-a5a4-39e1407fe998',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -45,7 +51,7 @@ export default define(meta, paramDef, async (ps) => {
}); });
if (app == null) { if (app == null) {
throw new ApiError('NO_SUCH_APP'); throw new ApiError(meta.errors.noSuchApp);
} }
// Generate token // Generate token

View file

@ -7,7 +7,13 @@ export const meta = {
requireCredential: false, requireCredential: false,
errors: ['NO_SUCH_SESSION'], errors: {
noSuchSession: {
message: 'No such session.',
code: 'NO_SUCH_SESSION',
id: 'bd72c97d-eba7-4adb-a467-f171b8847250',
},
},
res: { res: {
type: 'object', type: 'object',
@ -46,7 +52,9 @@ export default define(meta, paramDef, async (ps, user) => {
token: ps.token, token: ps.token,
}); });
if (session == null) throw new ApiError('NO_SUCH_SESSION'); if (session == null) {
throw new ApiError(meta.errors.noSuchSession);
}
return await AuthSessions.pack(session, user); return await AuthSessions.pack(session, user);
}); });

View file

@ -24,7 +24,25 @@ export const meta = {
}, },
}, },
errors: ['NO_SUCH_APP', 'NO_SUCH_SESSION', 'PENDING_SESSION'], errors: {
noSuchApp: {
message: 'No such app.',
code: 'NO_SUCH_APP',
id: 'fcab192a-2c5a-43b7-8ad8-9b7054d8d40d',
},
noSuchSession: {
message: 'No such session.',
code: 'NO_SUCH_SESSION',
id: '5b5a1503-8bc8-4bd0-8054-dc189e8cdcb3',
},
pendingSession: {
message: 'This session is not completed yet.',
code: 'PENDING_SESSION',
id: '8c8a4145-02cc-4cca-8e66-29ba60445a8e',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -43,7 +61,9 @@ export default define(meta, paramDef, async (ps) => {
secret: ps.appSecret, secret: ps.appSecret,
}); });
if (app == null) throw new ApiError('NO_SUCH_APP'); if (app == null) {
throw new ApiError(meta.errors.noSuchApp);
}
// Fetch token // Fetch token
const session = await AuthSessions.findOneBy({ const session = await AuthSessions.findOneBy({
@ -51,9 +71,13 @@ export default define(meta, paramDef, async (ps) => {
appId: app.id, appId: app.id,
}); });
if (session == null) throw new ApiError('NO_SUCH_SESSION'); if (session == null) {
throw new ApiError(meta.errors.noSuchSession);
}
if (session.userId == null) throw new ApiError('PENDING_SESSION'); if (session.userId == null) {
throw new ApiError(meta.errors.pendingSession);
}
// Lookup access token // Lookup access token
const accessToken = await AccessTokens.findOneByOrFail({ const accessToken = await AccessTokens.findOneByOrFail({

View file

@ -17,7 +17,25 @@ export const meta = {
kind: 'write:blocks', kind: 'write:blocks',
errors: ['NO_SUCH_USER', 'BLOCKEE_IS_YOURSELF', 'ALREADY_BLOCKING'], errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '7cc4f851-e2f1-4621-9633-ec9e1d00c01e',
},
blockeeIsYourself: {
message: 'Blockee is yourself.',
code: 'BLOCKEE_IS_YOURSELF',
id: '88b19138-f28d-42c0-8499-6a31bbd0fdc6',
},
alreadyBlocking: {
message: 'You are already blocking that user.',
code: 'ALREADY_BLOCKING',
id: '787fed64-acb9-464a-82eb-afbd745b9614',
},
},
res: { res: {
type: 'object', type: 'object',
@ -39,11 +57,13 @@ export default define(meta, paramDef, async (ps, user) => {
const blocker = await Users.findOneByOrFail({ id: user.id }); const blocker = await Users.findOneByOrFail({ id: user.id });
// 自分自身 // 自分自身
if (user.id === ps.userId) throw new ApiError('BLOCKEE_IS_YOURSELF'); if (user.id === ps.userId) {
throw new ApiError(meta.errors.blockeeIsYourself);
}
// Get blockee // Get blockee
const blockee = await getUser(ps.userId).catch(e => { const blockee = await getUser(ps.userId).catch(e => {
if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw e; throw e;
}); });
@ -53,7 +73,9 @@ export default define(meta, paramDef, async (ps, user) => {
blockeeId: blockee.id, blockeeId: blockee.id,
}); });
if (exist != null) throw new ApiError('ALREADY_BLOCKING'); if (exist != null) {
throw new ApiError(meta.errors.alreadyBlocking);
}
await create(blocker, blockee); await create(blocker, blockee);

View file

@ -17,7 +17,25 @@ export const meta = {
kind: 'write:blocks', kind: 'write:blocks',
errors: ['NO_SUCH_USER', 'BLOCKEE_IS_YOURSELF', 'NOT_BLOCKING'], errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '8621d8bf-c358-4303-a066-5ea78610eb3f',
},
blockeeIsYourself: {
message: 'Blockee is yourself.',
code: 'BLOCKEE_IS_YOURSELF',
id: '06f6fac6-524b-473c-a354-e97a40ae6eac',
},
notBlocking: {
message: 'You are not blocking that user.',
code: 'NOT_BLOCKING',
id: '291b2efa-60c6-45c0-9f6a-045c8f9b02cd',
},
},
res: { res: {
type: 'object', type: 'object',
@ -36,14 +54,16 @@ export const paramDef = {
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
// Check if the blockee is yourself
if (user.id === ps.userId) throw new ApiError('BLOCKEE_IS_YOURSELF');
const blocker = await Users.findOneByOrFail({ id: user.id }); const blocker = await Users.findOneByOrFail({ id: user.id });
// Check if the blockee is yourself
if (user.id === ps.userId) {
throw new ApiError(meta.errors.blockeeIsYourself);
}
// Get blockee // Get blockee
const blockee = await getUser(ps.userId).catch(e => { const blockee = await getUser(ps.userId).catch(e => {
if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw e; throw e;
}); });
@ -53,7 +73,9 @@ export default define(meta, paramDef, async (ps, user) => {
blockeeId: blockee.id, blockeeId: blockee.id,
}); });
if (exist == null) throw new ApiError('NOT_BLOCKING'); if (exist == null) {
throw new ApiError(meta.errors.notBlocking);
}
// Delete blocking // Delete blocking
await deleteBlocking(blocker, blockee); await deleteBlocking(blocker, blockee);

View file

@ -17,7 +17,13 @@ export const meta = {
ref: 'Channel', ref: 'Channel',
}, },
errors: ['NO_SUCH_FILE'], errors: {
noSuchFile: {
message: 'No such file.',
code: 'NO_SUCH_FILE',
id: 'cd1e9f3e-5a12-4ab4-96f6-5d0a2cc32050',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -39,7 +45,9 @@ export default define(meta, paramDef, async (ps, user) => {
userId: user.id, userId: user.id,
}); });
if (banner == null) throw new ApiError('NO_SUCH_FILE'); if (banner == null) {
throw new ApiError(meta.errors.noSuchFile);
}
} }
const channel = await Channels.insert({ const channel = await Channels.insert({

View file

@ -5,6 +5,7 @@ export const meta = {
tags: ['channels'], tags: ['channels'],
requireCredential: false, requireCredential: false,
requireCredentialPrivateMode: true,
res: { res: {
type: 'array', type: 'array',

View file

@ -11,7 +11,13 @@ export const meta = {
kind: 'write:channels', kind: 'write:channels',
errors: ['NO_SUCH_CHANNEL'], errors: {
noSuchChannel: {
message: 'No such channel.',
code: 'NO_SUCH_CHANNEL',
id: 'c0031718-d573-4e85-928e-10039f1fbb68',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -28,7 +34,9 @@ export default define(meta, paramDef, async (ps, user) => {
id: ps.channelId, id: ps.channelId,
}); });
if (channel == null) throw new ApiError('NO_SUCH_CHANNEL'); if (channel == null) {
throw new ApiError(meta.errors.noSuchChannel);
}
await ChannelFollowings.insert({ await ChannelFollowings.insert({
id: genId(), id: genId(),

View file

@ -6,6 +6,7 @@ export const meta = {
tags: ['channels'], tags: ['channels'],
requireCredential: false, requireCredential: false,
requireCredentialPrivateMode: true,
res: { res: {
type: 'object', type: 'object',
@ -13,7 +14,13 @@ export const meta = {
ref: 'Channel', ref: 'Channel',
}, },
errors: ['NO_SUCH_CHANNEL'], errors: {
noSuchChannel: {
message: 'No such channel.',
code: 'NO_SUCH_CHANNEL',
id: '6f6c314b-7486-4897-8966-c04a66a02923',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -30,7 +37,9 @@ export default define(meta, paramDef, async (ps, me) => {
id: ps.channelId, id: ps.channelId,
}); });
if (channel == null) throw new ApiError('NO_SUCH_CHANNEL'); if (channel == null) {
throw new ApiError(meta.errors.noSuchChannel);
}
return await Channels.pack(channel, me); return await Channels.pack(channel, me);
}); });

View file

@ -8,6 +8,7 @@ export const meta = {
tags: ['notes', 'channels'], tags: ['notes', 'channels'],
requireCredential: false, requireCredential: false,
requireCredentialPrivateMode: true,
res: { res: {
type: 'array', type: 'array',
@ -19,7 +20,13 @@ export const meta = {
}, },
}, },
errors: ['NO_SUCH_CHANNEL'], errors: {
noSuchChannel: {
message: 'No such channel.',
code: 'NO_SUCH_CHANNEL',
id: '4d0eeeba-a02c-4c3c-9966-ef60d38d2e7f',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -41,7 +48,9 @@ export default define(meta, paramDef, async (ps, user) => {
id: ps.channelId, id: ps.channelId,
}); });
if (channel == null) throw new ApiError('NO_SUCH_CHANNEL'); if (channel == null) {
throw new ApiError(meta.errors.noSuchChannel);
}
//#region Construct query //#region Construct query
const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)

View file

@ -10,7 +10,13 @@ export const meta = {
kind: 'write:channels', kind: 'write:channels',
errors: ['NO_SUCH_CHANNEL'], errors: {
noSuchChannel: {
message: 'No such channel.',
code: 'NO_SUCH_CHANNEL',
id: '19959ee9-0153-4c51-bbd9-a98c49dc59d6',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -27,7 +33,9 @@ export default define(meta, paramDef, async (ps, user) => {
id: ps.channelId, id: ps.channelId,
}); });
if (channel == null) throw new ApiError('NO_SUCH_CHANNEL'); if (channel == null) {
throw new ApiError(meta.errors.noSuchChannel);
}
await ChannelFollowings.delete({ await ChannelFollowings.delete({
followerId: user.id, followerId: user.id,

View file

@ -15,7 +15,25 @@ export const meta = {
ref: 'Channel', ref: 'Channel',
}, },
errors: ['ACCESS_DENIED', 'NO_SUCH_CHANNEL', 'NO_SUCH_FILE'], errors: {
noSuchChannel: {
message: 'No such channel.',
code: 'NO_SUCH_CHANNEL',
id: 'f9c5467f-d492-4c3c-9a8d-a70dacc86512',
},
accessDenied: {
message: 'You do not have edit privilege of the channel.',
code: 'ACCESS_DENIED',
id: '1fb7cb09-d46a-4fdf-b8df-057788cce513',
},
noSuchFile: {
message: 'No such file.',
code: 'NO_SUCH_FILE',
id: 'e86c14a4-0da2-4032-8df3-e737a04c7f3b',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -35,9 +53,13 @@ export default define(meta, paramDef, async (ps, me) => {
id: ps.channelId, id: ps.channelId,
}); });
if (channel == null) throw new ApiError('NO_SUCH_CHANNEL'); if (channel == null) {
throw new ApiError(meta.errors.noSuchChannel);
}
if (channel.userId !== me.id) throw new ApiError('ACCESS_DENIED', 'You are not the owner of this channel.'); if (channel.userId !== me.id) {
throw new ApiError(meta.errors.accessDenied);
}
// eslint:disable-next-line:no-unnecessary-initializer // eslint:disable-next-line:no-unnecessary-initializer
let banner = undefined; let banner = undefined;
@ -47,7 +69,9 @@ export default define(meta, paramDef, async (ps, me) => {
userId: me.id, userId: me.id,
}); });
if (banner == null) throw new ApiError('NO_SUCH_FILE'); if (banner == null) {
throw new ApiError(meta.errors.noSuchFile);
}
} else if (ps.bannerId === null) { } else if (ps.bannerId === null) {
banner = null; banner = null;
} }

View file

@ -4,6 +4,7 @@ import define from '../../define.js';
export const meta = { export const meta = {
tags: ['charts', 'users'], tags: ['charts', 'users'],
requireCredentialPrivateMode: true,
res: getJsonSchema(activeUsersChart.schema), res: getJsonSchema(activeUsersChart.schema),

View file

@ -4,6 +4,7 @@ import define from '../../define.js';
export const meta = { export const meta = {
tags: ['charts'], tags: ['charts'],
requireCredentialPrivateMode: true,
res: getJsonSchema(apRequestChart.schema), res: getJsonSchema(apRequestChart.schema),

View file

@ -4,6 +4,7 @@ import define from '../../define.js';
export const meta = { export const meta = {
tags: ['charts', 'drive'], tags: ['charts', 'drive'],
requireCredentialPrivateMode: true,
res: getJsonSchema(driveChart.schema), res: getJsonSchema(driveChart.schema),

View file

@ -4,6 +4,7 @@ import define from '../../define.js';
export const meta = { export const meta = {
tags: ['charts'], tags: ['charts'],
requireCredentialPrivateMode: true,
res: getJsonSchema(federationChart.schema), res: getJsonSchema(federationChart.schema),

View file

@ -4,6 +4,7 @@ import define from '../../define.js';
export const meta = { export const meta = {
tags: ['charts', 'hashtags'], tags: ['charts', 'hashtags'],
requireCredentialPrivateMode: true,
res: getJsonSchema(hashtagChart.schema), res: getJsonSchema(hashtagChart.schema),

View file

@ -4,6 +4,7 @@ import define from '../../define.js';
export const meta = { export const meta = {
tags: ['charts'], tags: ['charts'],
requireCredentialPrivateMode: true,
res: getJsonSchema(instanceChart.schema), res: getJsonSchema(instanceChart.schema),

View file

@ -4,6 +4,7 @@ import define from '../../define.js';
export const meta = { export const meta = {
tags: ['charts', 'notes'], tags: ['charts', 'notes'],
requireCredentialPrivateMode: true,
res: getJsonSchema(notesChart.schema), res: getJsonSchema(notesChart.schema),

View file

@ -4,6 +4,7 @@ import define from '../../../define.js';
export const meta = { export const meta = {
tags: ['charts', 'drive', 'users'], tags: ['charts', 'drive', 'users'],
requireCredentialPrivateMode: true,
res: getJsonSchema(perUserDriveChart.schema), res: getJsonSchema(perUserDriveChart.schema),

View file

@ -4,6 +4,7 @@ import define from '../../../define.js';
export const meta = { export const meta = {
tags: ['charts', 'users', 'following'], tags: ['charts', 'users', 'following'],
requireCredentialPrivateMode: true,
res: getJsonSchema(perUserFollowingChart.schema), res: getJsonSchema(perUserFollowingChart.schema),

View file

@ -4,6 +4,7 @@ import define from '../../../define.js';
export const meta = { export const meta = {
tags: ['charts', 'users', 'notes'], tags: ['charts', 'users', 'notes'],
requireCredentialPrivateMode: true,
res: getJsonSchema(perUserNotesChart.schema), res: getJsonSchema(perUserNotesChart.schema),

View file

@ -4,6 +4,7 @@ import define from '../../../define.js';
export const meta = { export const meta = {
tags: ['charts', 'users', 'reactions'], tags: ['charts', 'users', 'reactions'],
requireCredentialPrivateMode: true,
res: getJsonSchema(perUserReactionsChart.schema), res: getJsonSchema(perUserReactionsChart.schema),

View file

@ -4,6 +4,7 @@ import define from '../../define.js';
export const meta = { export const meta = {
tags: ['charts', 'users'], tags: ['charts', 'users'],
requireCredentialPrivateMode: true,
res: getJsonSchema(usersChart.schema), res: getJsonSchema(usersChart.schema),

View file

@ -11,7 +11,25 @@ export const meta = {
kind: 'write:account', kind: 'write:account',
errors: ['ALREADY_CLIPPED', 'NO_SUCH_CLIP', 'NO_SUCH_NOTE'], errors: {
noSuchClip: {
message: 'No such clip.',
code: 'NO_SUCH_CLIP',
id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf',
},
noSuchNote: {
message: 'No such note.',
code: 'NO_SUCH_NOTE',
id: 'fc8c0b49-c7a3-4664-a0a6-b418d386bb8b',
},
alreadyClipped: {
message: 'The note has already been clipped.',
code: 'ALREADY_CLIPPED',
id: '734806c4-542c-463a-9311-15c512803965',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -30,10 +48,12 @@ export default define(meta, paramDef, async (ps, user) => {
userId: user.id, userId: user.id,
}); });
if (clip == null) throw new ApiError('NO_SUCH_CLIP'); if (clip == null) {
throw new ApiError(meta.errors.noSuchClip);
}
const note = await getNote(ps.noteId, user).catch(err => { const note = await getNote(ps.noteId, user).catch(err => {
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError('NO_SUCH_NOTE'); if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
throw err; throw err;
}); });
@ -42,7 +62,9 @@ export default define(meta, paramDef, async (ps, user) => {
clipId: clip.id, clipId: clip.id,
}); });
if (exist != null) throw new ApiError('ALREADY_CLIPPED'); if (exist != null) {
throw new ApiError(meta.errors.alreadyClipped);
}
await ClipNotes.insert({ await ClipNotes.insert({
id: genId(), id: genId(),

View file

@ -9,7 +9,13 @@ export const meta = {
kind: 'write:account', kind: 'write:account',
errors: ['NO_SUCH_CLIP'], errors: {
noSuchClip: {
message: 'No such clip.',
code: 'NO_SUCH_CLIP',
id: '70ca08ba-6865-4630-b6fb-8494759aa754',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -27,7 +33,9 @@ export default define(meta, paramDef, async (ps, user) => {
userId: user.id, userId: user.id,
}); });
if (clip == null) throw new ApiError('NO_SUCH_CLIP'); if (clip == null) {
throw new ApiError(meta.errors.noSuchClip);
}
await Clips.delete(clip.id); await Clips.delete(clip.id);
}); });

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