Merge branch 'develop' into fix-msg-room

This commit is contained in:
tamaina 2022-06-11 07:19:05 +00:00
commit 312e2a022c
210 changed files with 2296 additions and 1439 deletions

View File

@ -22,7 +22,10 @@ First, in order to avoid duplicate Issues, please search to see if the problem y
## 🤬 Actual Behavior
<!--- Tell us what happens instead of the expected behavior -->
<!--
Tell us what happens instead of the expected behavior.
Please include errors from the developer console and/or server log files if you have access to them.
-->
## 📝 Steps to Reproduce

4
.github/labeler.yml vendored
View File

@ -4,5 +4,9 @@
'🖥Client':
- packages/client/**/*
'🧪Test':
- cypress/**/*
- packages/backend/test/**/*
'‼️ wrong locales':
- any: ['locales/*.yml', '!locales/ja-JP.yml']

View File

@ -1,6 +1,8 @@
name: "Pull Request Labeler"
on:
- pull_request_target
pull_request_target:
branches-ignore:
- 'l10n_develop'
jobs:
triage:

36
.github/workflows/ok-to-test.yml vendored Normal file
View File

@ -0,0 +1,36 @@
# If someone with write access comments "/ok-to-test" on a pull request, emit a repository_dispatch event
name: Ok To Test
on:
issue_comment:
types: [created]
jobs:
ok-to-test:
runs-on: ubuntu-latest
# Only run for PRs, not issue comments
if: ${{ github.event.issue.pull_request }}
steps:
# Generate a GitHub App installation access token from an App ID and private key
# To create a new GitHub App:
# https://developer.github.com/apps/building-github-apps/creating-a-github-app/
# See app.yml for an example app manifest
- name: Generate token
id: generate_token
uses: tibdex/github-app-token@v1
with:
app_id: ${{ secrets.DEPLOYBOT_APP_ID }}
private_key: ${{ secrets.DEPLOYBOT_PRIVATE_KEY }}
- name: Slash Command Dispatch
uses: peter-evans/slash-command-dispatch@v1
env:
TOKEN: ${{ steps.generate_token.outputs.token }}
with:
token: ${{ env.TOKEN }} # GitHub App installation access token
# token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} # PAT or OAuth token will also work
reaction-token: ${{ secrets.GITHUB_TOKEN }}
issue-type: pull-request
commands: deploy
named-args: true
permission: write

95
.github/workflows/pr-preview-deploy.yml vendored Normal file
View File

@ -0,0 +1,95 @@
# Run secret-dependent integration tests only after /deploy approval
on:
pull_request:
types: [opened, reopened, synchronize]
repository_dispatch:
types: [deploy-command]
name: Deploy preview environment
jobs:
# Repo owner has commented /deploy on a (fork-based) pull request
deploy-preview-environment:
runs-on: ubuntu-latest
if:
github.event_name == 'repository_dispatch' &&
github.event.client_payload.slash_command.sha != '' &&
contains(github.event.client_payload.pull_request.head.sha, github.event.client_payload.slash_command.sha)
steps:
- uses: actions/github-script@v5
id: check-id
env:
number: ${{ github.event.client_payload.pull_request.number }}
job: ${{ github.job }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
result-encoding: string
script: |
const { data: pull } = await github.rest.pulls.get({
...context.repo,
pull_number: process.env.number
});
const ref = pull.head.sha;
const { data: checks } = await github.rest.checks.listForRef({
...context.repo,
ref
});
const check = checks.check_runs.filter(c => c.name === process.env.job);
return check[0].id;
- uses: actions/github-script@v5
env:
check_id: ${{ steps.check-id.outputs.result }}
details_url: ${{ github.server_url }}/${{ github.repository }}/runs/${{ github.run_id }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
await github.rest.checks.update({
...context.repo,
check_run_id: process.env.check_id,
status: 'in_progress',
details_url: process.env.details_url
});
# Check out merge commit
- name: Fork based /deploy checkout
uses: actions/checkout@v2
with:
ref: 'refs/pull/${{ github.event.client_payload.pull_request.number }}/merge'
# <insert integration tests needing secrets>
- name: Context
uses: okteto/context@latest
with:
token: ${{ secrets.OKTETO_TOKEN }}
- name: Deploy preview environment
uses: ikuradon/deploy-preview@latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
name: pr-${{ github.event.client_payload.pull_request.number }}-syuilo
timeout: 15m
# Update check run called "integration-fork"
- uses: actions/github-script@v5
id: update-check-run
if: ${{ always() }}
env:
# Conveniently, job.status maps to https://developer.github.com/v3/checks/runs/#update-a-check-run
conclusion: ${{ job.status }}
check_id: ${{ steps.check-id.outputs.result }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { data: result } = await github.rest.checks.update({
...context.repo,
check_run_id: process.env.check_id,
status: 'completed',
conclusion: process.env.conclusion
});
return result;

View File

@ -0,0 +1,21 @@
# file: .github/workflows/preview-closed.yaml
on:
pull_request:
types:
- closed
name: Destroy preview environment
jobs:
destroy-preview-environment:
runs-on: ubuntu-latest
steps:
- name: Context
uses: okteto/context@latest
with:
token: ${{ secrets.OKTETO_TOKEN }}
- name: Destroy preview environment
uses: okteto/destroy-preview@latest
with:
name: pr-${{ github.event.number }}-syuilo

View File

@ -1 +1 @@
v18.0.0
v16.15.0

View File

@ -10,38 +10,52 @@ You should also include the user name that made the change.
-->
## 12.x.x (unreleased)
### NOTE
- From this version, Node 18.0.0 or later is required.
### Improvements
- enhance: ドライブに画像ファイルをアップロードするときオリジナル画像を破棄してwebpublicのみ保持するオプション @tamaina
- enhance: API: notifications/readは配列でも受け付けるように #7667 @tamaina
- enhance: プッシュ通知を複数アカウント対応に #7667 @tamaina
- enhance: プッシュ通知にクリックやactionを設定 #7667 @tamaina
- replaced webpack with Vite @tamaina
- update dependencies @syuilo
- enhance: display URL of QR code for TOTP registration @syuilo
- make CAPTCHA required for signin to improve security @syuilo
- enhance: Supports Unicode Emoji 14.0 @mei23
- Supports Unicode Emoji 14.0 @mei23
- プッシュ通知を複数アカウント対応に #7667 @tamaina
- プッシュ通知にクリックやactionを設定 #7667 @tamaina
- ドライブに画像ファイルをアップロードするときオリジナル画像を破棄してwebpublicのみ保持するオプション @tamaina
- Server: always remove completed tasks of job queue @Johann150
- Client: アバターの設定で画像をクロップできるように @syuilo
- Client: make emoji stand out more on reaction button @Johann150
- Client: display URL of QR code for TOTP registration @tamaina
- Client: render quote renote CWs as MFM @pixeldesu
- API: notifications/readは配列でも受け付けるように #7667 @tamaina
- API: ユーザー検索で、クエリがusernameの条件を満たす場合はusernameもLIKE検索するように @tamaina
- MFM: Allow speed changes in all animated MFMs @Johann150
- The theme color is now better validated. @Johann150
Your own theme color may be unset if it was in an invalid format.
Admins should check their instance settings if in doubt.
- Perform port diagnosis at startup only when Listen fails @mei23
- Rate limiting is now also usable for non-authenticated users. @Johann150 @mei23
Admins should make sure the reverse proxy sets the `X-Forwarded-For` header to the original address.
### Bugfixes
- Client: fix settings page @tamaina
- Client: fix profile tabs @futchitwo
- Server: keep file order of note attachement @Johann150
- Server: fix caching @Johann150
- Server: await promises when following or unfollowing users @Johann150
- Client: fix abuse reports page to be able to show all reports @Johann150
- Federation: Add rel attribute to host-meta @mei23
- Client: fix profile picture height in mentions @tamaina
- MFM: more animated functions support `speed` parameter @futchitwo
- Federation: Fix quote renotes containing no text being federated correctly @Johann150
- Server: fix missing foreign key for reports leading to reports page being unusable @Johann150
- Server: fix internal in-memory caching @Johann150
- Server: use correct order of attachments on notes @Johann150
- Server: prevent crash when processing certain PNGs @syuilo
- Server: Fix unable to generate video thumbnails @mei23
- Server: Fix `Cannot find module` issue @mei23
- Federation: Add rel attribute to host-meta @mei23
- Federation: add id for activitypub follows @Johann150
- Federation: ensure resolver does not fetch local resources via HTTP(S) @Johann150
- Federation: correctly render empty note text @Johann150
- Federation: Fix quote renotes containing no text being federated correctly @Johann150
- Federation: remove duplicate br tag/newline @Johann150
- Federation: add missing authorization checks @Johann150
- Client: fix profile picture height in mentions @tamaina
- Client: fix abuse reports page to be able to show all reports @Johann150
- Client: fix settings page @tamaina
- Client: fix profile tabs @futchitwo
- Client: fix popout URL @futchitwo
- Client: correctly handle MiAuth URLs with query string @sn0w
- Client: ノート詳細ページの新しいノートを表示する機能の動作が正しくなるように修正する @xianonn
- MFM: more animated functions support `speed` parameter @futchitwo
- MFM: limit large MFM @Johann150
## 12.110.1 (2022/04/23)

View File

@ -66,20 +66,29 @@ Be willing to comment on the good points and not just the things you want fixed
- Are there any omissions or gaps?
- Does it check for anomalies?
## Deploy
The `/deploy` command by issue comment can be used to deploy the contents of a PR to the preview environment.
```
/deploy sha=<commit hash>
```
An actual domain will be assigned so you can test the federation.
## Merge
For now, basically only @syuilo has the authority to merge PRs into develop because he is most familiar with the codebase.
However, minor fixes, refactoring, and urgent changes may be merged at the discretion of a contributor.
## Release
For now, basically only @syuilo has the authority to release Misskey.
However, in case of emergency, a release can be made at the discretion of a contributor.
### Release Instructions
1. commit version changes in the `develop` branch ([package.json](https://github.com/misskey-dev/misskey/blob/develop/package.json))
2. follow the `master` branch to the `develop` branch.
3. Create a [release of GitHub](https://github.com/misskey-dev/misskey/releases)
- The target branch must be `master`
- The tag name must be the version
1. Commit version changes in the `develop` branch ([package.json](https://github.com/misskey-dev/misskey/blob/develop/package.json))
2. Create a release PR.
- Into `master` from `develop` branch.
- The title must be in the format `Release: x.y.z`.
- `x.y.z` is the new version you are trying to release.
3. Deploy and perform a simple QA check. Also verify that the tests passed.
4. Merge it.
5. Create a [release of GitHub](https://github.com/misskey-dev/misskey/releases)
- The target branch must be `master`
- The tag name must be the version
## Localization (l10n)
Misskey uses [Crowdin](https://crowdin.com/project/misskey) for localization management.

View File

@ -47,6 +47,10 @@ With Misskey's built in drive, you get cloud storage right in your social media,
<div style="clear: both;"></div>
## Documentation
Misskey Documentation can be found at [Misskey Hub](https://misskey-hub.net/), some of the links and graphics above also lead to specific portions of it.
## Sponsors
<div align="center">
<a class="rss3" title="RSS3" href="https://rss3.io/" target="_blank"><img src="https://rss3.mypinata.cloud/ipfs/QmUG6H3Z7D5P511shn7sB4CPmpjH5uZWu4m5mWX7U3Gqbu" alt="RSS3" height="60"></a>

12
cypress.config.ts Normal file
View File

@ -0,0 +1,12 @@
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
setupNodeEvents(on, config) {
return require('./cypress/plugins/index.js')(on, config)
},
baseUrl: 'http://localhost:61812',
},
})

View File

@ -1,3 +0,0 @@
{
"baseUrl": "http://localhost:61812"
}

View File

@ -1,11 +1,6 @@
describe('Before setup instance', () => {
beforeEach(() => {
cy.window(win => {
win.indexedDB.deleteDatabase('keyval-store');
});
cy.request('POST', '/api/reset-db').as('reset');
cy.get('@reset').its('status').should('equal', 204);
cy.reload(true);
cy.resetState();
});
afterEach(() => {
@ -35,18 +30,10 @@ describe('Before setup instance', () => {
describe('After setup instance', () => {
beforeEach(() => {
cy.window(win => {
win.indexedDB.deleteDatabase('keyval-store');
});
cy.request('POST', '/api/reset-db').as('reset');
cy.get('@reset').its('status').should('equal', 204);
cy.reload(true);
cy.resetState();
// インスタンス初期セットアップ
cy.request('POST', '/api/admin/accounts/create', {
username: 'admin',
password: 'pass',
}).its('body').as('admin');
cy.registerUser('admin', 'pass', true);
});
afterEach(() => {
@ -76,24 +63,13 @@ describe('After setup instance', () => {
describe('After user signup', () => {
beforeEach(() => {
cy.window(win => {
win.indexedDB.deleteDatabase('keyval-store');
});
cy.request('POST', '/api/reset-db').as('reset');
cy.get('@reset').its('status').should('equal', 204);
cy.reload(true);
cy.resetState();
// インスタンス初期セットアップ
cy.request('POST', '/api/admin/accounts/create', {
username: 'admin',
password: 'pass',
}).its('body').as('admin');
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.request('POST', '/api/signup', {
username: 'alice',
password: 'alice1234',
}).its('body').as('alice');
cy.registerUser('alice', 'alice1234');
});
afterEach(() => {
@ -138,34 +114,15 @@ describe('After user signup', () => {
describe('After user singed in', () => {
beforeEach(() => {
cy.window(win => {
win.indexedDB.deleteDatabase('keyval-store');
});
cy.request('POST', '/api/reset-db').as('reset');
cy.get('@reset').its('status').should('equal', 204);
cy.reload(true);
cy.resetState();
// インスタンス初期セットアップ
cy.request('POST', '/api/admin/accounts/create', {
username: 'admin',
password: 'pass',
}).its('body').as('admin');
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.request('POST', '/api/signup', {
username: 'alice',
password: 'alice1234',
}).its('body').as('alice');
cy.registerUser('alice', 'alice1234');
cy.visit('/');
cy.intercept('POST', '/api/signin').as('signin');
cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-username] input').type('alice');
cy.get('[data-cy-signin-password] input').type('alice1234{enter}');
cy.wait('@signin').as('signedIn');
cy.login('alice', 'alice1234');
});
afterEach(() => {

65
cypress/e2e/widgets.cy.js Normal file
View File

@ -0,0 +1,65 @@
describe('After user signed in', () => {
beforeEach(() => {
cy.resetState();
cy.viewport('macbook-16');
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('widget edit toggle is visible', () => {
cy.get('.mk-widget-edit').should('be.visible');
});
it('widget select should be visible in edit mode', () => {
cy.get('.mk-widget-edit').click();
cy.get('.mk-widget-select').should('be.visible');
});
it('first widget should be removed', () => {
cy.get('.mk-widget-edit').click();
cy.get('.customize-container:first-child .remove._button').click();
cy.get('.customize-container').should('have.length', 2);
});
function buildWidgetTest(widgetName) {
it(`${widgetName} widget should get added`, () => {
cy.get('.mk-widget-edit').click();
cy.get('.mk-widget-select select').select(widgetName, { force: true });
cy.get('.bg._modalBg.transparent').click({ multiple: true, force: true });
cy.get('.mk-widget-add').click({ force: true });
cy.get(`.mkw-${widgetName}`).should('exist');
});
}
buildWidgetTest('memo');
buildWidgetTest('notifications');
buildWidgetTest('timeline');
buildWidgetTest('calendar');
buildWidgetTest('rss');
buildWidgetTest('trends');
buildWidgetTest('clock');
buildWidgetTest('activity');
buildWidgetTest('photos');
buildWidgetTest('digitalClock');
buildWidgetTest('federation');
buildWidgetTest('postForm');
buildWidgetTest('slideshow');
buildWidgetTest('serverMetric');
buildWidgetTest('onlineUsers');
buildWidgetTest('jobQueue');
buildWidgetTest('button');
buildWidgetTest('aiscript');
buildWidgetTest('aichan');
});

View File

@ -23,3 +23,33 @@
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
Cypress.Commands.add('resetState', () => {
cy.window(win => {
win.indexedDB.deleteDatabase('keyval-store');
});
cy.request('POST', '/api/reset-db').as('reset');
cy.get('@reset').its('status').should('equal', 204);
cy.reload(true);
});
Cypress.Commands.add('registerUser', (username, password, isAdmin = false) => {
const route = isAdmin ? '/api/admin/accounts/create' : '/api/signup';
cy.request('POST', route, {
username: username,
password: password,
}).its('body').as(username);
});
Cypress.Commands.add('login', (username, password) => {
cy.visit('/');
cy.intercept('POST', '/api/signin').as('signin');
cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-username] input').type(username);
cy.get('[data-cy-signin-password] input').type(`${password}{enter}`);
cy.wait('@signin').as('signedIn');
});

View File

@ -842,6 +842,9 @@ oneDay: "1日"
oneWeek: "1週間"
reflectMayTakeTime: "反映されるまで時間がかかる場合があります。"
failedToFetchAccountInformation: "アカウント情報の取得に失敗しました"
rateLimitExceeded: "レート制限を超えました"
cropImage: "画像のクロップ"
cropImageAsk: "画像をクロップしますか?"
_emailUnavailable:
used: "既に使用されています"
@ -1110,7 +1113,6 @@ _sfx:
channel: "チャンネル通知"
_ago:
unknown: "謎"
future: "未来"
justNow: "たった今"
secondsAgo: "{n}秒前"

View File

@ -19,7 +19,7 @@
"watch": "npm run dev",
"dev": "node ./scripts/dev.js",
"lint": "node ./scripts/lint.js",
"cy:open": "cypress open",
"cy:open": "cypress open --browser --e2e --config-file=cypress.config.ts",
"cy:run": "cypress run",
"e2e": "start-server-and-test start:test http://localhost:61812 cy:run",
"mocha": "cd packages/backend && cross-env NODE_ENV=test TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT=\"./test/tsconfig.json\" npx mocha",
@ -41,10 +41,10 @@
"devDependencies": {
"@types/gulp": "4.0.9",
"@types/gulp-rename": "2.0.1",
"@typescript-eslint/parser": "5.18.0",
"@typescript-eslint/parser": "5.27.1",
"cross-env": "7.0.3",
"cypress": "9.5.3",
"cypress": "10.0.3",
"start-server-and-test": "1.14.0",
"typescript": "4.6.3"
"typescript": "4.7.3"
}
}

View File

@ -5,6 +5,6 @@
"loader=./test/loader.js"
],
"slow": 1000,
"timeout": 3000,
"timeout": 10000,
"exit": true
}

View File

@ -14,7 +14,7 @@
"lodash": "^4.17.21"
},
"dependencies": {
"@bull-board/koa": "3.10.4",
"@bull-board/koa": "3.11.1",
"@discordapp/twemoji": "14.0.2",
"@elastic/elasticsearch": "7.11.0",
"@koa/cors": "3.1.0",
@ -28,10 +28,9 @@
"archiver": "5.3.1",
"autobind-decorator": "2.4.0",
"autwh": "0.1.0",
"aws-sdk": "2.1135.0",
"aws-sdk": "2.1152.0",
"bcryptjs": "2.4.3",
"blurhash": "1.1.5",
"broadcast-channel": "4.12.0",
"bull": "4.8.3",
"cacheable-lookup": "6.0.4",
"cbor": "8.1.0",
@ -44,18 +43,18 @@
"deep-email-validator": "0.1.21",
"escape-regexp": "0.0.1",
"feed": "4.2.2",
"file-type": "17.1.1",
"file-type": "17.1.2",
"fluent-ffmpeg": "2.1.2",
"got": "12.0.4",
"got": "12.1.0",
"hpagent": "0.1.2",
"ip-cidr": "3.0.8",
"ip-cidr": "3.0.10",
"is-svg": "4.3.2",
"js-yaml": "4.1.0",
"jsdom": "19.0.0",
"json5": "2.2.1",
"json5-loader": "4.0.1",
"jsonld": "5.2.0",
"jsrsasign": "10.5.22",
"jsonld": "6.0.0",
"jsrsasign": "10.5.24",
"koa": "2.13.4",
"koa-bodyparser": "4.3.0",
"koa-favicon": "2.1.0",
@ -72,7 +71,7 @@
"ms": "3.0.0-canary.1",
"multer": "1.4.4",
"nested-property": "4.0.0",
"node-fetch": "3.2.4",
"node-fetch": "3.2.6",
"nodemailer": "6.7.5",
"os-utils": "0.0.14",
"parse5": "6.0.1",
@ -101,14 +100,14 @@
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"style-loader": "3.3.1",
"summaly": "2.5.0",
"summaly": "2.5.1",
"syslog-pro": "1.0.0",
"systeminformation": "5.11.15",
"systeminformation": "5.11.16",
"tinycolor2": "1.4.2",
"tmp": "0.2.1",
"ts-loader": "9.3.0",
"ts-node": "10.8.0",
"tsc-alias": "1.6.7",
"ts-node": "10.8.1",
"tsc-alias": "1.6.9",
"tsconfig-paths": "4.0.0",
"twemoji-parser": "14.0.0",
"typeorm": "0.3.6",
@ -117,7 +116,7 @@
"uuid": "8.3.2",
"web-push": "3.5.0",
"websocket": "1.0.34",
"ws": "8.6.0",
"ws": "8.8.0",
"xev": "3.0.2"
},
"devDependencies": {
@ -145,7 +144,7 @@
"@types/koa__multer": "2.0.4",
"@types/koa__router": "8.0.11",
"@types/mocha": "9.1.1",
"@types/node": "17.0.35",
"@types/node": "17.0.41",
"@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.4",
"@types/oauth": "0.9.1",
@ -167,12 +166,11 @@
"@types/web-push": "3.3.2",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.3",
"@typescript-eslint/eslint-plugin": "5.26.0",
"@typescript-eslint/parser": "5.26.0",
"typescript": "4.7.2",
"eslint": "8.16.0",
"@typescript-eslint/eslint-plugin": "5.27.1",
"@typescript-eslint/parser": "5.27.1",
"typescript": "4.7.3",
"eslint": "8.17.0",
"eslint-plugin-import": "2.26.0",
"cross-env": "7.0.3",
"execa": "6.1.0"
}

View File

@ -46,7 +46,7 @@ export default function load() {
mixin.authUrl = `${mixin.scheme}://${mixin.host}/auth`;
mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`;
mixin.userAgent = `Misskey/${meta.version} (${config.url})`;
mixin.clientEntry = clientManifest['src/init.ts'].file.replace(/^_client_dist_\//, '');
mixin.clientEntry = clientManifest['src/init.ts'];
if (!config.redis.prefix) config.redis.prefix = mixin.host;

View File

@ -73,6 +73,7 @@ import { entities as charts } from '@/services/chart/entities.js';
import { Webhook } from '@/models/entities/webhook.js';
import { envOption } from '../env.js';
import { dbLogger } from './logger.js';
import { redisClient } from './redis.js';
const sqlLogger = dbLogger.createSubLogger('sql', 'gray', false);
@ -207,7 +208,15 @@ export const db = new DataSource({
migrations: ['../../migration/*.js'],
});
export async function initDb() {
export async function initDb(force = false) {
if (force) {
if (db.isInitialized) {
await db.destroy();
}
await db.initialize();
return;
}
if (db.isInitialized) {
// nop
} else {
@ -217,6 +226,7 @@ export async function initDb() {
export async function resetDb() {
const reset = async () => {
await redisClient.FLUSHDB();
const tables = await db.query(`SELECT relname AS "table"
FROM pg_class C LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace)
WHERE nspname NOT IN ('pg_catalog', 'information_schema')

View File

@ -6,6 +6,9 @@ const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
export function fromHtml(html: string, hashtagNames?: string[]): string {
// some AP servers like Pixelfed use br tags as well as newlines
html = html.replace(/<br\s?\/?>\r?\n/gi, '\n');
const dom = parse5.parseFragment(html);
let text = '';

View File

@ -0,0 +1,9 @@
import IPCIDR from 'ip-cidr';
export function getIpHash(ip: string) {
// because a single person may control many IPv6 addresses,
// only a /64 subnet prefix of any IP will be taken into account.
// (this means for IPv4 the entire address is used)
const prefix = IPCIDR.createAddress(ip).mask(64);
return 'ip-' + BigInt('0b' + prefix).toString(36);
}

View File

@ -29,7 +29,9 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({
getPublicProperties(file: DriveFile): DriveFile['properties'] {
if (file.properties.orientation != null) {
const properties = structuredClone(file.properties);
// TODO
//const properties = structuredClone(file.properties);
const properties = JSON.parse(JSON.stringify(file.properties));
if (file.properties.orientation >= 5) {
[properties.width, properties.height] = [properties.height, properties.width];
}

View File

@ -144,13 +144,7 @@ export const NoteRepository = db.getRepository(Note).extend({
return true;
} else {
// 指定されているかどうか
const specified = note.visibleUserIds.some((id: any) => meId === id);
if (specified) {
return true;
} else {
return false;
}
return note.visibleUserIds.some((id: any) => meId === id);
}
}
@ -169,9 +163,12 @@ export const NoteRepository = db.getRepository(Note).extend({
} else {
// フォロワーかどうか
const [following, user] = await Promise.all([
Followings.findOneBy({
followeeId: note.userId,
followerId: meId,
Followings.count({
where: {
followeeId: note.userId,
followerId: meId,
},
take: 1,
}),
Users.findOneByOrFail({ id: meId }),
]);
@ -183,7 +180,7 @@ export const NoteRepository = db.getRepository(Note).extend({
in which case we can never know the following. Instead we have
to assume that the users are following each other.
*/
return following != null || (note.userHost != null && user.host != null);
return following > 0 || (note.userHost != null && user.host != null);
}
}

View File

@ -61,47 +61,58 @@ export const UserRepository = db.getRepository(User).extend({
//#endregion
async getRelation(me: User['id'], target: User['id']) {
const [following1, following2, followReq1, followReq2, toBlocking, fromBlocked, mute] = await Promise.all([
Followings.findOneBy({
followerId: me,
followeeId: target,
}),
Followings.findOneBy({
followerId: target,
followeeId: me,
}),
FollowRequests.findOneBy({
followerId: me,
followeeId: target,
}),
FollowRequests.findOneBy({
followerId: target,
followeeId: me,
}),
Blockings.findOneBy({
blockerId: me,
blockeeId: target,
}),
Blockings.findOneBy({
blockerId: target,
blockeeId: me,
}),
Mutings.findOneBy({
muterId: me,
muteeId: target,
}),
]);
return {
return awaitAll({
id: target,
isFollowing: following1 != null,
hasPendingFollowRequestFromYou: followReq1 != null,
hasPendingFollowRequestToYou: followReq2 != null,
isFollowed: following2 != null,
isBlocking: toBlocking != null,
isBlocked: fromBlocked != null,
isMuted: mute != null,
};
isFollowing: Followings.count({
where: {
followerId: me,
followeeId: target,
},
take: 1,
}).then(n => n > 0),
isFollowed: Followings.count({
where: {
followerId: target,
followeeId: me,
},
take: 1,
}).then(n => n > 0),
hasPendingFollowRequestFromYou: FollowRequests.count({
where: {
followerId: me,
followeeId: target,
},
take: 1,
}).then(n => n > 0),
hasPendingFollowRequestToYou: FollowRequests.count({
where: {
followerId: target,
followeeId: me,
},
take: 1,
}).then(n => n > 0),
isBlocking: Blockings.count({
where: {
blockerId: me,
blockeeId: target,
},
take: 1,
}).then(n => n > 0),
isBlocked: Blockings.count({
where: {
blockerId: target,
blockeeId: me,
},
take: 1,
}).then(n => n > 0),
isMuted: Mutings.count({
where: {
muterId: me,
muteeId: target,
},
take: 1,
}).then(n => n > 0),
});
},
async getHasUnreadMessagingMessage(userId: User['id']): Promise<boolean> {

View File

@ -305,11 +305,13 @@ export default function() {
systemQueue.add('resyncCharts', {
}, {
repeat: { cron: '0 0 * * *' },
removeOnComplete: true,
});
systemQueue.add('cleanCharts', {
}, {
repeat: { cron: '0 0 * * *' },
removeOnComplete: true,
});
systemQueue.add('checkExpiredMutings', {

View File

@ -9,7 +9,7 @@ import { addFile } from '@/services/drive/add-file.js';
import { format as dateFormat } from 'date-fns';
import { Users, Emojis } from '@/models/index.js';
import { } from '@/queue/types.js';
import { createTempDir } from '@/misc/create-temp.js';
import { createTemp, createTempDir } from '@/misc/create-temp.js';
import { downloadUrl } from '@/misc/download-url.js';
import config from '@/config/index.js';
import { IsNull } from 'typeorm';

View File

@ -5,14 +5,52 @@ import { User, IRemoteUser, CacheableRemoteUser, CacheableUser } from '@/models/
import { UserPublickey } from '@/models/entities/user-publickey.js';
import { MessagingMessage } from '@/models/entities/messaging-message.js';
import { Notes, Users, UserPublickeys, MessagingMessages } from '@/models/index.js';
import { IObject, getApId } from './type.js';
import { resolvePerson } from './models/person.js';
import { Cache } from '@/misc/cache.js';
import { uriPersonCache, userByIdCache } from '@/services/user-cache.js';
import { IObject, getApId } from './type.js';
import { resolvePerson } from './models/person.js';
const publicKeyCache = new Cache<UserPublickey | null>(Infinity);
const publicKeyByUserIdCache = new Cache<UserPublickey | null>(Infinity);
export type UriParseResult = {
/** wether the URI was generated by us */
local: true;
/** id in DB */
id: string;
/** hint of type, e.g. "notes", "users" */
type: string;
/** any remaining text after type and id, not including the slash after id. undefined if empty */
rest?: string;
} | {
/** wether the URI was generated by us */
local: false;
/** uri in DB */
uri: string;
};
export function parseUri(value: string | IObject): UriParseResult {
const uri = getApId(value);
// the host part of a URL is case insensitive, so use the 'i' flag.
const localRegex = new RegExp('^' + escapeRegexp(config.url) + '/(\\w+)/(\\w+)(?:\/(.+))?', 'i');
const matchLocal = uri.match(localRegex);
if (matchLocal) {
return {
local: true,
type: matchLocal[1],
id: matchLocal[2],
rest: matchLocal[3],
};
} else {
return {
local: false,
uri,
};
}
}
export default class DbResolver {
constructor() {
}
@ -21,60 +59,54 @@ export default class DbResolver {
* AP Note => Misskey Note in DB
*/
public async getNoteFromApId(value: string | IObject): Promise<Note | null> {
const parsed = this.parseUri(value);
const parsed = parseUri(value);
if (parsed.local) {
if (parsed.type !== 'notes') return null;
if (parsed.id) {
return await Notes.findOneBy({
id: parsed.id,
});
}
if (parsed.uri) {
} else {
return await Notes.findOneBy({
uri: parsed.uri,
});
}
return null;
}
public async getMessageFromApId(value: string | IObject): Promise<MessagingMessage | null> {
const parsed = this.parseUri(value);
const parsed = parseUri(value);
if (parsed.local) {
if (parsed.type !== 'notes') return null;
if (parsed.id) {
return await MessagingMessages.findOneBy({
id: parsed.id,
});
}
if (parsed.uri) {
} else {
return await MessagingMessages.findOneBy({
uri: parsed.uri,
});
}
return null;
}
/**
* AP Person => Misskey User in DB
*/
public async getUserFromApId(value: string | IObject): Promise<CacheableUser | null> {
const parsed = this.parseUri(value);
const parsed = parseUri(value);
if (parsed.local) {
if (parsed.type !== 'users') return null;
if (parsed.id) {
return await userByIdCache.fetchMaybe(parsed.id, () => Users.findOneBy({
id: parsed.id,
}).then(x => x ?? undefined)) ?? null;
}
if (parsed.uri) {
} else {
return await uriPersonCache.fetch(parsed.uri, () => Users.findOneBy({
uri: parsed.uri,
}));
}
return null;
}
/**
@ -120,31 +152,4 @@ export default class DbResolver {
key,
};
}
public parseUri(value: string | IObject): UriParseResult {
const uri = getApId(value);
const localRegex = new RegExp('^' + escapeRegexp(config.url) + '/' + '(\\w+)' + '/' + '(\\w+)');
const matchLocal = uri.match(localRegex);
if (matchLocal) {
return {
type: matchLocal[1],
id: matchLocal[2],
};
} else {
return {
uri,
};
}
}
}
type UriParseResult = {
/** id in DB (local object only) */
id?: string;
/** uri in DB (remote object only) */
uri?: string;
/** hint of type (local object only, ex: notes, users) */
type?: string
};

View File

@ -3,8 +3,6 @@ import { Note } from '@/models/entities/note.js';
import { toHtml } from '../../../mfm/to-html.js';
export default function(note: Note) {
let html = note.text ? toHtml(mfm.parse(note.text), JSON.parse(note.mentionedRemoteUsers)) : null;
if (html == null) html = '<p>.</p>';
return html;
if (!note.text) return '';
return toHtml(mfm.parse(note.text), JSON.parse(note.mentionedRemoteUsers));
}

View File

@ -3,9 +3,9 @@ import promiseLimit from 'promise-limit';
import config from '@/config/index.js';
import Resolver from '../resolver.js';
import post from '@/services/note/create.js';
import { resolvePerson, updatePerson } from './person.js';
import { resolvePerson } from './person.js';
import { resolveImage } from './image.js';
import { CacheableRemoteUser, IRemoteUser } from '@/models/entities/user.js';
import { CacheableRemoteUser } from '@/models/entities/user.js';
import { htmlToMfm } from '../misc/html-to-mfm.js';
import { extractApHashtags } from './tag.js';
import { unique, toArray, toSingle } from '@/prelude/array.js';
@ -15,7 +15,7 @@ import { apLogger } from '../logger.js';
import { DriveFile } from '@/models/entities/drive-file.js';
import { deliverQuestionUpdate } from '@/services/note/polls/update.js';
import { extractDbHost, toPuny } from '@/misc/convert-host.js';
import { Emojis, Polls, MessagingMessages, Users } from '@/models/index.js';
import { Emojis, Polls, MessagingMessages } from '@/models/index.js';
import { Note } from '@/models/entities/note.js';
import { IObject, getOneApId, getApId, getOneApHrefNullable, validPost, IPost, isEmoji, getApType } from '../type.js';
import { Emoji } from '@/models/entities/emoji.js';
@ -197,7 +197,14 @@ export async function createNote(value: string | IObject, resolver?: Resolver, s
const cw = note.summary === '' ? null : note.summary;
// テキストのパース
const text = typeof note._misskey_content !== 'undefined' ? note._misskey_content : (note.content ? htmlToMfm(note.content, note.tag) : null);
let text: string | null = null;
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source?.content === 'string') {
text = note.source.content;
} else if (typeof note._misskey_content === 'string') {
text = note._misskey_content;
} else if (typeof note.content === 'string') {
text = htmlToMfm(note.content, note.tag);
}
// vote
if (reply && reply.hasPoll) {

View File

@ -1,8 +1,20 @@
import config from '@/config/index.js';
import { ILocalUser, IRemoteUser } from '@/models/entities/user.js';
import { Blocking } from '@/models/entities/blocking.js';
export default (blocker: ILocalUser, blockee: IRemoteUser) => ({
type: 'Block',
actor: `${config.url}/users/${blocker.id}`,
object: blockee.uri,
});
/**
* Renders a block into its ActivityPub representation.
*
* @param block The block to be rendered. The blockee relation must be loaded.
*/
export function renderBlock(block: Blocking) {
if (block.blockee?.url == null) {
throw new Error('renderBlock: missing blockee uri');
}
return {
type: 'Block',
id: `${config.url}/blocks/${block.id}`,
actor: `${config.url}/users/${block.blockerId}`,
object: block.blockee.uri,
};
}

View File

@ -4,12 +4,11 @@ import { Users } from '@/models/index.js';
export default (follower: { id: User['id']; host: User['host']; uri: User['host'] }, followee: { id: User['id']; host: User['host']; uri: User['host'] }, requestId?: string) => {
const follow = {
id: requestId ?? `${config.url}/follows/${follower.id}/${followee.id}`,
type: 'Follow',
actor: Users.isLocalUser(follower) ? `${config.url}/users/${follower.id}` : follower.uri,
object: Users.isLocalUser(followee) ? `${config.url}/users/${followee.id}` : followee.uri,
} as any;
if (requestId) follow.id = requestId;
return follow;
};

View File

@ -8,7 +8,7 @@ import { User } from '@/models/entities/user.js';
export const renderActivity = (x: any): IActivity | null => {
if (x == null) return null;
if (x !== null && typeof x === 'object' && x.id == null) {
if (typeof x === 'object' && x.id == null) {
x.id = `${config.url}/${uuid()}`;
}

View File

@ -82,15 +82,15 @@ export default async function renderNote(note: Note, dive = true, isTalk = false
const files = await getPromisedFiles(note.fileIds);
const text = note.text;
// text should never be undefined
const text = note.text ?? null;
let poll: Poll | null = null;
if (note.hasPoll) {
poll = await Polls.findOneBy({ noteId: note.id });
}
let apText = text;
if (apText == null) apText = '';
let apText = text ?? '';
if (quote) {
apText += `\n\nRE: ${quote}`;
@ -138,6 +138,10 @@ export default async function renderNote(note: Note, dive = true, isTalk = false
summary,
content,
_misskey_content: text,
source: {
content: text,
mediaType: "text/x.misskeymarkdown",
},
_misskey_quote: quote,
quoteUrl: quote,
published: note.createdAt.toISOString(),

View File

@ -3,9 +3,18 @@ import { getJson } from '@/misc/fetch.js';
import { ILocalUser } from '@/models/entities/user.js';
import { getInstanceActor } from '@/services/instance-actor.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { extractDbHost } from '@/misc/convert-host.js';
import { extractDbHost, isSelfHost } from '@/misc/convert-host.js';
import { signedGet } from './request.js';
import { IObject, isCollectionOrOrderedCollection, ICollection, IOrderedCollection } from './type.js';
import { FollowRequests, Notes, NoteReactions, Polls, Users } from '@/models/index.js';
import { parseUri } from './db-resolver.js';
import renderNote from '@/remote/activitypub/renderer/note.js';
import { renderLike } from '@/remote/activitypub/renderer/like.js';
import { renderPerson } from '@/remote/activitypub/renderer/person.js';
import renderQuestion from '@/remote/activitypub/renderer/question.js';
import renderCreate from '@/remote/activitypub/renderer/create.js';
import { renderActivity } from '@/remote/activitypub/renderer/index.js';
import renderFollow from '@/remote/activitypub/renderer/follow.js';
export default class Resolver {
private history: Set<string>;
@ -40,14 +49,25 @@ export default class Resolver {
return value;
}
if (value.includes('#')) {
// URLs with fragment parts cannot be resolved correctly because
// the fragment part does not get transmitted over HTTP(S).
// Avoid strange behaviour by not trying to resolve these at all.
throw new Error(`cannot resolve URL with fragment: ${value}`);
}
if (this.history.has(value)) {
throw new Error('cannot resolve already resolved one');
}
this.history.add(value);
const meta = await fetchMeta();
const host = extractDbHost(value);
if (isSelfHost(host)) {
return await this.resolveLocal(value);
}
const meta = await fetchMeta();
if (meta.blockedHosts.includes(host)) {
throw new Error('Instance is blocked');
}
@ -70,4 +90,44 @@ export default class Resolver {
return object;
}
private resolveLocal(url: string): Promise<IObject> {
const parsed = parseUri(url);
if (!parsed.local) throw new Error('resolveLocal: not local');
switch (parsed.type) {
case 'notes':
return Notes.findOneByOrFail({ id: parsed.id })
.then(note => {
if (parsed.rest === 'activity') {
// this refers to the create activity and not the note itself
return renderActivity(renderCreate(renderNote(note)));
} else {
return renderNote(note);
}
});
case 'users':
return Users.findOneByOrFail({ id: parsed.id })
.then(user => renderPerson(user as ILocalUser));
case 'questions':
// Polls are indexed by the note they are attached to.
return Promise.all([
Notes.findOneByOrFail({ id: parsed.id }),
Polls.findOneByOrFail({ noteId: parsed.id }),
])
.then(([note, poll]) => renderQuestion({ id: note.userId }, note, poll));
case 'likes':
return NoteReactions.findOneByOrFail({ id: parsed.id }).then(reaction => renderActivity(renderLike(reaction, { uri: null })));
case 'follows':
// rest should be <followee id>
if (parsed.rest == null || !/^\w+$/.test(parsed.rest)) throw new Error('resolveLocal: invalid follow URI');
return Promise.all(
[parsed.id, parsed.rest].map(id => Users.findOneByOrFail({ id }))
)
.then(([follower, followee]) => renderActivity(renderFollow(follower, followee, url)));
default:
throw new Error(`resolveLocal: type ${type} unhandled`);
}
}
}

View File

@ -106,7 +106,10 @@ export const isPost = (object: IObject): object is IPost =>
export interface IPost extends IObject {
type: 'Note' | 'Question' | 'Article' | 'Audio' | 'Document' | 'Image' | 'Page' | 'Video' | 'Event';
_misskey_content?: string;
source?: {
content: string;
mediaType: string;
};
_misskey_quote?: string;
quoteUrl?: string;
_misskey_talk: boolean;
@ -114,7 +117,10 @@ export interface IPost extends IObject {
export interface IQuestion extends IObject {
type: 'Note' | 'Question';
_misskey_content?: string;
source?: {
content: string;
mediaType: string;
};
_misskey_quote?: string;
quoteUrl?: string;
oneOf?: IQuestionChoice[];

View File

@ -15,9 +15,10 @@ import { inbox as processInbox } from '@/queue/index.js';
import { isSelfHost } from '@/misc/convert-host.js';
import { Notes, Users, Emojis, NoteReactions } from '@/models/index.js';
import { ILocalUser, User } from '@/models/entities/user.js';
import { In, IsNull } from 'typeorm';
import { In, IsNull, Not } from 'typeorm';
import { renderLike } from '@/remote/activitypub/renderer/like.js';
import { getUserKeypair } from '@/misc/keypair-store.js';
import renderFollow from '@/remote/activitypub/renderer/follow.js';
// Init router
const router = new Router();
@ -224,4 +225,30 @@ router.get('/likes/:like', async ctx => {
setResponseType(ctx);
});
// follow
router.get('/follows/:follower/:followee', async ctx => {
// This may be used before the follow is completed, so we do not
// check if the following exists.
const [follower, followee] = await Promise.all([
Users.findOneBy({
id: ctx.params.follower,
host: IsNull(),
}),
Users.findOneBy({
id: ctx.params.followee,
host: Not(IsNull()),
}),
]);
if (follower == null || followee == null) {
ctx.status = 404;
return;
}
ctx.body = renderActivity(renderFollow(follower, followee));
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
});
export default router;

View File

@ -2,10 +2,11 @@ import Koa from 'koa';
import { performance } from 'perf_hooks';
import { limiter } from './limiter.js';
import { CacheableLocalUser, User } from '@/models/entities/user.js';
import endpoints, { IEndpoint } from './endpoints.js';
import endpoints, { IEndpointMeta } from './endpoints.js';
import { ApiError } from './error.js';
import { apiLogger } from './logger.js';
import { AccessToken } from '@/models/entities/access-token.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
const accessDenied = {
message: 'Access denied.',
@ -15,6 +16,7 @@ const accessDenied = {
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 isModerator = user != null && (user.isModerator || user.isAdmin);
const ep = endpoints.find(e => e.name === endpoint);
@ -31,6 +33,32 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
throw new ApiError(accessDenied);
}
if (ep.meta.limit && !isModerator) {
// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
let limitActor: string;
if (user) {
limitActor = user.id;
} else {
limitActor = getIpHash(ctx!.ip);
}
const limit = Object.assign({}, ep.meta.limit);
if (!limit.key) {
limit.key = ep.name;
}
// Rate limit
await limiter(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor).catch(e => {
throw new ApiError({
message: 'Rate limit exceeded. Please try again later.',
code: 'RATE_LIMIT_EXCEEDED',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
httpStatusCode: 429,
});
});
}
if (ep.meta.requireCredential && user == null) {
throw new ApiError({
message: 'Credential required.',
@ -53,7 +81,7 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
throw new ApiError(accessDenied, { reason: 'You are not the admin.' });
}
if (ep.meta.requireModerator && !user!.isAdmin && !user!.isModerator) {
if (ep.meta.requireModerator && !isModerator) {
throw new ApiError(accessDenied, { reason: 'You are not a moderator.' });
}
@ -65,18 +93,6 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
});
}
if (ep.meta.requireCredential && ep.meta.limit && !user!.isAdmin && !user!.isModerator) {
// Rate limit
await limiter(ep as IEndpoint & { meta: { limit: NonNullable<IEndpoint['meta']['limit']> } }, user!).catch(e => {
throw new ApiError({
message: 'Rate limit exceeded. Please try again later.',
code: 'RATE_LIMIT_EXCEEDED',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
httpStatusCode: 429,
});
});
}
// Cast non JSON input
if (ep.meta.requireFile && ep.params.properties) {
for (const k of Object.keys(ep.params.properties)) {

View File

@ -654,7 +654,6 @@ export interface IEndpointMeta {
/**
*
*
* withCredential false
*/
readonly limit?: {

View File

@ -1,5 +1,5 @@
import { Signins, UserProfiles, Users } from '@/models/index.js';
import define from '../../define.js';
import { Users } from '@/models/index.js';
export const meta = {
tags: ['admin'],
@ -23,9 +23,12 @@ export const paramDef = {
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, me) => {
const user = await Users.findOneBy({ id: ps.userId });
const [user, profile] = await Promise.all([
Users.findOneBy({ id: ps.userId }),
UserProfiles.findOneBy({ userId: ps.userId })
]);
if (user == null) {
if (user == null || profile == null) {
throw new Error('user not found');
}
@ -34,8 +37,37 @@ export default define(meta, paramDef, async (ps, me) => {
throw new Error('cannot show info of admin');
}
if (!_me.isAdmin) {
return {
isModerator: user.isModerator,
isSilenced: user.isSilenced,
isSuspended: user.isSuspended,
};
}
const maskedKeys = ['accessToken', 'accessTokenSecret', 'refreshToken'];
Object.keys(profile.integrations).forEach(integration => {
maskedKeys.forEach(key => profile.integrations[integration][key] = '<MASKED>');
});
const signins = await Signins.findBy({ userId: user.id });
return {
...user,
token: user.token != null ? '<MASKED>' : user.token,
email: profile.email,
emailVerified: profile.emailVerified,
autoAcceptFollowed: profile.autoAcceptFollowed,
noCrawle: profile.noCrawle,
alwaysMarkNsfw: profile.alwaysMarkNsfw,
carefulBot: profile.carefulBot,
injectFeaturedNote: profile.injectFeaturedNote,
receiveAnnouncementEmail: profile.receiveAnnouncementEmail,
integrations: profile.integrations,
mutedWords: profile.mutedWords,
mutedInstances: profile.mutedInstances,
mutingNotificationTypes: profile.mutingNotificationTypes,
isModerator: user.isModerator,
isSilenced: user.isSilenced,
isSuspended: user.isSuspended,
signins,
};
});

View File

@ -9,6 +9,8 @@ export const meta = {
kind: 'read:drive',
description: 'Find the notes to which the given file is attached.',
res: {
type: 'array',
optional: false, nullable: false,

View File

@ -8,6 +8,8 @@ export const meta = {
kind: 'read:drive',
description: 'Check if a given file exists.',
res: {
type: 'boolean',
optional: false, nullable: false,

View File

@ -20,6 +20,8 @@ export const meta = {
kind: 'write:drive',
description: 'Upload a new drive file.',
res: {
type: 'object',
optional: false, nullable: false,

View File

@ -11,6 +11,8 @@ export const meta = {
kind: 'write:drive',
description: 'Delete an existing drive file.',
errors: {
noSuchFile: {
message: 'No such file.',

View File

@ -8,6 +8,8 @@ export const meta = {
kind: 'read:drive',
description: 'Search for a drive file by a hash of the contents.',
res: {
type: 'array',
optional: false, nullable: false,

View File

@ -9,6 +9,8 @@ export const meta = {
kind: 'read:drive',
description: 'Search for a drive file by the given parameters.',
res: {
type: 'array',
optional: false, nullable: false,

View File

@ -10,6 +10,8 @@ export const meta = {
kind: 'read:drive',
description: 'Show the properties of a drive file.',
res: {
type: 'object',
optional: false, nullable: false,

View File

@ -11,6 +11,8 @@ export const meta = {
kind: 'write:drive',
description: 'Update the properties of a drive file.',
errors: {
invalidFileName: {
message: 'Invalid file name.',

View File

@ -13,6 +13,8 @@ export const meta = {
max: 60,
},
description: 'Request the server to download a new drive file from the specified URL.',
requireCredential: true,
kind: 'write:drive',

View File

@ -134,7 +134,7 @@ export const paramDef = {
{
// (re)note with text, files and poll are optional
properties: {
text: { type: 'string', maxLength: MAX_NOTE_TEXT_LENGTH, nullable: false },
text: { type: 'string', minLength: 1, maxLength: MAX_NOTE_TEXT_LENGTH, nullable: false },
},
required: ['text'],
},

View File

@ -10,8 +10,12 @@ import { genId } from '@/misc/gen-id.js';
import { IsNull } from 'typeorm';
export const meta = {
tags: ['reset password'],
requireCredential: false,
description: 'Request a users password to be reset.',
limit: {
duration: ms('1hour'),
max: 3,

View File

@ -3,8 +3,12 @@ import { ApiError } from '../error.js';
import { resetDb } from '@/db/postgre.js';
export const meta = {
tags: ['non-productive'],
requireCredential: false,
description: 'Only available when running with <code>NODE_ENV=testing</code>. Reset the database and flush Redis.',
errors: {
},

View File

@ -5,8 +5,12 @@ import { Users, UserProfiles, PasswordResetRequests } from '@/models/index.js';
import { ApiError } from '../error.js';
export const meta = {
tags: ['reset password'],
requireCredential: false,
description: 'Complete the password reset that was previously requested.',
errors: {
},

View File

@ -8,6 +8,8 @@ export const meta = {
requireCredential: true,
description: 'Register to receive push notifications.',
res: {
type: 'object',
optional: false, nullable: false,

View File

@ -5,6 +5,8 @@ export const meta = {
tags: ['account'],
requireCredential: true,
description: 'Unregister from receiving push notifications.',
} as const;
export const paramDef = {

View File

@ -1,6 +1,10 @@
import define from '../define.js';
export const meta = {
tags: ['non-productive'],
description: 'Endpoint for testing input validation.',
requireCredential: false,
} as const;

View File

@ -4,6 +4,18 @@ import { makePaginationQuery } from '../../common/make-pagination-query.js';
export const meta = {
tags: ['users', 'clips'],
description: 'Show all clips this user owns.',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'Clip',
},
},
} as const;
export const paramDef = {

View File

@ -10,6 +10,8 @@ export const meta = {
requireCredential: false,
description: 'Show everyone that follows this user.',
res: {
type: 'array',
optional: false, nullable: false,

View File

@ -10,6 +10,8 @@ export const meta = {
requireCredential: false,
description: 'Show everyone that this user is following.',
res: {
type: 'array',
optional: false, nullable: false,

View File

@ -4,6 +4,18 @@ import { makePaginationQuery } from '../../../common/make-pagination-query.js';
export const meta = {
tags: ['users', 'gallery'],
description: 'Show all gallery posts by the given user.',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'GalleryPost',
},
},
} as const;
export const paramDef = {

View File

@ -10,6 +10,8 @@ export const meta = {
requireCredential: false,
description: 'Get a list of other users that the specified user frequently replies to.',
res: {
type: 'array',
optional: false, nullable: false,

View File

@ -11,6 +11,8 @@ export const meta = {
kind: 'write:user-groups',
description: 'Create a new group.',
res: {
type: 'object',
optional: false, nullable: false,

View File

@ -9,6 +9,8 @@ export const meta = {
kind: 'write:user-groups',
description: 'Delete an existing group.',
errors: {
noSuchGroup: {
message: 'No such group.',

View File

@ -11,6 +11,8 @@ export const meta = {
kind: 'write:user-groups',
description: 'Join a group the authenticated user has been invited to.',
errors: {
noSuchInvitation: {
message: 'No such invitation.',

View File

@ -9,6 +9,8 @@ export const meta = {
kind: 'write:user-groups',
description: 'Delete an existing group invitation for the authenticated user without joining the group.',
errors: {
noSuchInvitation: {
message: 'No such invitation.',

View File

@ -13,6 +13,8 @@ export const meta = {
kind: 'write:user-groups',
description: 'Invite a user to an existing group.',
errors: {
noSuchGroup: {
message: 'No such group.',

View File

@ -9,6 +9,8 @@ export const meta = {
kind: 'read:user-groups',
description: 'List the groups that the authenticated user is a member of.',
res: {
type: 'array',
optional: false, nullable: false,

View File

@ -9,6 +9,8 @@ export const meta = {
kind: 'write:user-groups',
description: 'Leave a group. The owner of a group can not leave. They must transfer ownership or delete the group instead.',
errors: {
noSuchGroup: {
message: 'No such group.',

View File

@ -8,6 +8,8 @@ export const meta = {
kind: 'read:user-groups',
description: 'List the groups that the authenticated user is the owner of.',
res: {
type: 'array',
optional: false, nullable: false,

View File

@ -10,6 +10,8 @@ export const meta = {
kind: 'write:user-groups',
description: 'Removes a specified user from a group. The owner can not be removed.',
errors: {
noSuchGroup: {
message: 'No such group.',

View File

@ -9,6 +9,8 @@ export const meta = {
kind: 'read:user-groups',
description: 'Show the properties of a group.',
res: {
type: 'object',
optional: false, nullable: false,

View File

@ -10,6 +10,8 @@ export const meta = {
kind: 'write:user-groups',
description: 'Transfer ownership of a group from the authenticated user to another user.',
res: {
type: 'object',
optional: false, nullable: false,

View File

@ -9,6 +9,8 @@ export const meta = {
kind: 'write:user-groups',
description: 'Update the properties of a group.',
res: {
type: 'object',
optional: false, nullable: false,

View File

@ -10,6 +10,8 @@ export const meta = {
kind: 'write:account',
description: 'Create a new list of users.',
res: {
type: 'object',
optional: false, nullable: false,

View File

@ -9,6 +9,8 @@ export const meta = {
kind: 'write:account',
description: 'Delete an existing list of users.',
errors: {
noSuchList: {
message: 'No such list.',

View File

@ -8,6 +8,8 @@ export const meta = {
kind: 'read:account',
description: 'Show all lists that the authenticated user has created.',
res: {
type: 'array',
optional: false, nullable: false,

View File

@ -11,6 +11,8 @@ export const meta = {
kind: 'write:account',
description: 'Remove a user from a list.',
errors: {
noSuchList: {
message: 'No such list.',

View File

@ -11,6 +11,8 @@ export const meta = {
kind: 'write:account',
description: 'Add a user to an existing list.',
errors: {
noSuchList: {
message: 'No such list.',

View File

@ -9,6 +9,8 @@ export const meta = {
kind: 'read:account',
description: 'Show the properties of a list.',
res: {
type: 'object',
optional: false, nullable: false,

View File

@ -9,6 +9,8 @@ export const meta = {
kind: 'write:account',
description: 'Update the properties of a list.',
res: {
type: 'object',
optional: false, nullable: false,

View File

@ -12,6 +12,8 @@ import { generateMutedInstanceQuery } from '../../common/generate-muted-instance
export const meta = {
tags: ['users', 'notes'],
description: 'Show all notes that this user created.',
res: {
type: 'array',
optional: false, nullable: false,

View File

@ -4,6 +4,18 @@ import { makePaginationQuery } from '../../common/make-pagination-query.js';
export const meta = {
tags: ['users', 'pages'],
description: 'Show all pages this user created.',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'Page',
},
},
} as const;
export const paramDef = {

View File

@ -9,6 +9,8 @@ export const meta = {
requireCredential: false,
description: 'Show all reactions this user made.',
res: {
type: 'array',
optional: false, nullable: false,

View File

@ -11,6 +11,8 @@ export const meta = {
kind: 'read:account',
description: 'Show users that the authenticated user might be interested to follow.',
res: {
type: 'array',
optional: false, nullable: false,

View File

@ -6,6 +6,8 @@ export const meta = {
requireCredential: true,
description: 'Show the different kinds of relations between the authenticated user and the specified user(s).',
res: {
optional: false, nullable: false,
oneOf: [

View File

@ -13,6 +13,8 @@ export const meta = {
requireCredential: true,
description: 'File a report.',
errors: {
noSuchUser: {
message: 'No such user.',

View File

@ -9,6 +9,8 @@ export const meta = {
requireCredential: false,
description: 'Search for a user by username and/or host.',
res: {
type: 'array',
optional: false, nullable: false,

View File

@ -8,6 +8,8 @@ export const meta = {
requireCredential: false,
description: 'Search for users.',
res: {
type: 'array',
optional: false, nullable: false,

View File

@ -11,6 +11,8 @@ export const meta = {
requireCredential: false,
description: 'Show the properties of a user.',
res: {
optional: false, nullable: false,
oneOf: [

View File

@ -1,12 +1,15 @@
import define from '../../define.js';
import { ApiError } from '../../error.js';
import { DriveFiles, Followings, NoteFavorites, NoteReactions, Notes, PageLikes, PollVotes, Users } from '@/models/index.js';
import { awaitAll } from '@/prelude/await-all.js';
export const meta = {
tags: ['users'],
requireCredential: false,
description: 'Show statistics about a user.',
errors: {
noSuchUser: {
message: 'No such user.',
@ -14,6 +17,94 @@ export const meta = {
id: '9e638e45-3b25-4ef7-8f95-07e8498f1819',
},
},
res: {
type: 'object',
optional: false, nullable: false,
properties: {
notesCount: {
type: 'integer',
optional: false, nullable: false,
},
repliesCount: {
type: 'integer',
optional: false, nullable: false,
},
renotesCount: {
type: 'integer',
optional: false, nullable: false,
},
repliedCount: {
type: 'integer',
optional: false, nullable: false,
},
renotedCount: {
type: 'integer',
optional: false, nullable: false,
},
pollVotesCount: {
type: 'integer',
optional: false, nullable: false,
},
pollVotedCount: {
type: 'integer',
optional: false, nullable: false,
},
localFollowingCount: {
type: 'integer',
optional: false, nullable: false,
},
remoteFollowingCount: {
type: 'integer',
optional: false, nullable: false,
},
localFollowersCount: {
type: 'integer',
optional: false, nullable: false,
},
remoteFollowersCount: {
type: 'integer',
optional: false, nullable: false,
},
followingCount: {
type: 'integer',
optional: false, nullable: false,
},
followersCount: {
type: 'integer',
optional: false, nullable: false,
},
sentReactionsCount: {
type: 'integer',
optional: false, nullable: false,
},
receivedReactionsCount: {
type: 'integer',
optional: false, nullable: false,
},
noteFavoritesCount: {
type: 'integer',
optional: false, nullable: false,
},
pageLikesCount: {
type: 'integer',
optional: false, nullable: false,
},
pageLikedCount: {
type: 'integer',
optional: false, nullable: false,
},
driveFilesCount: {
type: 'integer',
optional: false, nullable: false,
},
driveUsage: {
type: 'integer',
optional: false, nullable: false,
description: 'Drive usage in bytes',
},
},
},
} as const;
export const paramDef = {
@ -31,109 +122,72 @@ export default define(meta, paramDef, async (ps, me) => {
throw new ApiError(meta.errors.noSuchUser);
}
const [
notesCount,
repliesCount,
renotesCount,
repliedCount,
renotedCount,
pollVotesCount,
pollVotedCount,
localFollowingCount,
remoteFollowingCount,
localFollowersCount,
remoteFollowersCount,
sentReactionsCount,
receivedReactionsCount,
noteFavoritesCount,
pageLikesCount,
pageLikedCount,
driveFilesCount,
driveUsage,
] = await Promise.all([
Notes.createQueryBuilder('note')
const result = await awaitAll({
notesCount: Notes.createQueryBuilder('note')
.where('note.userId = :userId', { userId: user.id })
.getCount(),
Notes.createQueryBuilder('note')
repliesCount: Notes.createQueryBuilder('note')
.where('note.userId = :userId', { userId: user.id })
.andWhere('note.replyId IS NOT NULL')
.getCount(),
Notes.createQueryBuilder('note')
renotesCount: Notes.createQueryBuilder('note')
.where('note.userId = :userId', { userId: user.id })
.andWhere('note.renoteId IS NOT NULL')
.getCount(),
Notes.createQueryBuilder('note')
repliedCount: Notes.createQueryBuilder('note')
.where('note.replyUserId = :userId', { userId: user.id })
.getCount(),
Notes.createQueryBuilder('note')
renotedCount: Notes.createQueryBuilder('note')
.where('note.renoteUserId = :userId', { userId: user.id })
.getCount(),
PollVotes.createQueryBuilder('vote')
pollVotesCount: PollVotes.createQueryBuilder('vote')
.where('vote.userId = :userId', { userId: user.id })
.getCount(),
PollVotes.createQueryBuilder('vote')
pollVotedCount: PollVotes.createQueryBuilder('vote')
.innerJoin('vote.note', 'note')
.where('note.userId = :userId', { userId: user.id })
.getCount(),
Followings.createQueryBuilder('following')
localFollowingCount: Followings.createQueryBuilder('following')
.where('following.followerId = :userId', { userId: user.id })
.andWhere('following.followeeHost IS NULL')
.getCount(),
Followings.createQueryBuilder('following')
remoteFollowingCount: Followings.createQueryBuilder('following')
.where('following.followerId = :userId', { userId: user.id })
.andWhere('following.followeeHost IS NOT NULL')
.getCount(),
Followings.createQueryBuilder('following')
localFollowersCount: Followings.createQueryBuilder('following')
.where('following.followeeId = :userId', { userId: user.id })
.andWhere('following.followerHost IS NULL')
.getCount(),
Followings.createQueryBuilder('following')
remoteFollowersCount: Followings.createQueryBuilder('following')
.where('following.followeeId = :userId', { userId: user.id })
.andWhere('following.followerHost IS NOT NULL')
.getCount(),
NoteReactions.createQueryBuilder('reaction')
sentReactionsCount: NoteReactions.createQueryBuilder('reaction')
.where('reaction.userId = :userId', { userId: user.id })
.getCount(),
NoteReactions.createQueryBuilder('reaction')
receivedReactionsCount: NoteReactions.createQueryBuilder('reaction')
.innerJoin('reaction.note', 'note')
.where('note.userId = :userId', { userId: user.id })
.getCount(),
NoteFavorites.createQueryBuilder('favorite')
noteFavoritesCount: NoteFavorites.createQueryBuilder('favorite')
.where('favorite.userId = :userId', { userId: user.id })
.getCount(),
PageLikes.createQueryBuilder('like')
pageLikesCount: PageLikes.createQueryBuilder('like')
.where('like.userId = :userId', { userId: user.id })
.getCount(),
PageLikes.createQueryBuilder('like')
pageLikedCount: PageLikes.createQueryBuilder('like')
.innerJoin('like.page', 'page')
.where('page.userId = :userId', { userId: user.id })
.getCount(),
DriveFiles.createQueryBuilder('file')
driveFilesCount: DriveFiles.createQueryBuilder('file')
.where('file.userId = :userId', { userId: user.id })
.getCount(),
DriveFiles.calcDriveUsageOf(user),
]);
driveUsage: DriveFiles.calcDriveUsageOf(user),
});
return {
notesCount,
repliesCount,
renotesCount,
repliedCount,
renotedCount,
pollVotesCount,
pollVotedCount,
localFollowingCount,
remoteFollowingCount,
localFollowersCount,
remoteFollowersCount,
followingCount: localFollowingCount + remoteFollowingCount,
followersCount: localFollowersCount + remoteFollowersCount,
sentReactionsCount,
receivedReactionsCount,
noteFavoritesCount,
pageLikesCount,
pageLikedCount,
driveFilesCount,
driveUsage,
};
result.followingCount = result.localFollowingCount + result.remoteFollowingCount;
result.followersCount = result.localFollowersCount + result.remoteFollowersCount;
return result;
});

View File

@ -1,25 +1,17 @@
import Limiter from 'ratelimiter';
import { redisClient } from '../../db/redis.js';
import { IEndpoint } from './endpoints.js';
import * as Acct from '@/misc/acct.js';
import { IEndpointMeta } from './endpoints.js';
import { CacheableLocalUser, User } from '@/models/entities/user.js';
import Logger from '@/services/logger.js';
const logger = new Logger('limiter');
export const limiter = (endpoint: IEndpoint & { meta: { limit: NonNullable<IEndpoint['meta']['limit']> } }, user: CacheableLocalUser) => new Promise<void>((ok, reject) => {
const limitation = endpoint.meta.limit;
const key = Object.prototype.hasOwnProperty.call(limitation, 'key')
? limitation.key
: endpoint.name;
const hasShortTermLimit =
Object.prototype.hasOwnProperty.call(limitation, 'minInterval');
export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string) => new Promise<void>((ok, reject) => {
const hasShortTermLimit = typeof limitation.minInterval === 'number';
const hasLongTermLimit =
Object.prototype.hasOwnProperty.call(limitation, 'duration') &&
Object.prototype.hasOwnProperty.call(limitation, 'max');
typeof limitation.duration === 'number' &&
typeof limitation.max === 'number';
if (hasShortTermLimit) {
min();
@ -32,7 +24,7 @@ export const limiter = (endpoint: IEndpoint & { meta: { limit: NonNullable<IEndp
// Short-term limit
function min(): void {
const minIntervalLimiter = new Limiter({
id: `${user.id}:${key}:min`,
id: `${actor}:${limitation.key}:min`,
duration: limitation.minInterval,
max: 1,
db: redisClient,
@ -43,7 +35,7 @@ export const limiter = (endpoint: IEndpoint & { meta: { limit: NonNullable<IEndp
return reject('ERR');
}
logger.debug(`@${Acct.toString(user)} ${endpoint.name} min remaining: ${info.remaining}`);
logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
if (info.remaining === 0) {
reject('BRIEF_REQUEST_INTERVAL');
@ -60,7 +52,7 @@ export const limiter = (endpoint: IEndpoint & { meta: { limit: NonNullable<IEndp
// Long term limit
function max(): void {
const limiter = new Limiter({
id: `${user.id}:${key}`,
id: `${actor}:${limitation.key}`,
duration: limitation.duration,
max: limitation.max,
db: redisClient,
@ -71,7 +63,7 @@ export const limiter = (endpoint: IEndpoint & { meta: { limit: NonNullable<IEndp
return reject('ERR');
}
logger.debug(`@${Acct.toString(user)} ${endpoint.name} max remaining: ${info.remaining}`);
logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
if (info.remaining === 0) {
reject('RATE_LIMIT_EXCEEDED');

View File

@ -1,25 +1,22 @@
import { randomBytes } from 'node:crypto';
import Koa from 'koa';
import bcrypt from 'bcryptjs';
import * as speakeasy from 'speakeasy';
import { IsNull } from 'typeorm';
import signin from '../common/signin.js';
import config from '@/config/index.js';
import { Users, Signins, UserProfiles, UserSecurityKeys, AttestationChallenges } from '@/models/index.js';
import { ILocalUser } from '@/models/entities/user.js';
import { genId } from '@/misc/gen-id.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { verifyHcaptcha, verifyRecaptcha } from '@/misc/captcha.js';
import { verifyLogin, hash } from '../2fa.js';
import signin from '../common/signin.js';
import { randomBytes } from 'node:crypto';
import { IsNull } from 'typeorm';
import { limiter } from '../limiter.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
export default async (ctx: Koa.Context) => {
ctx.set('Access-Control-Allow-Origin', config.url);
ctx.set('Access-Control-Allow-Credentials', 'true');
const body = ctx.request.body as any;
const instance = await fetchMeta(true);
const username = body['username'];
const password = body['password'];
const token = body['token'];
@ -29,6 +26,21 @@ export default async (ctx: Koa.Context) => {
ctx.body = { error };
}
try {
// not more than 1 attempt per second and not more than 10 attempts per hour
await limiter({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(ctx.ip));
} catch (err) {
ctx.status = 429;
ctx.body = {
error: {
message: 'Too many failed attempts to sign in. Try again later.',
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
},
};
return;
}
if (typeof username !== 'string') {
ctx.status = 400;
return;
@ -84,18 +96,6 @@ export default async (ctx: Koa.Context) => {
}
if (!profile.twoFactorEnabled) {
if (instance.enableHcaptcha && instance.hcaptchaSecretKey) {
await verifyHcaptcha(instance.hcaptchaSecretKey, body['hcaptcha-response']).catch(e => {
ctx.throw(400, e);
});
}
if (instance.enableRecaptcha && instance.recaptchaSecretKey) {
await verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(e => {
ctx.throw(400, e);
});
}
if (same) {
signin(ctx, user);
return;
@ -172,7 +172,7 @@ export default async (ctx: Koa.Context) => {
body.credentialId
.replace(/-/g, '+')
.replace(/_/g, '/'),
'base64',
'base64'
).toString('hex'),
});

View File

@ -54,14 +54,10 @@
//#endregion
//#region Script
const salt = localStorage.getItem('salt')
? `?salt=${localStorage.getItem('salt')}`
: '';
import(`/assets/${CLIENT_ENTRY}${salt}`)
.catch(async () => {
import(`/assets/${CLIENT_ENTRY}`)
.catch(async e => {
await checkUpdate();
renderError('APP_FETCH_FAILED');
renderError('APP_FETCH_FAILED', JSON.stringify(e));
})
//#endregion
@ -142,9 +138,6 @@
// eslint-disable-next-line no-inner-declarations
function refresh() {
// Random
localStorage.setItem('salt', Math.random().toString().substr(2, 8));
// Clear cache (service worker)
try {
navigator.serviceWorker.controller.postMessage('clear');

View File

@ -74,9 +74,9 @@ app.use(views(_dirname + '/views', {
extension: 'pug',
options: {
version: config.version,
clientEntry: () => process.env.NODE_ENV === 'production' ?
getClientEntry: () => process.env.NODE_ENV === 'production' ?
config.clientEntry :
JSON.parse(readFileSync(`${_dirname}/../../../../../built/_client_dist_/manifest.json`, 'utf-8'))['src/init.ts'].file.replace(/^_client_dist_\//, ''),
JSON.parse(readFileSync(`${_dirname}/../../../../../built/_client_dist_/manifest.json`, 'utf-8'))['src/init.ts'],
config,
},
}));
@ -247,7 +247,7 @@ router.get(['/@:user', '/@:user/:sub'], async (ctx, next) => {
icon: meta.iconUrl,
themeColor: meta.themeColor,
});
ctx.set('Cache-Control', 'public, max-age=30');
ctx.set('Cache-Control', 'public, max-age=15');
} else {
// リモートユーザーなので
// モデレータがAPI経由で参照可能にするために404にはしない
@ -292,7 +292,7 @@ router.get('/notes/:note', async (ctx, next) => {
themeColor: meta.themeColor,
});
ctx.set('Cache-Control', 'public, max-age=180');
ctx.set('Cache-Control', 'public, max-age=15');
return;
}
@ -329,7 +329,7 @@ router.get('/@:user/pages/:page', async (ctx, next) => {
});
if (['public'].includes(page.visibility)) {
ctx.set('Cache-Control', 'public, max-age=180');
ctx.set('Cache-Control', 'public, max-age=15');
} else {
ctx.set('Cache-Control', 'private, max-age=0, must-revalidate');
}
@ -360,7 +360,7 @@ router.get('/clips/:clip', async (ctx, next) => {
themeColor: meta.themeColor,
});
ctx.set('Cache-Control', 'public, max-age=180');
ctx.set('Cache-Control', 'public, max-age=15');
return;
}
@ -385,7 +385,7 @@ router.get('/gallery/:post', async (ctx, next) => {
themeColor: meta.themeColor,
});
ctx.set('Cache-Control', 'public, max-age=180');
ctx.set('Cache-Control', 'public, max-age=15');
return;
}
@ -409,7 +409,7 @@ router.get('/channels/:channel', async (ctx, next) => {
themeColor: meta.themeColor,
});
ctx.set('Cache-Control', 'public, max-age=180');
ctx.set('Cache-Control', 'public, max-age=15');
return;
}
@ -468,7 +468,7 @@ router.get('(.*)', async ctx => {
icon: meta.iconUrl,
themeColor: meta.themeColor,
});
ctx.set('Cache-Control', 'public, max-age=300');
ctx.set('Cache-Control', 'public, max-age=15');
});
// Register router

View File

@ -3,7 +3,9 @@ import { fetchMeta } from '@/misc/fetch-meta.js';
import manifest from './manifest.json' assert { type: 'json' };
export const manifestHandler = async (ctx: Koa.Context) => {
const res = structuredClone(manifest);
// TODO
//const res = structuredClone(manifest);
const res = JSON.parse(JSON.stringify(manifest));
const instance = await fetchMeta(true);

View File

@ -1,17 +1,21 @@
block vars
block loadClientEntry
- const clientEntry = getClientEntry();
doctype html
!= '<!--\n'
!= ' _____ _ _ \n'
!= ' | |_|___ ___| |_ ___ _ _ \n'
!= ' | | | | |_ -|_ -| \'_| -_| | |\n'
!= ' |_|_|_|_|___|___|_,_|___|_ |\n'
!= ' |___|\n'
!= ' Thank you for using Misskey!\n'
!= ' If you are reading this message... how about joining the development?\n'
!= ' https://github.com/misskey-dev/misskey'
!= '\n-->\n'
//
-
_____ _ _
| |_|___ ___| |_ ___ _ _
| | | | |_ -|_ -| '_| -_| | |
|_|_|_|_|___|___|_,_|___|_ |
|___|
Thank you for using Misskey!
If you are reading this message... how about joining the development?
https://github.com/misskey-dev/misskey
html
@ -30,8 +34,14 @@ html
link(rel='prefetch' href='https://xn--931a.moe/assets/info.jpg')
link(rel='prefetch' href='https://xn--931a.moe/assets/not-found.jpg')
link(rel='prefetch' href='https://xn--931a.moe/assets/error.jpg')
link(rel='preload' href='/assets/fontawesome/css/all.css' as='style')
link(rel='stylesheet' href='/assets/fontawesome/css/all.css')
link(rel='modulepreload' href=`/assets/${clientEntry.file}`)
each href in clientEntry.css
link(rel='preload' href=`/assets/${href}` as='style')
each href in clientEntry.css
link(rel='preload' href=`/assets/${href}` as='style')
title
block title
@ -52,7 +62,7 @@ html
script.
var VERSION = "#{version}";
var CLIENT_ENTRY = "#{clientEntry()}";
var CLIENT_ENTRY = "#{clientEntry.file}";
script
include ../boot.js

View File

@ -2,9 +2,10 @@ import { publishMainStream, publishUserEvent } from '@/services/stream.js';
import { renderActivity } from '@/remote/activitypub/renderer/index.js';
import renderFollow from '@/remote/activitypub/renderer/follow.js';
import renderUndo from '@/remote/activitypub/renderer/undo.js';
import renderBlock from '@/remote/activitypub/renderer/block.js';
import { renderBlock } from '@/remote/activitypub/renderer/block.js';
import { deliver } from '@/queue/index.js';
import renderReject from '@/remote/activitypub/renderer/reject.js';
import { Blocking } from '@/models/entities/blocking.js';
import { User } from '@/models/entities/user.js';
import { Blockings, Users, FollowRequests, Followings, UserListJoinings, UserLists } from '@/models/index.js';
import { perUserFollowingChart } from '@/services/chart/index.js';
@ -22,15 +23,19 @@ export default async function(blocker: User, blockee: User) {
removeFromList(blockee, blocker),
]);
await Blockings.insert({
const blocking = {
id: genId(),
createdAt: new Date(),
blocker,
blockerId: blocker.id,
blockee,
blockeeId: blockee.id,
});
} as Blocking;
await Blockings.insert(blocking);
if (Users.isLocalUser(blocker) && Users.isRemoteUser(blockee)) {
const content = renderActivity(renderBlock(blocker, blockee));
const content = renderActivity(renderBlock(blocking));
deliver(blocker, content, blockee.inbox);
}
}

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