Merge branch 'develop' into fix-msg-room
This commit is contained in:
commit
312e2a022c
5
.github/ISSUE_TEMPLATE/01_bug-report.md
vendored
5
.github/ISSUE_TEMPLATE/01_bug-report.md
vendored
@ -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
4
.github/labeler.yml
vendored
@ -4,5 +4,9 @@
|
||||
'🖥️Client':
|
||||
- packages/client/**/*
|
||||
|
||||
'🧪Test':
|
||||
- cypress/**/*
|
||||
- packages/backend/test/**/*
|
||||
|
||||
'‼️ wrong locales':
|
||||
- any: ['locales/*.yml', '!locales/ja-JP.yml']
|
||||
|
4
.github/workflows/labeler.yml
vendored
4
.github/workflows/labeler.yml
vendored
@ -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
36
.github/workflows/ok-to-test.yml
vendored
Normal 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
95
.github/workflows/pr-preview-deploy.yml
vendored
Normal 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;
|
21
.github/workflows/pr-preview-destroy.yml
vendored
Normal file
21
.github/workflows/pr-preview-destroy.yml
vendored
Normal 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
|
@ -1 +1 @@
|
||||
v18.0.0
|
||||
v16.15.0
|
||||
|
52
CHANGELOG.md
52
CHANGELOG.md
@ -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)
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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
12
cypress.config.ts
Normal 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',
|
||||
},
|
||||
})
|
@ -1,3 +0,0 @@
|
||||
{
|
||||
"baseUrl": "http://localhost:61812"
|
||||
}
|
@ -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
65
cypress/e2e/widgets.cy.js
Normal 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');
|
||||
});
|
@ -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');
|
||||
});
|
||||
|
@ -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}秒前"
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,6 @@
|
||||
"loader=./test/loader.js"
|
||||
],
|
||||
"slow": 1000,
|
||||
"timeout": 3000,
|
||||
"timeout": 10000,
|
||||
"exit": true
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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')
|
||||
|
@ -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 = '';
|
||||
|
9
packages/backend/src/misc/get-ip-hash.ts
Normal file
9
packages/backend/src/misc/get-ip-hash.ts
Normal 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);
|
||||
}
|
@ -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];
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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> {
|
||||
|
@ -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', {
|
||||
|
@ -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';
|
||||
|
@ -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
|
||||
};
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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()}`;
|
||||
}
|
||||
|
||||
|
@ -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(),
|
||||
|
@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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[];
|
||||
|
@ -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;
|
||||
|
@ -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)) {
|
||||
|
@ -654,7 +654,6 @@ export interface IEndpointMeta {
|
||||
/**
|
||||
* エンドポイントのリミテーションに関するやつ
|
||||
* 省略した場合はリミテーションは無いものとして解釈されます。
|
||||
* また、withCredential が false の場合はリミテーションを行うことはできません。
|
||||
*/
|
||||
readonly limit?: {
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -8,6 +8,8 @@ export const meta = {
|
||||
|
||||
kind: 'read:drive',
|
||||
|
||||
description: 'Check if a given file exists.',
|
||||
|
||||
res: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
|
@ -20,6 +20,8 @@ export const meta = {
|
||||
|
||||
kind: 'write:drive',
|
||||
|
||||
description: 'Upload a new drive file.',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
|
@ -11,6 +11,8 @@ export const meta = {
|
||||
|
||||
kind: 'write:drive',
|
||||
|
||||
description: 'Delete an existing drive file.',
|
||||
|
||||
errors: {
|
||||
noSuchFile: {
|
||||
message: 'No such 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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -11,6 +11,8 @@ export const meta = {
|
||||
|
||||
kind: 'write:drive',
|
||||
|
||||
description: 'Update the properties of a drive file.',
|
||||
|
||||
errors: {
|
||||
invalidFileName: {
|
||||
message: 'Invalid file name.',
|
||||
|
@ -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',
|
||||
|
@ -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'],
|
||||
},
|
||||
|
@ -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,
|
||||
|
@ -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: {
|
||||
|
||||
},
|
||||
|
@ -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: {
|
||||
|
||||
},
|
||||
|
@ -8,6 +8,8 @@ export const meta = {
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
description: 'Register to receive push notifications.',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
|
@ -5,6 +5,8 @@ export const meta = {
|
||||
tags: ['account'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
description: 'Unregister from receiving push notifications.',
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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 = {
|
||||
|
@ -10,6 +10,8 @@ export const meta = {
|
||||
|
||||
requireCredential: false,
|
||||
|
||||
description: 'Show everyone that follows this user.',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
|
@ -10,6 +10,8 @@ export const meta = {
|
||||
|
||||
requireCredential: false,
|
||||
|
||||
description: 'Show everyone that this user is following.',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
|
@ -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 = {
|
||||
|
@ -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,
|
||||
|
@ -11,6 +11,8 @@ export const meta = {
|
||||
|
||||
kind: 'write:user-groups',
|
||||
|
||||
description: 'Create a new group.',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
|
@ -9,6 +9,8 @@ export const meta = {
|
||||
|
||||
kind: 'write:user-groups',
|
||||
|
||||
description: 'Delete an existing group.',
|
||||
|
||||
errors: {
|
||||
noSuchGroup: {
|
||||
message: 'No such group.',
|
||||
|
@ -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.',
|
||||
|
@ -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.',
|
||||
|
@ -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.',
|
||||
|
@ -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,
|
||||
|
@ -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.',
|
||||
|
@ -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,
|
||||
|
@ -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.',
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -10,6 +10,8 @@ export const meta = {
|
||||
|
||||
kind: 'write:account',
|
||||
|
||||
description: 'Create a new list of users.',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
|
@ -9,6 +9,8 @@ export const meta = {
|
||||
|
||||
kind: 'write:account',
|
||||
|
||||
description: 'Delete an existing list of users.',
|
||||
|
||||
errors: {
|
||||
noSuchList: {
|
||||
message: 'No such list.',
|
||||
|
@ -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,
|
||||
|
@ -11,6 +11,8 @@ export const meta = {
|
||||
|
||||
kind: 'write:account',
|
||||
|
||||
description: 'Remove a user from a list.',
|
||||
|
||||
errors: {
|
||||
noSuchList: {
|
||||
message: 'No such list.',
|
||||
|
@ -11,6 +11,8 @@ export const meta = {
|
||||
|
||||
kind: 'write:account',
|
||||
|
||||
description: 'Add a user to an existing list.',
|
||||
|
||||
errors: {
|
||||
noSuchList: {
|
||||
message: 'No such list.',
|
||||
|
@ -9,6 +9,8 @@ export const meta = {
|
||||
|
||||
kind: 'read:account',
|
||||
|
||||
description: 'Show the properties of a list.',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
|
@ -9,6 +9,8 @@ export const meta = {
|
||||
|
||||
kind: 'write:account',
|
||||
|
||||
description: 'Update the properties of a list.',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
|
@ -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,
|
||||
|
@ -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 = {
|
||||
|
@ -9,6 +9,8 @@ export const meta = {
|
||||
|
||||
requireCredential: false,
|
||||
|
||||
description: 'Show all reactions this user made.',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
|
@ -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,
|
||||
|
@ -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: [
|
||||
|
@ -13,6 +13,8 @@ export const meta = {
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
description: 'File a report.',
|
||||
|
||||
errors: {
|
||||
noSuchUser: {
|
||||
message: 'No such user.',
|
||||
|
@ -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,
|
||||
|
@ -8,6 +8,8 @@ export const meta = {
|
||||
|
||||
requireCredential: false,
|
||||
|
||||
description: 'Search for users.',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
|
@ -11,6 +11,8 @@ export const meta = {
|
||||
|
||||
requireCredential: false,
|
||||
|
||||
description: 'Show the properties of a user.',
|
||||
|
||||
res: {
|
||||
optional: false, nullable: false,
|
||||
oneOf: [
|
||||
|
@ -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;
|
||||
});
|
||||
|
@ -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');
|
||||
|
@ -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'),
|
||||
});
|
||||
|
||||
|
@ -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');
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user