Merge branch 'develop' into test

This commit is contained in:
syuilo 2022-06-09 01:21:05 +09:00
commit c1ec2f6ab6
247 changed files with 4353 additions and 4383 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

12
.github/labeler.yml vendored Normal file
View File

@ -0,0 +1,12 @@
'⚙Server':
- packages/backend/**/*
'🖥Client':
- packages/client/**/*
'🧪Test':
- cypress/**/*
- packages/backend/test/**/*
'‼️ wrong locales':
- any: ['locales/*.yml', '!locales/ja-JP.yml']

16
.github/workflows/labeler.yml vendored Normal file
View File

@ -0,0 +1,16 @@
name: "Pull Request Labeler"
on:
pull_request_target:
branches-ignore:
- 'l10n_develop'
jobs:
triage:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v4
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"

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.APP_ID }}
private_key: ${{ secrets.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: ok-to-test
named-args: true
permission: write

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

@ -0,0 +1,70 @@
# Run secret-dependent integration tests only after /ok-to-test approval
on:
repository_dispatch:
types: [ok-to-test-command]
name: Deploy preview environment
jobs:
# Repo owner has commented /ok-to-test 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:
# Check out merge commit
- name: Fork based /ok-to-test 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 }}-misskey-dev
timeout: 15m
# Update check run called "integration-fork"
- uses: actions/github-script@v5
id: update-check-run
if: ${{ always() }}
env:
number: ${{ github.event.client_payload.pull_request.number }}
job: ${{ github.job }}
# Conveniently, job.status maps to https://developer.github.com/v3/checks/runs/#update-a-check-run
conclusion: ${{ job.status }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
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);
const { data: result } = await github.rest.checks.update({
...context.repo,
check_run_id: check[0].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 }}-misskey-dev

View File

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

View File

@ -10,32 +10,50 @@ 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
- Supports Unicode Emoji 14.0 @mei23
- プッシュ通知を複数アカウント対応に #7667 @tamaina
- プッシュ通知にクリックやactionを設定 #7667 @tamaina
- ドライブに画像ファイルをアップロードするときオリジナル画像を破棄してwebpublicのみ保持するオプション @tamaina
- Server: always remove completed tasks of job queue @Johann150
- Client: make emoji stand out more on reaction button @Johann150
- Client: display URL of QR code for TOTP registration @tamaina
- 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

@ -1,10 +1,11 @@
# Contribution guide
We're glad you're interested in contributing Misskey! In this document you will find the information you need to contribute to the project.
** Important:** This project uses Japanese as its major language, **but you do not need to translate and write the Issues/PRs in Japanese.**
Also, you might receive comments on your Issue/PR in Japanese, but you do not need to reply to them in Japanese as well.\
The accuracy of machine translation into Japanese is not high, so it will be easier for us to understand if you write it in the original language.
It will also allow the reader to use the translation tool of their preference if necessary.
> **Note**
> This project uses Japanese as its major language, **but you do not need to translate and write the Issues/PRs in Japanese.**
> Also, you might receive comments on your Issue/PR in Japanese, but you do not need to reply to them in Japanese as well.\
> The accuracy of machine translation into Japanese is not high, so it will be easier for us to understand if you write it in the original language.
> It will also allow the reader to use the translation tool of their preference if necessary.
## Roadmap
See [ROADMAP.md](./ROADMAP.md)
@ -16,6 +17,9 @@ Before creating an issue, please check the following:
- Issues should only be used to feature requests, suggestions, and bug tracking.
- Please ask questions or troubleshooting in the [Misskey Forum](https://forum.misskey.io/) or [Discord](https://discord.gg/Wp8gVStHW3).
> **Warning**
> Do not close issues that are about to be resolved. It should remain open until a commit that actually resolves it is merged.
## Before implementation
When you want to add a feature or fix a bug, **first have the design and policy reviewed in an Issue** (if it is not there, please make one). Without this step, there is a high possibility that the PR will not be merged even if it is implemented.
@ -67,15 +71,17 @@ For now, basically only @syuilo has the authority to merge PRs into develop beca
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.~~ (TODO)
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

@ -1,27 +1,29 @@
[![Misskey](https://github.com/misskey-dev/assets/blob/main/banner.png?raw=true)](https://join.misskey.page/)
<div align="center">
**🌎 A forever evolving, interplanetary microblogging platform. 🚀**
**Misskey** is a distributed microblogging platform with advanced features such as Reactions and a highly customizable UI.
[Learn more](https://misskey-hub.net/)
<a href="https://misskey-hub.net">
<img src="./assets/title_float.svg" alt="Misskey logo" style="border-radius:50%" width="400"/>
</a>
**🌎 **[Misskey](https://misskey-hub.net/)** is an open source, decentralized social media platform that's free forever! 🚀**
---
[✨ Find an instance](https://misskey-hub.net/instances.html)
[📦 Create your own instance](https://misskey-hub.net/docs/install.html)
[🛠️ Contribute](./CONTRIBUTING.md)
[🚀 Join the community](https://discord.gg/Wp8gVStHW3)
<a href="https://misskey-hub.net/instances.html">
<img src="https://custom-icon-badges.herokuapp.com/badge/find_an-instance-acea31?logoColor=acea31&style=for-the-badge&logo=misskey&labelColor=363B40" alt="find an instance"/></a>
<a href="https://misskey-hub.net/docs/install.html">
<img src="https://custom-icon-badges.herokuapp.com/badge/create_an-instance-FBD53C?logoColor=FBD53C&style=for-the-badge&logo=server&labelColor=363B40" alt="create an instance"/></a>
<a href="./CONTRIBUTING.md">
<img src="https://custom-icon-badges.herokuapp.com/badge/become_a-contributor-A371F7?logoColor=A371F7&style=for-the-badge&logo=git-merge&labelColor=363B40" alt="become a contributor"/></a>
<a href="https://discord.gg/Wp8gVStHW3">
<img src="https://custom-icon-badges.herokuapp.com/badge/join_the-community-5865F2?logoColor=5865F2&style=for-the-badge&logo=discord&labelColor=363B40" alt="join the community"/></a>
<a href="https://www.patreon.com/syuilo">
<img src="https://custom-icon-badges.herokuapp.com/badge/become_a-patron-F96854?logoColor=F96854&style=for-the-badge&logo=patreon&labelColor=363B40" alt="become a patron"/></a>
---
<a href="https://www.patreon.com/syuilo"><img src="https://c5.patreon.com/external/logo/become_a_patron_button@2x.png" alt="Become a Patron!" width="160" /></a>
</div>
<div>
@ -30,22 +32,25 @@
## ✨ Features
- **ActivityPub support**\
It is possible to interact with other software.
Not on Misskey? No problem! Not only can Misskey instances talk to each other, but you can make friends with people on other networks like Mastodon and Pixelfed!
- **Reactions**\
You can add "reactions" to each post, making it easy for you to express your feelings.
You can add emoji reactions to any post! No longer are you bound by a like button, show everyone exactly how you feel with the tap of a button.
- **Drive**\
An interface to manage uploaded files such as images, videos, sounds, etc.
You can also organize your favorite content into folders, making it easy to share again.
With Misskey's built in drive, you get cloud storage right in your social media, where you can upload any files, make folders, and find media from posts you've made!
- **Rich Web UI**\
Misskey has a rich WebUI by default.
It is highly customizable by flexibly changing the layout and installing various widgets and themes.
Furthermore, plug-ins can be created using AiScript, a original programming language.
- and more...
Misskey has a rich and easy to use Web UI!
It is highly customizable, from changing the layout and adding widgets to making custom themes.
Furthermore, plugins can be created using AiScript, an original programming language.
- And much more...
</div>
<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>

67
assets/title_float.svg Normal file
View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg10"
version="1.1"
viewBox="0 0 162.642 54.261"
height="205.08"
width="614.71">
<metadata
id="metadata16">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<style>
#g8 {
animation-name: floating;
animation-duration: 3s;
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
}
@keyframes floating {
0% { transform: translate(0, 0px); }
50% { transform: translate(0, -5px); }
100% { transform: translate(0, 0px); }
}
</style>
<linearGradient id="myGradient" gradientTransform="rotate(90)">
<stop offset="5%" stop-color="#A1CA03" />
<stop offset="95%" stop-color="#91BA03" />
</linearGradient>
<defs
id="defs14" />
<g
id="g8"
fill="url('#myGradient')"
word-spacing="0"
letter-spacing="0"
font-family="OTADESIGN Rounded"
font-weight="400">
<g
id="g4"
style="line-height:476.69509888px;-inkscape-font-specification:'OTADESIGN Rounded'">
<path
id="path2"
font-size="141.034"
aria-label="Mi"
d="m 27.595,34.59 c -1.676,0.006 -3.115,-1.004 -3.793,-2.179 -0.363,-0.513 -1.08,-0.696 -1.09,0 v 3.214 c 0,1.291 -0.47,2.408 -1.412,3.35 -0.915,0.914 -2.031,1.371 -3.35,1.371 -1.29,0 -2.407,-0.457 -3.349,-1.372 -0.914,-0.941 -1.372,-2.058 -1.372,-3.349 V 17.95 c 0,-0.995 0.283,-1.896 0.848,-2.703 0.591,-0.834 1.345,-1.413 2.26,-1.735 0.516591,-0.189385 1.062793,-0.285215 1.613,-0.283 1.453,0 2.664,0.565 3.632,1.695 l 4.832,5.608 c 0.108,0.08 0.424,0.697 1.18,0.697 0.758,0 1.115,-0.617 1.222,-0.698 l 4.791,-5.607 c 0.996,-1.13 2.22,-1.695 3.673,-1.695 0.538,0 1.076,0.094 1.614,0.283 0.914,0.322 1.654,0.9 2.22,1.735 0.591,0.807 0.887,1.708 0.887,2.703 v 17.675 c 0,1.291 -0.47,2.408 -1.412,3.35 -0.915,0.914 -2.032,1.371 -3.35,1.371 -1.291,0 -2.407,-0.457 -3.35,-1.372 -0.914,-0.941 -1.371,-2.058 -1.371,-3.349 v -3.214 c -0.08,-0.877 -0.855,-0.324 -1.13,0 -0.726,1.345 -2.118,2.173 -3.793,2.18 z M 47.806,21.38 c -1.13,0 -2.098333,-0.39 -2.905,-1.17 -0.78,-0.806667 -1.17,-1.775 -1.17,-2.905 0,-1.13 0.39,-2.085 1.17,-2.865 0.806667,-0.806667 1.775,-1.21 2.905,-1.21 1.13,0 2.098667,0.403333 2.906,1.21 0.806667,0.78 1.21,1.735 1.21,2.865 0,1.13 -0.403333,2.098333 -1.21,2.905 -0.807333,0.78 -1.776,1.17 -2.906,1.17 z m 0.04,0.808 c 1.13,0 2.085333,0.403333 2.866,1.21 0.806667,0.806667 1.21,1.775333 1.21,2.906 v 9.967 c 0,1.13 -0.403333,2.098333 -1.21,2.905 -0.78,0.78 -1.735333,1.17 -2.866,1.17 -1.129333,0 -2.097667,-0.39 -2.905,-1.17 -0.806667,-0.806667 -1.21,-1.775 -1.21,-2.905 v -9.967 c 0,-1.13 0.403333,-2.098667 1.21,-2.906 0.806667,-0.806667 1.775,-1.21 2.905,-1.21 z"
style="font-size:141.03399658px;-inkscape-font-specification:'OTADESIGN Rounded'" />
</g>
<path
id="path6"
d="M60.925 27.24q.968.243 2.42.525 2.42.403 3.792 1.29 2.582 1.695 2.582 5.083 0 2.743-1.815 4.478-2.098 2.017-5.85 2.017-2.742 0-6.13-.767-1.09-.242-1.776-1.089-.645-.847-.645-1.896 0-1.29.887-2.178.928-.928 2.179-.928.363 0 .685.081 1.17.242 4.478.605.444 0 .968-.04.202 0 .202-.242.04-.202-.242-.283-1.372-.242-2.542-.524-1.33-.282-1.896-.484-1.129-.323-1.895-.847-2.582-1.694-2.622-5.083 0-2.702 1.855-4.477 2.26-2.179 6.414-1.977 2.783.121 5.567.726 1.048.242 1.734 1.09.686.846.686 1.936 0 1.25-.928 2.178-.887.887-2.178.887-.323 0-.645-.08-1.17-.242-4.518-.565-.404-.04-.767 0-.323.04-.323.242.04.242.323.323zm17.555 0q.968.243 2.42.525 2.42.403 3.792 1.29 2.581 1.695 2.581 5.083 0 2.743-1.815 4.478-2.098 2.017-5.849 2.017-2.743 0-6.131-.767-1.09-.242-1.775-1.089-.646-.847-.646-1.896 0-1.29.888-2.178.927-.928 2.178-.928.363 0 .686.081 1.17.242 4.477.605.444 0 .968-.04.202 0 .202-.242.04-.202-.242-.283-1.371-.242-2.541-.524-1.331-.282-1.896-.484-1.13-.323-1.896-.847-2.582-1.694-2.622-5.083 0-2.702 1.855-4.477 2.26-2.179 6.414-1.977 2.784.121 5.567.726 1.049.242 1.735 1.09.685.846.685 1.936 0 1.25-.927 2.178-.888.887-2.179.887-.322 0-.645-.08-1.17-.242-4.518-.565-.403-.04-.767 0-.322.04-.322.242.04.242.322.323zm26.075 3.335q.12.08 2.864 2.783 1.25 1.21 1.25 2.945 0 1.613-1.17 2.864-1.17 1.21-2.904 1.21-1.654 0-2.864-1.17l-4.034-3.913q-.161-.12-.323-.12-.322 0-.322 1.21 0 1.694-1.21 2.904-1.21 1.17-2.905 1.17-1.694 0-2.904-1.17-1.17-1.21-1.17-2.905V17.586q0-1.694 1.17-2.864 1.21-1.21 2.904-1.21t2.904 1.21q1.21 1.17 1.21 2.864v6.293q0 .403.283.524.242.121.524-.08.162-.081 4.841-3.188 1.049-.645 2.259-.645 2.219 0 3.429 1.815.645 1.05.645 2.26 0 2.218-1.815 3.428l-2.541 1.614v.04l-.081.04q-.565.363-.04.888zm15.599 10.058q-4.195 0-7.18-2.945-2.945-2.985-2.945-7.18 0-4.155 2.945-7.1 2.985-2.985 7.18-2.985 4.155 0 6.979 2.784.928.927.928 2.259 0 1.33-.928 2.259l-4.68 4.639q-1.008 1.008-2.016 1.008-1.453 0-2.26-.807-.806-.807-.806-2.138 0-1.29.928-2.218l.806-.847q.162-.121.081-.243-.12-.08-.323-.04-.806.202-1.371.807-1.13 1.09-1.13 2.622 0 1.573 1.09 2.703 1.13 1.089 2.702 1.089 1.533 0 2.622-1.13.928-.927 2.26-.927 1.33 0 2.258.927.928.928.928 2.26 0 1.33-.928 2.258-2.985 2.945-7.14 2.945zm29.259-15.786v5.607q0 .564-.08 1.21v7.382q0 4.518-2.744 7.22-2.702 2.703-7.301 2.703-2.662 0-4.8-1.008-2.138-.968-2.138-3.348 0-.807.363-1.533.968-2.179 3.348-2.179.565 0 1.573.323 1.009.323 1.654.323 1.694 0 2.219-.726.201-.283.08-.444-.161-.242-.564-.161-.686.12-1.493.12-4.074 0-6.979-2.904-2.904-2.904-2.904-6.978v-5.607q0-1.695 1.17-2.864 1.21-1.21 2.904-1.21t2.905 1.21q1.21 1.17 1.21 2.864v5.607q0 .685.484 1.21.524.484 1.21.484.726 0 1.21-.484.484-.525.484-1.21v-5.607q0-1.695 1.21-2.864 1.21-1.21 2.905-1.21 1.694 0 2.864 1.21 1.21 1.17 1.21 2.864z"
style="line-height:136.34428406px;-inkscape-font-specification:'OTADESIGN Rounded'" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

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(() => {

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

@ -425,7 +425,7 @@ quoteQuestion: "引用として添付しますか?"
noMessagesYet: "まだチャットはありません"
newMessageExists: "新しいメッセージがあります"
onlyOneFileCanBeAttached: "メッセージに添付できるファイルはひとつです"
signinRequired: "ログインしてください"
signinRequired: "続行する前に、サインアップまたはサインインが必要です"
invitations: "招待"
invitationCode: "招待コード"
checking: "確認しています"
@ -842,6 +842,7 @@ oneDay: "1日"
oneWeek: "1週間"
reflectMayTakeTime: "反映されるまで時間がかかる場合があります。"
failedToFetchAccountInformation: "アカウント情報の取得に失敗しました"
rateLimitExceeded: "レート制限を超えました"
_emailUnavailable:
used: "既に使用されています"
@ -1110,7 +1111,6 @@ _sfx:
channel: "チャンネル通知"
_ago:
unknown: "謎"
future: "未来"
justNow: "たった今"
secondsAgo: "{n}秒前"

View File

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

View File

@ -0,0 +1,36 @@
import tinycolor from 'tinycolor2';
export class uniformThemecolor1652859567549 {
name = 'uniformThemecolor1652859567549'
async up(queryRunner) {
const formatColor = (color) => {
let tc = new tinycolor(color);
if (tc.isValid()) {
return tc.toHexString();
} else {
return null;
}
};
await queryRunner.query('SELECT "id", "themeColor" FROM "instance" WHERE "themeColor" IS NOT NULL')
.then(instances => Promise.all(instances.map(instance => {
// update theme color to uniform format, e.g. #00ff00
// invalid theme colors get set to null
return queryRunner.query('UPDATE "instance" SET "themeColor" = $1 WHERE "id" = $2', [formatColor(instance.themeColor), instance.id]);
})));
// also fix own theme color
await queryRunner.query('SELECT "themeColor" FROM "meta" WHERE "themeColor" IS NOT NULL LIMIT 1')
.then(metas => {
if (metas.length > 0) {
return queryRunner.query('UPDATE "meta" SET "themeColor" = $1', [formatColor(metas[0].themeColor)]);
}
});
}
async down(queryRunner) {
// The original representation is not stored, so migrating back is not possible.
// The new format also works in older versions so this is not a problem.
}
}

View File

@ -15,11 +15,12 @@
},
"dependencies": {
"@bull-board/koa": "3.10.4",
"@discordapp/twemoji": "13.1.1",
"@discordapp/twemoji": "14.0.2",
"@elastic/elasticsearch": "7.11.0",
"@koa/cors": "3.1.0",
"@koa/multer": "3.0.0",
"@koa/router": "9.0.1",
"@peertube/http-signature": "1.6.0",
"@sinonjs/fake-timers": "9.1.2",
"@syuilo/aiscript": "0.11.1",
"abort-controller": "3.0.0",
@ -30,7 +31,7 @@
"aws-sdk": "2.1135.0",
"bcryptjs": "2.4.3",
"blurhash": "1.1.5",
"broadcast-channel": "4.11.0",
"broadcast-channel": "4.12.0",
"bull": "4.8.3",
"cacheable-lookup": "6.0.4",
"cbor": "8.1.0",
@ -47,15 +48,14 @@
"fluent-ffmpeg": "2.1.2",
"got": "12.0.4",
"hpagent": "0.1.2",
"http-signature": "1.3.6",
"ip-cidr": "3.0.7",
"ip-cidr": "3.0.8",
"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.20",
"jsrsasign": "10.5.22",
"koa": "2.13.4",
"koa-bodyparser": "4.3.0",
"koa-favicon": "2.1.0",
@ -65,10 +65,10 @@
"koa-send": "5.0.1",
"koa-slow": "2.1.0",
"koa-views": "7.0.2",
"mfm-js": "0.21.0",
"mfm-js": "0.22.1",
"mime-types": "2.1.35",
"misskey-js": "0.0.14",
"mocha": "9.2.2",
"mocha": "10.0.0",
"ms": "3.0.0-canary.1",
"multer": "1.4.4",
"nested-property": "4.0.0",
@ -77,7 +77,6 @@
"os-utils": "0.0.14",
"parse5": "6.0.1",
"pg": "8.7.3",
"portscanner": "2.2.0",
"private-ip": "2.3.3",
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
@ -102,15 +101,15 @@
"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",
"tinycolor2": "1.4.2",
"tmp": "0.2.1",
"ts-loader": "9.3.0",
"ts-node": "10.7.0",
"tsc-alias": "1.4.1",
"tsconfig-paths": "3.14.1",
"ts-node": "10.8.0",
"tsc-alias": "1.6.7",
"tsconfig-paths": "4.0.0",
"twemoji-parser": "14.0.0",
"typeorm": "0.3.6",
"ulid": "2.3.0",
@ -146,12 +145,11 @@
"@types/koa__multer": "2.0.4",
"@types/koa__router": "8.0.11",
"@types/mocha": "9.1.1",
"@types/node": "17.0.33",
"@types/node": "17.0.35",
"@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.4",
"@types/oauth": "0.9.1",
"@types/parse5": "6.0.3",
"@types/portscanner": "2.1.1",
"@types/pug": "2.0.6",
"@types/punycode": "2.1.0",
"@types/qrcode": "1.4.2",
@ -169,10 +167,10 @@
"@types/web-push": "3.3.2",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.3",
"@typescript-eslint/eslint-plugin": "5.23.0",
"@typescript-eslint/parser": "5.23.0",
"typescript": "4.6.4",
"eslint": "8.15.0",
"@typescript-eslint/eslint-plugin": "5.26.0",
"@typescript-eslint/parser": "5.26.0",
"typescript": "4.7.2",
"eslint": "8.16.0",
"eslint-plugin-import": "2.26.0",
"cross-env": "7.0.3",

View File

@ -1,4 +1,4 @@
declare module 'http-signature' {
declare module '@peertube/http-signature' {
import { IncomingMessage, ClientRequest } from 'node:http';
interface ISignature {

View File

@ -5,7 +5,6 @@ import * as os from 'node:os';
import cluster from 'node:cluster';
import chalk from 'chalk';
import chalkTemplate from 'chalk-template';
import * as portscanner from 'portscanner';
import semver from 'semver';
import Logger from '@/services/logger.js';
@ -48,11 +47,6 @@ function greet() {
bootLogger.info(`Misskey v${meta.version}`, null, true);
}
function isRoot() {
// maybe process.getuid will be undefined under not POSIX environment (e.g. Windows)
return process.getuid != null && process.getuid() === 0;
}
/**
* Init master process
*/
@ -67,7 +61,6 @@ export async function masterMain() {
showNodejsVersion();
config = loadConfigBoot();
await connectDb();
await validatePort(config);
} catch (e) {
bootLogger.error('Fatal error occurred during initialization', null, true);
process.exit(1);
@ -97,8 +90,6 @@ function showEnvironment(): void {
logger.warn('The environment is not in production mode.');
logger.warn('DO NOT USE FOR PRODUCTION PURPOSE!', null, true);
}
logger.info(`You ${isRoot() ? '' : 'do not '}have root privileges`);
}
function showNodejsVersion(): void {
@ -152,29 +143,6 @@ async function connectDb(): Promise<void> {
}
}
async function validatePort(config: Config): Promise<void> {
const isWellKnownPort = (port: number) => port < 1024;
async function isPortAvailable(port: number): Promise<boolean> {
return await portscanner.checkPortStatus(port, '127.0.0.1') === 'closed';
}
if (config.port == null || Number.isNaN(config.port)) {
bootLogger.error('The port is not configured. Please configure port.', null, true);
process.exit(1);
}
if (process.platform === 'linux' && isWellKnownPort(config.port) && !isRoot()) {
bootLogger.error('You need root privileges to listen on well-known port on Linux', null, true);
process.exit(1);
}
if (!await isPortAvailable(config.port)) {
bootLogger.error(`Port ${config.port} is already in use`, null, true);
process.exit(1);
}
}
async function spawnWorkers(limit: number = 1) {
const workers = Math.min(limit, os.cpus().length);
bootLogger.info(`Starting ${workers} worker${workers === 1 ? '' : 's'}...`);
@ -186,6 +154,10 @@ function spawnWorker(): Promise<void> {
return new Promise(res => {
const worker = cluster.fork();
worker.on('message', message => {
if (message === 'listenFailed') {
bootLogger.error(`The server Listen failed due to the previous error.`);
process.exit(1);
}
if (message !== 'ready') return;
res();
});

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

@ -5,9 +5,6 @@ pg.types.setTypeParser(20, Number);
import { Logger, DataSource } from 'typeorm';
import * as highlight from 'cli-highlight';
import config from '@/config/index.js';
import { envOption } from '../env.js';
import { dbLogger } from './logger.js';
import { User } from '@/models/entities/user.js';
import { DriveFile } from '@/models/entities/drive-file.js';
@ -74,6 +71,9 @@ import { UserPending } from '@/models/entities/user-pending.js';
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);
@ -208,16 +208,25 @@ 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 {
await db.connect();
await db.initialize();
}
}
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

@ -1,10 +1,19 @@
import * as tmp from 'tmp';
export function createTemp(): Promise<[string, any]> {
return new Promise<[string, any]>((res, rej) => {
export function createTemp(): Promise<[string, () => void]> {
return new Promise<[string, () => void]>((res, rej) => {
tmp.file((e, path, fd, cleanup) => {
if (e) return rej(e);
res([path, cleanup]);
});
});
}
export function createTempDir(): Promise<[string, () => void]> {
return new Promise<[string, () => void]>((res, rej) => {
tmp.dir((e, path, cleanup) => {
if (e) return rej(e);
res([path, cleanup]);
});
});
}

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);
}
}
@ -168,16 +162,25 @@ export const NoteRepository = db.getRepository(Note).extend({
return true;
} else {
// フォロワーかどうか
const following = await Followings.findOneBy({
followeeId: note.userId,
followerId: meId,
});
const [following, user] = await Promise.all([
Followings.count({
where: {
followeeId: note.userId,
followerId: meId,
},
take: 1,
}),
Users.findOneByOrFail({ id: meId }),
]);
if (following == null) {
return false;
} else {
return true;
}
/* If we know the following, everyhting is fine.
But if we do not know the following, it might be that both the
author of the note and the author of the like are remote users,
in which case we can never know the following. Instead we have
to assume that the users are following each other.
*/
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

@ -1,4 +1,4 @@
import httpSignature from 'http-signature';
import httpSignature from '@peertube/http-signature';
import { v4 as uuid } from 'uuid';
import config from '@/config/index.js';
@ -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

@ -1,11 +1,11 @@
import Bull from 'bull';
import * as tmp from 'tmp';
import * as fs from 'node:fs';
import { queueLogger } from '../../logger.js';
import { addFile } from '@/services/drive/add-file.js';
import { format as dateFormat } from 'date-fns';
import { getFullApAccount } from '@/misc/convert-host.js';
import { createTemp } from '@/misc/create-temp.js';
import { Users, Blockings } from '@/models/index.js';
import { MoreThan } from 'typeorm';
import { DbUserJobData } from '@/queue/types.js';
@ -22,73 +22,72 @@ export async function exportBlocking(job: Bull.Job<DbUserJobData>, done: any): P
}
// Create temp file
const [path, cleanup] = await new Promise<[string, any]>((res, rej) => {
tmp.file((e, path, fd, cleanup) => {
if (e) return rej(e);
res([path, cleanup]);
});
});
const [path, cleanup] = await createTemp();
logger.info(`Temp file is ${path}`);
const stream = fs.createWriteStream(path, { flags: 'a' });
try {
const stream = fs.createWriteStream(path, { flags: 'a' });
let exportedCount = 0;
let cursor: any = null;
let exportedCount = 0;
let cursor: any = null;
while (true) {
const blockings = await Blockings.find({
where: {
blockerId: user.id,
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 100,
order: {
id: 1,
},
});
while (true) {
const blockings = await Blockings.find({
where: {
blockerId: user.id,
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 100,
order: {
id: 1,
},
});
if (blockings.length === 0) {
job.progress(100);
break;
}
cursor = blockings[blockings.length - 1].id;
for (const block of blockings) {
const u = await Users.findOneBy({ id: block.blockeeId });
if (u == null) {
exportedCount++; continue;
if (blockings.length === 0) {
job.progress(100);
break;
}
const content = getFullApAccount(u.username, u.host);
await new Promise<void>((res, rej) => {
stream.write(content + '\n', err => {
if (err) {
logger.error(err);
rej(err);
} else {
res();
}
cursor = blockings[blockings.length - 1].id;
for (const block of blockings) {
const u = await Users.findOneBy({ id: block.blockeeId });
if (u == null) {
exportedCount++; continue;
}
const content = getFullApAccount(u.username, u.host);
await new Promise<void>((res, rej) => {
stream.write(content + '\n', err => {
if (err) {
logger.error(err);
rej(err);
} else {
res();
}
});
});
exportedCount++;
}
const total = await Blockings.countBy({
blockerId: user.id,
});
exportedCount++;
job.progress(exportedCount / total);
}
const total = await Blockings.countBy({
blockerId: user.id,
});
stream.end();
logger.succ(`Exported to: ${path}`);
job.progress(exportedCount / total);
const fileName = 'blocking-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv';
const driveFile = await addFile({ user, path, name: fileName, force: true });
logger.succ(`Exported to: ${driveFile.id}`);
} finally {
cleanup();
}
stream.end();
logger.succ(`Exported to: ${path}`);
const fileName = 'blocking-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv';
const driveFile = await addFile({ user, path, name: fileName, force: true });
logger.succ(`Exported to: ${driveFile.id}`);
cleanup();
done();
}

View File

@ -1,5 +1,4 @@
import Bull from 'bull';
import * as tmp from 'tmp';
import * as fs from 'node:fs';
import { ulid } from 'ulid';
@ -10,6 +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 { createTemp, createTempDir } from '@/misc/create-temp.js';
import { downloadUrl } from '@/misc/download-url.js';
import config from '@/config/index.js';
import { IsNull } from 'typeorm';
@ -25,13 +25,7 @@ export async function exportCustomEmojis(job: Bull.Job, done: () => void): Promi
return;
}
// Create temp dir
const [path, cleanup] = await new Promise<[string, () => void]>((res, rej) => {
tmp.dir((e, path, cleanup) => {
if (e) return rej(e);
res([path, cleanup]);
});
});
const [path, cleanup] = await createTempDir();
logger.info(`Temp dir is ${path}`);
@ -98,12 +92,7 @@ export async function exportCustomEmojis(job: Bull.Job, done: () => void): Promi
metaStream.end();
// Create archive
const [archivePath, archiveCleanup] = await new Promise<[string, () => void]>((res, rej) => {
tmp.file((e, path, fd, cleanup) => {
if (e) return rej(e);
res([path, cleanup]);
});
});
const [archivePath, archiveCleanup] = await createTemp();
const archiveStream = fs.createWriteStream(archivePath);
const archive = archiver('zip', {
zlib: { level: 0 },

View File

@ -1,11 +1,11 @@
import Bull from 'bull';
import * as tmp from 'tmp';
import * as fs from 'node:fs';
import { queueLogger } from '../../logger.js';
import { addFile } from '@/services/drive/add-file.js';
import { format as dateFormat } from 'date-fns';
import { getFullApAccount } from '@/misc/convert-host.js';
import { createTemp } from '@/misc/create-temp.js';
import { Users, Followings, Mutings } from '@/models/index.js';
import { In, MoreThan, Not } from 'typeorm';
import { DbUserJobData } from '@/queue/types.js';
@ -23,73 +23,72 @@ export async function exportFollowing(job: Bull.Job<DbUserJobData>, done: () =>
}
// Create temp file
const [path, cleanup] = await new Promise<[string, () => void]>((res, rej) => {
tmp.file((e, path, fd, cleanup) => {
if (e) return rej(e);
res([path, cleanup]);
});
});
const [path, cleanup] = await createTemp();
logger.info(`Temp file is ${path}`);
const stream = fs.createWriteStream(path, { flags: 'a' });
try {
const stream = fs.createWriteStream(path, { flags: 'a' });
let cursor: Following['id'] | null = null;
let cursor: Following['id'] | null = null;
const mutings = job.data.excludeMuting ? await Mutings.findBy({
muterId: user.id,
}) : [];
const mutings = job.data.excludeMuting ? await Mutings.findBy({
muterId: user.id,
}) : [];
while (true) {
const followings = await Followings.find({
where: {
followerId: user.id,
...(mutings.length > 0 ? { followeeId: Not(In(mutings.map(x => x.muteeId))) } : {}),
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 100,
order: {
id: 1,
},
}) as Following[];
while (true) {
const followings = await Followings.find({
where: {
followerId: user.id,
...(mutings.length > 0 ? { followeeId: Not(In(mutings.map(x => x.muteeId))) } : {}),
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 100,
order: {
id: 1,
},
}) as Following[];
if (followings.length === 0) {
break;
}
cursor = followings[followings.length - 1].id;
for (const following of followings) {
const u = await Users.findOneBy({ id: following.followeeId });
if (u == null) {
continue;
if (followings.length === 0) {
break;
}
if (job.data.excludeInactive && u.updatedAt && (Date.now() - u.updatedAt.getTime() > 1000 * 60 * 60 * 24 * 90)) {
continue;
}
cursor = followings[followings.length - 1].id;
const content = getFullApAccount(u.username, u.host);
await new Promise<void>((res, rej) => {
stream.write(content + '\n', err => {
if (err) {
logger.error(err);
rej(err);
} else {
res();
}
for (const following of followings) {
const u = await Users.findOneBy({ id: following.followeeId });
if (u == null) {
continue;
}
if (job.data.excludeInactive && u.updatedAt && (Date.now() - u.updatedAt.getTime() > 1000 * 60 * 60 * 24 * 90)) {
continue;
}
const content = getFullApAccount(u.username, u.host);
await new Promise<void>((res, rej) => {
stream.write(content + '\n', err => {
if (err) {
logger.error(err);
rej(err);
} else {
res();
}
});
});
});
}
}
stream.end();
logger.succ(`Exported to: ${path}`);
const fileName = 'following-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv';
const driveFile = await addFile({ user, path, name: fileName, force: true });
logger.succ(`Exported to: ${driveFile.id}`);
} finally {
cleanup();
}
stream.end();
logger.succ(`Exported to: ${path}`);
const fileName = 'following-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv';
const driveFile = await addFile({ user, path, name: fileName, force: true });
logger.succ(`Exported to: ${driveFile.id}`);
cleanup();
done();
}

View File

@ -1,11 +1,11 @@
import Bull from 'bull';
import * as tmp from 'tmp';
import * as fs from 'node:fs';
import { queueLogger } from '../../logger.js';
import { addFile } from '@/services/drive/add-file.js';
import { format as dateFormat } from 'date-fns';
import { getFullApAccount } from '@/misc/convert-host.js';
import { createTemp } from '@/misc/create-temp.js';
import { Users, Mutings } from '@/models/index.js';
import { IsNull, MoreThan } from 'typeorm';
import { DbUserJobData } from '@/queue/types.js';
@ -22,74 +22,73 @@ export async function exportMute(job: Bull.Job<DbUserJobData>, done: any): Promi
}
// Create temp file
const [path, cleanup] = await new Promise<[string, any]>((res, rej) => {
tmp.file((e, path, fd, cleanup) => {
if (e) return rej(e);
res([path, cleanup]);
});
});
const [path, cleanup] = await createTemp();
logger.info(`Temp file is ${path}`);
const stream = fs.createWriteStream(path, { flags: 'a' });
try {
const stream = fs.createWriteStream(path, { flags: 'a' });
let exportedCount = 0;
let cursor: any = null;
let exportedCount = 0;
let cursor: any = null;
while (true) {
const mutes = await Mutings.find({
where: {
muterId: user.id,
expiresAt: IsNull(),
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 100,
order: {
id: 1,
},
});
while (true) {
const mutes = await Mutings.find({
where: {
muterId: user.id,
expiresAt: IsNull(),
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 100,
order: {
id: 1,
},
});
if (mutes.length === 0) {
job.progress(100);
break;
}
cursor = mutes[mutes.length - 1].id;
for (const mute of mutes) {
const u = await Users.findOneBy({ id: mute.muteeId });
if (u == null) {
exportedCount++; continue;
if (mutes.length === 0) {
job.progress(100);
break;
}
const content = getFullApAccount(u.username, u.host);
await new Promise<void>((res, rej) => {
stream.write(content + '\n', err => {
if (err) {
logger.error(err);
rej(err);
} else {
res();
}
cursor = mutes[mutes.length - 1].id;
for (const mute of mutes) {
const u = await Users.findOneBy({ id: mute.muteeId });
if (u == null) {
exportedCount++; continue;
}
const content = getFullApAccount(u.username, u.host);
await new Promise<void>((res, rej) => {
stream.write(content + '\n', err => {
if (err) {
logger.error(err);
rej(err);
} else {
res();
}
});
});
exportedCount++;
}
const total = await Mutings.countBy({
muterId: user.id,
});
exportedCount++;
job.progress(exportedCount / total);
}
const total = await Mutings.countBy({
muterId: user.id,
});
stream.end();
logger.succ(`Exported to: ${path}`);
job.progress(exportedCount / total);
const fileName = 'mute-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv';
const driveFile = await addFile({ user, path, name: fileName, force: true });
logger.succ(`Exported to: ${driveFile.id}`);
} finally {
cleanup();
}
stream.end();
logger.succ(`Exported to: ${path}`);
const fileName = 'mute-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv';
const driveFile = await addFile({ user, path, name: fileName, force: true });
logger.succ(`Exported to: ${driveFile.id}`);
cleanup();
done();
}

View File

@ -1,5 +1,4 @@
import Bull from 'bull';
import * as tmp from 'tmp';
import * as fs from 'node:fs';
import { queueLogger } from '../../logger.js';
@ -10,6 +9,7 @@ import { MoreThan } from 'typeorm';
import { Note } from '@/models/entities/note.js';
import { Poll } from '@/models/entities/poll.js';
import { DbUserJobData } from '@/queue/types.js';
import { createTemp } from '@/misc/create-temp.js';
const logger = queueLogger.createSubLogger('export-notes');
@ -23,82 +23,81 @@ export async function exportNotes(job: Bull.Job<DbUserJobData>, done: any): Prom
}
// Create temp file
const [path, cleanup] = await new Promise<[string, any]>((res, rej) => {
tmp.file((e, path, fd, cleanup) => {
if (e) return rej(e);
res([path, cleanup]);
});
});
const [path, cleanup] = await createTemp();
logger.info(`Temp file is ${path}`);
const stream = fs.createWriteStream(path, { flags: 'a' });
try {
const stream = fs.createWriteStream(path, { flags: 'a' });
const write = (text: string): Promise<void> => {
return new Promise<void>((res, rej) => {
stream.write(text, err => {
if (err) {
logger.error(err);
rej(err);
} else {
res();
}
const write = (text: string): Promise<void> => {
return new Promise<void>((res, rej) => {
stream.write(text, err => {
if (err) {
logger.error(err);
rej(err);
} else {
res();
}
});
});
});
};
};
await write('[');
await write('[');
let exportedNotesCount = 0;
let cursor: Note['id'] | null = null;
let exportedNotesCount = 0;
let cursor: Note['id'] | null = null;
while (true) {
const notes = await Notes.find({
where: {
userId: user.id,
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 100,
order: {
id: 1,
},
}) as Note[];
while (true) {
const notes = await Notes.find({
where: {
userId: user.id,
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 100,
order: {
id: 1,
},
}) as Note[];
if (notes.length === 0) {
job.progress(100);
break;
}
cursor = notes[notes.length - 1].id;
for (const note of notes) {
let poll: Poll | undefined;
if (note.hasPoll) {
poll = await Polls.findOneByOrFail({ noteId: note.id });
if (notes.length === 0) {
job.progress(100);
break;
}
const content = JSON.stringify(serialize(note, poll));
const isFirst = exportedNotesCount === 0;
await write(isFirst ? content : ',\n' + content);
exportedNotesCount++;
cursor = notes[notes.length - 1].id;
for (const note of notes) {
let poll: Poll | undefined;
if (note.hasPoll) {
poll = await Polls.findOneByOrFail({ noteId: note.id });
}
const content = JSON.stringify(serialize(note, poll));
const isFirst = exportedNotesCount === 0;
await write(isFirst ? content : ',\n' + content);
exportedNotesCount++;
}
const total = await Notes.countBy({
userId: user.id,
});
job.progress(exportedNotesCount / total);
}
const total = await Notes.countBy({
userId: user.id,
});
await write(']');
job.progress(exportedNotesCount / total);
stream.end();
logger.succ(`Exported to: ${path}`);
const fileName = 'notes-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json';
const driveFile = await addFile({ user, path, name: fileName, force: true });
logger.succ(`Exported to: ${driveFile.id}`);
} finally {
cleanup();
}
await write(']');
stream.end();
logger.succ(`Exported to: ${path}`);
const fileName = 'notes-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json';
const driveFile = await addFile({ user, path, name: fileName, force: true });
logger.succ(`Exported to: ${driveFile.id}`);
cleanup();
done();
}

View File

@ -1,11 +1,11 @@
import Bull from 'bull';
import * as tmp from 'tmp';
import * as fs from 'node:fs';
import { queueLogger } from '../../logger.js';
import { addFile } from '@/services/drive/add-file.js';
import { format as dateFormat } from 'date-fns';
import { getFullApAccount } from '@/misc/convert-host.js';
import { createTemp } from '@/misc/create-temp.js';
import { Users, UserLists, UserListJoinings } from '@/models/index.js';
import { In } from 'typeorm';
import { DbUserJobData } from '@/queue/types.js';
@ -26,46 +26,45 @@ export async function exportUserLists(job: Bull.Job<DbUserJobData>, done: any):
});
// Create temp file
const [path, cleanup] = await new Promise<[string, any]>((res, rej) => {
tmp.file((e, path, fd, cleanup) => {
if (e) return rej(e);
res([path, cleanup]);
});
});
const [path, cleanup] = await createTemp();
logger.info(`Temp file is ${path}`);
const stream = fs.createWriteStream(path, { flags: 'a' });
try {
const stream = fs.createWriteStream(path, { flags: 'a' });
for (const list of lists) {
const joinings = await UserListJoinings.findBy({ userListId: list.id });
const users = await Users.findBy({
id: In(joinings.map(j => j.userId)),
});
for (const u of users) {
const acct = getFullApAccount(u.username, u.host);
const content = `${list.name},${acct}`;
await new Promise<void>((res, rej) => {
stream.write(content + '\n', err => {
if (err) {
logger.error(err);
rej(err);
} else {
res();
}
});
for (const list of lists) {
const joinings = await UserListJoinings.findBy({ userListId: list.id });
const users = await Users.findBy({
id: In(joinings.map(j => j.userId)),
});
for (const u of users) {
const acct = getFullApAccount(u.username, u.host);
const content = `${list.name},${acct}`;
await new Promise<void>((res, rej) => {
stream.write(content + '\n', err => {
if (err) {
logger.error(err);
rej(err);
} else {
res();
}
});
});
}
}
stream.end();
logger.succ(`Exported to: ${path}`);
const fileName = 'user-lists-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv';
const driveFile = await addFile({ user, path, name: fileName, force: true });
logger.succ(`Exported to: ${driveFile.id}`);
} finally {
cleanup();
}
stream.end();
logger.succ(`Exported to: ${path}`);
const fileName = 'user-lists-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv';
const driveFile = await addFile({ user, path, name: fileName, force: true });
logger.succ(`Exported to: ${driveFile.id}`);
cleanup();
done();
}

View File

@ -1,9 +1,9 @@
import Bull from 'bull';
import * as tmp from 'tmp';
import * as fs from 'node:fs';
import unzipper from 'unzipper';
import { queueLogger } from '../../logger.js';
import { createTempDir } from '@/misc/create-temp.js';
import { downloadUrl } from '@/misc/download-url.js';
import { DriveFiles, Emojis } from '@/models/index.js';
import { DbUserImportJobData } from '@/queue/types.js';
@ -25,13 +25,7 @@ export async function importCustomEmojis(job: Bull.Job<DbUserImportJobData>, don
return;
}
// Create temp dir
const [path, cleanup] = await new Promise<[string, () => void]>((res, rej) => {
tmp.dir((e, path, cleanup) => {
if (e) return rej(e);
res([path, cleanup]);
});
});
const [path, cleanup] = await createTempDir();
logger.info(`Temp dir is ${path}`);

View File

@ -1,6 +1,6 @@
import { URL } from 'node:url';
import Bull from 'bull';
import httpSignature from 'http-signature';
import httpSignature from '@peertube/http-signature';
import perform from '@/remote/activitypub/perform.js';
import Logger from '@/services/logger.js';
import { registerOrFetchInstanceDoc } from '@/services/register-or-fetch-instance-doc.js';

View File

@ -3,7 +3,7 @@ import { Note } from '@/models/entities/note';
import { User } from '@/models/entities/user.js';
import { Webhook } from '@/models/entities/webhook';
import { IActivity } from '@/remote/activitypub/type.js';
import httpSignature from 'http-signature';
import httpSignature from '@peertube/http-signature';
export type DeliverJobData = {
/** Actor */

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

@ -9,6 +9,7 @@ import { fetchMeta } from '@/misc/fetch-meta.js';
import { getApLock } from '@/misc/app-lock.js';
import { parseAudience } from '../../audience.js';
import { StatusError } from '@/misc/fetch.js';
import { Notes } from '@/models/index.js';
const logger = apLogger;
@ -52,6 +53,8 @@ export default async function(resolver: Resolver, actor: CacheableRemoteUser, ac
throw e;
}
if (!await Notes.isVisibleForMe(renote, actor.id)) return 'skip: invalid actor for this activity';
logger.info(`Creating the (Re)Note: ${uri}`);
const activityAudience = await parseAudience(actor, activity.to, activity.cc);

View File

@ -13,37 +13,37 @@ export default async (actor: CacheableRemoteUser, activity: IDelete): Promise<st
}
// 削除対象objectのtype
let formarType: string | undefined;
let formerType: string | undefined;
if (typeof activity.object === 'string') {
// typeが不明だけど、どうせ消えてるのでremote resolveしない
formarType = undefined;
formerType = undefined;
} else {
const object = activity.object as IObject;
if (isTombstone(object)) {
formarType = toSingle(object.formerType);
formerType = toSingle(object.formerType);
} else {
formarType = toSingle(object.type);
formerType = toSingle(object.type);
}
}
const uri = getApId(activity.object);
// type不明でもactorとobjectが同じならばそれはPersonに違いない
if (!formarType && actor.uri === uri) {
formarType = 'Person';
if (!formerType && actor.uri === uri) {
formerType = 'Person';
}
// それでもなかったらおそらくNote
if (!formarType) {
formarType = 'Note';
if (!formerType) {
formerType = 'Note';
}
if (validPost.includes(formarType)) {
if (validPost.includes(formerType)) {
return await deleteNote(actor, uri);
} else if (validActor.includes(formarType)) {
} else if (validActor.includes(formerType)) {
return await deleteActor(actor, uri);
} else {
return `Unknown type ${formarType}`;
return `Unknown type ${formerType}`;
}
};

View File

@ -8,6 +8,7 @@ export const undoAnnounce = async (actor: CacheableRemoteUser, activity: IAnnoun
const note = await Notes.findOneBy({
uri,
userId: actor.id,
});
if (!note) return 'skip: no such Announce';

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';

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}`;

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

@ -1,6 +1,6 @@
import Router from '@koa/router';
import json from 'koa-json-body';
import httpSignature from 'http-signature';
import httpSignature from '@peertube/http-signature';
import { renderActivity } from '@/remote/activitypub/renderer/index.js';
import renderNote from '@/remote/activitypub/renderer/note.js';
@ -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

@ -27,7 +27,7 @@ export const paramDef = {
blockedHosts: { type: 'array', nullable: true, items: {
type: 'string',
} },
themeColor: { type: 'string', nullable: true },
themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' },
mascotImageUrl: { type: 'string', nullable: true },
bannerUrl: { type: 'string', nullable: true },
errorImageUrl: { type: 'string', nullable: true },

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

@ -1,6 +1,7 @@
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'],
@ -31,109 +32,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

@ -59,6 +59,18 @@ export function genOpenapiSpec(lang = 'ja-JP') {
desc += ` / **Permission**: *${kind}*`;
}
const requestType = endpoint.meta.requireFile ? 'multipart/form-data' : 'application/json';
const schema = endpoint.params;
if (endpoint.meta.requireFile) {
schema.properties.file = {
type: 'string',
format: 'binary',
description: 'The file contents.',
};
schema.required.push('file');
}
const info = {
operationId: endpoint.name,
summary: endpoint.name,
@ -78,8 +90,8 @@ export function genOpenapiSpec(lang = 'ja-JP') {
requestBody: {
required: true,
content: {
'application/json': {
schema: endpoint.params,
[requestType]: {
schema,
},
},
},

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

@ -4,11 +4,11 @@ import { dirname } from 'node:path';
import Koa from 'koa';
import send from 'koa-send';
import rename from 'rename';
import * as tmp from 'tmp';
import { serverLogger } from '../index.js';
import { contentDisposition } from '@/misc/content-disposition.js';
import { DriveFiles } from '@/models/index.js';
import { InternalStorage } from '@/services/drive/internal-storage.js';
import { createTemp } from '@/misc/create-temp.js';
import { downloadUrl } from '@/misc/download-url.js';
import { detectType } from '@/misc/get-file-info.js';
import { convertToWebp, convertToJpeg, convertToPng } from '@/services/drive/image-processor.js';
@ -50,12 +50,7 @@ export default async function(ctx: Koa.Context) {
if (!file.storedInternal) {
if (file.isLink && file.uri) { // 期限切れリモートファイル
const [path, cleanup] = await new Promise<[string, any]>((res, rej) => {
tmp.file((e, path, fd, cleanup) => {
if (e) return rej(e);
res([path, cleanup]);
});
});
const [path, cleanup] = await createTemp();
try {
await downloadUrl(file.uri, path);

View File

@ -2,6 +2,7 @@
* Core Server
*/
import cluster from 'node:cluster';
import * as fs from 'node:fs';
import * as http from 'node:http';
import Koa from 'koa';
@ -88,10 +89,10 @@ router.get('/avatar/@:acct', async ctx => {
});
router.get('/identicon/:x', async ctx => {
const [temp] = await createTemp();
const [temp, cleanup] = await createTemp();
await genIdenticon(ctx.params.x, fs.createWriteStream(temp));
ctx.set('Content-Type', 'image/png');
ctx.body = fs.createReadStream(temp);
ctx.body = fs.createReadStream(temp).on('close', () => cleanup());
});
router.get('/verify-email/:code', async ctx => {
@ -142,5 +143,26 @@ export default () => new Promise(resolve => {
initializeStreamingServer(server);
server.on('error', e => {
switch ((e as any).code) {
case 'EACCES':
serverLogger.error(`You do not have permission to listen on port ${config.port}.`);
break;
case 'EADDRINUSE':
serverLogger.error(`Port ${config.port} is already in use by another process.`);
break;
default:
serverLogger.error(e);
break;
}
if (cluster.isWorker) {
process.send!('listenFailed');
} else {
// disableClustering
process.exit(1);
}
});
server.listen(config.port, resolve);
});

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

@ -39,28 +39,24 @@ html {
width: 28px;
height: 28px;
transform: translateY(70px);
color: var(--accent);
}
#splashSpinner:before,
#splashSpinner:after {
content: " ";
display: block;
box-sizing: border-box;
width: 28px;
height: 28px;
border-radius: 50%;
border: solid 4px;
}
#splashSpinner:before {
border-color: currentColor;
opacity: 0.3;
}
#splashSpinner:after {
#splashSpinner > .spinner {
position: absolute;
top: 0;
border-color: currentColor transparent transparent transparent;
left: 0;
width: 28px;
height: 28px;
fill-rule: evenodd;
clip-rule: evenodd;
stroke-linecap: round;
stroke-linejoin: round;
stroke-miterlimit: 1.5;
}
#splashSpinner > .spinner.bg {
opacity: 0.275;
}
#splashSpinner > .spinner.fg {
animation: splashSpinner 0.5s linear infinite;
}

View File

@ -1,17 +1,23 @@
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 +36,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 +64,7 @@ html
script.
var VERSION = "#{version}";
var CLIENT_ENTRY = "#{clientEntry()}";
var CLIENT_ENTRY = "#{clientEntry.file}";
script
include ../boot.js
@ -65,4 +77,14 @@ html
div#splash
img#splashIcon(src= icon || '/static-assets/splash.png')
div#splashSpinner
<svg class="spinner bg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
<svg class="spinner fg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
block content

View File

@ -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);
}
}

View File

@ -1,5 +1,5 @@
import { renderActivity } from '@/remote/activitypub/renderer/index.js';
import renderBlock from '@/remote/activitypub/renderer/block.js';
import { renderBlock } from '@/remote/activitypub/renderer/block.js';
import renderUndo from '@/remote/activitypub/renderer/undo.js';
import { deliver } from '@/queue/index.js';
import Logger from '../logger.js';
@ -19,11 +19,16 @@ export default async function(blocker: CacheableUser, blockee: CacheableUser) {
return;
}
// Since we already have the blocker and blockee, we do not need to fetch
// them in the query above and can just manually insert them here.
blocking.blocker = blocker;
blocking.blockee = blockee;
Blockings.delete(blocking.id);
// deliver if remote bloking
if (Users.isLocalUser(blocker) && Users.isRemoteUser(blockee)) {
const content = renderActivity(renderUndo(renderBlock(blocker, blockee), blocker));
const content = renderActivity(renderUndo(renderBlock(blocking), blocker));
deliver(blocker, content, blockee.inbox);
}
}

View File

@ -91,27 +91,20 @@ type ToJsonSchema<S> = {
};
export function getJsonSchema<S extends Schema>(schema: S): ToJsonSchema<Unflatten<ChartResult<S>>> {
const object = {};
for (const [k, v] of Object.entries(schema)) {
nestedProperty.set(object, k, null);
}
const jsonSchema = {
type: 'object',
properties: {} as Record<string, unknown>,
required: [],
};
function f(obj: Record<string, null | Record<string, unknown>>) {
const jsonSchema = {
type: 'object',
properties: {} as Record<string, unknown>,
required: [],
for (const k in schema) {
jsonSchema.properties[k] = {
type: 'array',
items: { type: 'number' },
};
for (const [k, v] of Object.entries(obj)) {
jsonSchema.properties[k] = v === null ? {
type: 'array',
items: { type: 'number' },
} : f(v as Record<string, null | Record<string, unknown>>);
}
return jsonSchema;
}
return f(object) as ToJsonSchema<Unflatten<ChartResult<S>>>;
return jsonSchema as ToJsonSchema<Unflatten<ChartResult<S>>>;
}
/**

View File

@ -11,6 +11,11 @@ import { entity as PerUserFollowingChart } from './charts/entities/per-user-foll
import { entity as PerUserDriveChart } from './charts/entities/per-user-drive.js';
import { entity as ApRequestChart } from './charts/entities/ap-request.js';
import { entity as TestChart } from './charts/entities/test.js';
import { entity as TestGroupedChart } from './charts/entities/test-grouped.js';
import { entity as TestUniqueChart } from './charts/entities/test-unique.js';
import { entity as TestIntersectionChart } from './charts/entities/test-intersection.js';
export const entities = [
FederationChart.hour, FederationChart.day,
NotesChart.hour, NotesChart.day,
@ -24,4 +29,11 @@ export const entities = [
PerUserFollowingChart.hour, PerUserFollowingChart.day,
PerUserDriveChart.hour, PerUserDriveChart.day,
ApRequestChart.hour, ApRequestChart.day,
...(process.env.NODE_ENV === 'test' ? [
TestChart.hour, TestChart.day,
TestGroupedChart.hour, TestGroupedChart.day,
TestUniqueChart.hour, TestUniqueChart.day,
TestIntersectionChart.hour, TestIntersectionChart.day,
] : []),
];

View File

@ -1,38 +1,31 @@
import * as fs from 'node:fs';
import * as tmp from 'tmp';
import * as path from 'node:path';
import { createTemp } from '@/misc/create-temp.js';
import { IImage, convertToJpeg } from './image-processor.js';
import * as FFmpeg from 'fluent-ffmpeg';
import FFmpeg from 'fluent-ffmpeg';
export async function GenerateVideoThumbnail(path: string): Promise<IImage> {
const [outDir, cleanup] = await new Promise<[string, any]>((res, rej) => {
tmp.dir((e, path, cleanup) => {
if (e) return rej(e);
res([path, cleanup]);
export async function GenerateVideoThumbnail(source: string): Promise<IImage> {
const [file, cleanup] = await createTemp();
const parsed = path.parse(file);
try {
await new Promise((res, rej) => {
FFmpeg({
source,
})
.on('end', res)
.on('error', rej)
.screenshot({
folder: parsed.dir,
filename: parsed.base,
count: 1,
timestamps: ['5%'],
});
});
});
await new Promise((res, rej) => {
FFmpeg({
source: path,
})
.on('end', res)
.on('error', rej)
.screenshot({
folder: outDir,
filename: 'output.png',
count: 1,
timestamps: ['5%'],
});
});
const outPath = `${outDir}/output.png`;
// JPEGに変換 (Webpでもいいが、MastodonはWebpをサポートせず表示できなくなる)
const thumbnail = await convertToJpeg(outPath, 498, 280);
// cleanup
await fs.promises.unlink(outPath);
cleanup();
return thumbnail;
// JPEGに変換 (Webpでもいいが、MastodonはWebpをサポートせず表示できなくなる)
return await convertToJpeg(498, 280);
} finally {
cleanup();
}
}

View File

@ -45,29 +45,20 @@ export async function uploadFromUrl({
// Create temp file
const [path, cleanup] = await createTemp();
// write content at URL to temp file
await downloadUrl(url, path);
let driveFile: DriveFile;
let error;
try {
driveFile = await addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive });
// write content at URL to temp file
await downloadUrl(url, path);
const driveFile = await addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive });
logger.succ(`Got: ${driveFile.id}`);
return driveFile!;
} catch (e) {
error = e;
logger.error(`Failed to create drive file: ${e}`, {
url: url,
e: e,
});
}
// clean-up
cleanup();
if (error) {
throw error;
} else {
return driveFile!;
throw e;
} finally {
cleanup();
}
}

View File

@ -1,5 +1,6 @@
import { DOMWindow, JSDOM } from 'jsdom';
import fetch from 'node-fetch';
import tinycolor from 'tinycolor2';
import { getJson, getHtml, getAgentByUrl } from '@/misc/fetch.js';
import { Instance } from '@/models/entities/instance.js';
import { Instances } from '@/models/index.js';
@ -208,16 +209,11 @@ async function fetchIconUrl(instance: Instance, doc: DOMWindow['document'] | nul
}
async function getThemeColor(doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
if (doc) {
const themeColor = doc.querySelector('meta[name="theme-color"]')?.getAttribute('content');
const themeColor = doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') || manifest?.theme_color;
if (themeColor) {
return themeColor;
}
}
if (manifest) {
return manifest.theme_color;
if (themeColor) {
const color = new tinycolor(themeColor);
if (color.isValid()) return color.toHexString();
}
return null;

View File

@ -312,7 +312,8 @@ export default async (user: { id: User['id']; username: User['username']; host:
endedPollNotificationQueue.add({
noteId: note.id,
}, {
delay
delay,
removeOnComplete: true,
});
}

View File

@ -27,6 +27,11 @@ export default async (user: { id: User['id']; host: User['host']; }, note: Note,
}
}
// check visibility
if (!await Notes.isVisibleForMe(note, user.id)) {
throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.');
}
// TODO: cache
reaction = await toDbReaction(reaction, user.host);

View File

@ -1,4 +1,4 @@
import { createSystemUser } from './create-system-user.js';
import { IsNull } from 'typeorm';
import { renderFollowRelay } from '@/remote/activitypub/renderer/follow-relay.js';
import { renderActivity, attachLdSignature } from '@/remote/activitypub/renderer/index.js';
import renderUndo from '@/remote/activitypub/renderer/undo.js';
@ -8,7 +8,7 @@ import { Users, Relays } from '@/models/index.js';
import { genId } from '@/misc/gen-id.js';
import { Cache } from '@/misc/cache.js';
import { Relay } from '@/models/entities/relay.js';
import { IsNull } from 'typeorm';
import { createSystemUser } from './create-system-user.js';
const ACTOR_USERNAME = 'relay.actor' as const;
@ -88,7 +88,9 @@ export async function deliverToRelays(user: { id: User['id']; host: null; }, act
}));
if (relays.length === 0) return;
const copy = structuredClone(activity);
// TODO
//const copy = structuredClone(activity);
const copy = JSON.parse(JSON.stringify(activity));
if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public'];
const signed = await attachLdSignature(copy, user);

View File

@ -1,12 +1,14 @@
process.env.NODE_ENV = 'test';
import rndstr from 'rndstr';
import * as assert from 'assert';
import rndstr from 'rndstr';
import { initDb } from '../src/db/postgre.js';
import { initTestDb } from './utils.js';
describe('ActivityPub', () => {
before(async () => {
await initTestDb();
//await initTestDb();
await initDb();
});
describe('Parse minimum object', () => {
@ -57,8 +59,8 @@ describe('ActivityPub', () => {
const note = await createNote(post.id, resolver, true);
assert.deepStrictEqual(note?.uri, post.id);
assert.deepStrictEqual(note?.visibility, 'public');
assert.deepStrictEqual(note?.text, post.content);
assert.deepStrictEqual(note.visibility, 'public');
assert.deepStrictEqual(note.text, post.content);
});
});

View File

@ -1,7 +1,7 @@
import * as assert from 'assert';
import httpSignature from 'http-signature';
import { genRsaKeyPair } from '../src/misc/gen-key-pair.js';
import { createSignedPost, createSignedGet } from '../src/remote/activitypub/ap-request.js';
import httpSignature from 'http-signature';
export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => {
return {
@ -13,7 +13,7 @@ export const buildParsedSignature = (signingString: string, signature: string, a
signature: signature,
},
signingString: signingString,
algorithm: algorithm?.toUpperCase(),
algorithm: algorithm.toUpperCase(),
keyId: 'KeyID', // dummy, not used for verify
};
};
@ -26,7 +26,7 @@ describe('ap-request', () => {
const activity = { a: 1 };
const body = JSON.stringify(activity);
const headers = {
'User-Agent': 'UA'
'User-Agent': 'UA',
};
const req = createSignedPost({ key, url, body, additionalHeaders: headers });
@ -42,7 +42,7 @@ describe('ap-request', () => {
const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey };
const url = 'https://example.com/outbox';
const headers = {
'User-Agent': 'UA'
'User-Agent': 'UA',
};
const req = createSignedGet({ key, url, additionalHeaders: headers });

View File

@ -61,40 +61,40 @@ describe('API visibility', () => {
const show = async (noteId: any, by: any) => {
return await request('/notes/show', {
noteId
noteId,
}, by);
};
before(async () => {
//#region prepare
// signup
alice = await signup({ username: 'alice' });
alice = await signup({ username: 'alice' });
follower = await signup({ username: 'follower' });
other = await signup({ username: 'other' });
target = await signup({ username: 'target' });
target2 = await signup({ username: 'target2' });
other = await signup({ username: 'other' });
target = await signup({ username: 'target' });
target2 = await signup({ username: 'target2' });
// follow alice <= follower
await request('/following/create', { userId: alice.id }, follower);
// normal posts
pub = await post(alice, { text: 'x', visibility: 'public' });
pub = await post(alice, { text: 'x', visibility: 'public' });
home = await post(alice, { text: 'x', visibility: 'home' });
fol = await post(alice, { text: 'x', visibility: 'followers' });
spe = await post(alice, { text: 'x', visibility: 'specified', visibleUserIds: [target.id] });
fol = await post(alice, { text: 'x', visibility: 'followers' });
spe = await post(alice, { text: 'x', visibility: 'specified', visibleUserIds: [target.id] });
// replies
tgt = await post(target, { text: 'y', visibility: 'public' });
pubR = await post(alice, { text: 'x', replyId: tgt.id, visibility: 'public' });
pubR = await post(alice, { text: 'x', replyId: tgt.id, visibility: 'public' });
homeR = await post(alice, { text: 'x', replyId: tgt.id, visibility: 'home' });
folR = await post(alice, { text: 'x', replyId: tgt.id, visibility: 'followers' });
speR = await post(alice, { text: 'x', replyId: tgt.id, visibility: 'specified' });
folR = await post(alice, { text: 'x', replyId: tgt.id, visibility: 'followers' });
speR = await post(alice, { text: 'x', replyId: tgt.id, visibility: 'specified' });
// mentions
pubM = await post(alice, { text: '@target x', replyId: tgt.id, visibility: 'public' });
pubM = await post(alice, { text: '@target x', replyId: tgt.id, visibility: 'public' });
homeM = await post(alice, { text: '@target x', replyId: tgt.id, visibility: 'home' });
folM = await post(alice, { text: '@target x', replyId: tgt.id, visibility: 'followers' });
speM = await post(alice, { text: '@target2 x', replyId: tgt.id, visibility: 'specified' });
folM = await post(alice, { text: '@target x', replyId: tgt.id, visibility: 'followers' });
speM = await post(alice, { text: '@target2 x', replyId: tgt.id, visibility: 'specified' });
//#endregion
});

View File

@ -25,7 +25,7 @@ describe('Block', () => {
it('Block作成', async(async () => {
const res = await request('/blocking/create', {
userId: bob.id
userId: bob.id,
}, alice);
assert.strictEqual(res.status, 200);

View File

@ -2,30 +2,21 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as lolex from '@sinonjs/fake-timers';
import { async, initTestDb } from './utils.js';
import TestChart from '../src/services/chart/charts/test.js';
import TestGroupedChart from '../src/services/chart/charts/test-grouped.js';
import TestUniqueChart from '../src/services/chart/charts/test-unique.js';
import TestIntersectionChart from '../src/services/chart/charts/test-intersection.js';
import * as _TestChart from '../src/services/chart/charts/entities/test.js';
import * as _TestGroupedChart from '../src/services/chart/charts/entities/test-grouped.js';
import * as _TestUniqueChart from '../src/services/chart/charts/entities/test-unique.js';
import * as _TestIntersectionChart from '../src/services/chart/charts/entities/test-intersection.js';
import { initDb } from '../src/db/postgre.js';
describe('Chart', () => {
let testChart: TestChart;
let testGroupedChart: TestGroupedChart;
let testUniqueChart: TestUniqueChart;
let testIntersectionChart: TestIntersectionChart;
let clock: lolex.Clock;
let clock: lolex.InstalledClock;
beforeEach(async(async () => {
await initTestDb(false, [
_TestChart.entity.hour, _TestChart.entity.day,
_TestGroupedChart.entity.hour, _TestGroupedChart.entity.day,
_TestUniqueChart.entity.hour, _TestUniqueChart.entity.day,
_TestIntersectionChart.entity.hour, _TestIntersectionChart.entity.day,
]);
beforeEach(async () => {
await initDb(true);
testChart = new TestChart();
testGroupedChart = new TestGroupedChart();
@ -33,15 +24,16 @@ describe('Chart', () => {
testIntersectionChart = new TestIntersectionChart();
clock = lolex.install({
now: new Date(Date.UTC(2000, 0, 1, 0, 0, 0))
now: new Date(Date.UTC(2000, 0, 1, 0, 0, 0)),
shouldClearNativeTimers: true,
});
}));
});
afterEach(async(async () => {
afterEach(() => {
clock.uninstall();
}));
});
it('Can updates', async(async () => {
it('Can updates', async () => {
await testChart.increment();
await testChart.save();
@ -52,7 +44,7 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [1, 0, 0],
total: [1, 0, 0]
total: [1, 0, 0],
},
});
@ -60,12 +52,12 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [1, 0, 0],
total: [1, 0, 0]
total: [1, 0, 0],
},
});
}));
});
it('Can updates (dec)', async(async () => {
it('Can updates (dec)', async () => {
await testChart.decrement();
await testChart.save();
@ -76,7 +68,7 @@ describe('Chart', () => {
foo: {
dec: [1, 0, 0],
inc: [0, 0, 0],
total: [-1, 0, 0]
total: [-1, 0, 0],
},
});
@ -84,12 +76,12 @@ describe('Chart', () => {
foo: {
dec: [1, 0, 0],
inc: [0, 0, 0],
total: [-1, 0, 0]
total: [-1, 0, 0],
},
});
}));
});
it('Empty chart', async(async () => {
it('Empty chart', async () => {
const chartHours = await testChart.getChart('hour', 3, null);
const chartDays = await testChart.getChart('day', 3, null);
@ -97,7 +89,7 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [0, 0, 0],
total: [0, 0, 0]
total: [0, 0, 0],
},
});
@ -105,12 +97,12 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [0, 0, 0],
total: [0, 0, 0]
total: [0, 0, 0],
},
});
}));
});
it('Can updates at multiple times at same time', async(async () => {
it('Can updates at multiple times at same time', async () => {
await testChart.increment();
await testChart.increment();
await testChart.increment();
@ -123,7 +115,7 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [3, 0, 0],
total: [3, 0, 0]
total: [3, 0, 0],
},
});
@ -131,12 +123,12 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [3, 0, 0],
total: [3, 0, 0]
total: [3, 0, 0],
},
});
}));
});
it('複数回saveされてもデータの更新は一度だけ', async(async () => {
it('複数回saveされてもデータの更新は一度だけ', async () => {
await testChart.increment();
await testChart.save();
await testChart.save();
@ -149,7 +141,7 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [1, 0, 0],
total: [1, 0, 0]
total: [1, 0, 0],
},
});
@ -157,12 +149,12 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [1, 0, 0],
total: [1, 0, 0]
total: [1, 0, 0],
},
});
}));
});
it('Can updates at different times', async(async () => {
it('Can updates at different times', async () => {
await testChart.increment();
await testChart.save();
@ -178,7 +170,7 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [1, 1, 0],
total: [2, 1, 0]
total: [2, 1, 0],
},
});
@ -186,14 +178,14 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [2, 0, 0],
total: [2, 0, 0]
total: [2, 0, 0],
},
});
}));
});
// 仕様上はこうなってほしいけど、実装は難しそうなのでskip
/*
it('Can updates at different times without save', async(async () => {
it('Can updates at different times without save', async () => {
await testChart.increment();
clock.tick('01:00:00');
@ -219,10 +211,10 @@ describe('Chart', () => {
total: [2, 0, 0]
},
});
}));
});
*/
it('Can padding', async(async () => {
it('Can padding', async () => {
await testChart.increment();
await testChart.save();
@ -238,7 +230,7 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [1, 0, 1],
total: [2, 1, 1]
total: [2, 1, 1],
},
});
@ -246,13 +238,13 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [2, 0, 0],
total: [2, 0, 0]
total: [2, 0, 0],
},
});
}));
});
// 要求された範囲にログがひとつもない場合でもパディングできる
it('Can padding from past range', async(async () => {
it('Can padding from past range', async () => {
await testChart.increment();
await testChart.save();
@ -265,7 +257,7 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [0, 0, 0],
total: [1, 1, 1]
total: [1, 1, 1],
},
});
@ -273,14 +265,14 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [1, 0, 0],
total: [1, 0, 0]
total: [1, 0, 0],
},
});
}));
});
// 要求された範囲の最も古い箇所に位置するログが存在しない場合でもパディングできる
// Issue #3190
it('Can padding from past range 2', async(async () => {
it('Can padding from past range 2', async () => {
await testChart.increment();
await testChart.save();
@ -296,7 +288,7 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [1, 0, 0],
total: [2, 1, 1]
total: [2, 1, 1],
},
});
@ -304,12 +296,12 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [2, 0, 0],
total: [2, 0, 0]
total: [2, 0, 0],
},
});
}));
});
it('Can specify offset', async(async () => {
it('Can specify offset', async () => {
await testChart.increment();
await testChart.save();
@ -325,7 +317,7 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [1, 0, 0],
total: [1, 0, 0]
total: [1, 0, 0],
},
});
@ -333,12 +325,12 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [2, 0, 0],
total: [2, 0, 0]
total: [2, 0, 0],
},
});
}));
});
it('Can specify offset (floor time)', async(async () => {
it('Can specify offset (floor time)', async () => {
clock.tick('00:30:00');
await testChart.increment();
@ -356,7 +348,7 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [1, 0, 0],
total: [1, 0, 0]
total: [1, 0, 0],
},
});
@ -364,13 +356,13 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [2, 0, 0],
total: [2, 0, 0]
total: [2, 0, 0],
},
});
}));
});
describe('Grouped', () => {
it('Can updates', async(async () => {
it('Can updates', async () => {
await testGroupedChart.increment('alice');
await testGroupedChart.save();
@ -383,7 +375,7 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [1, 0, 0],
total: [1, 0, 0]
total: [1, 0, 0],
},
});
@ -391,7 +383,7 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [1, 0, 0],
total: [1, 0, 0]
total: [1, 0, 0],
},
});
@ -399,7 +391,7 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [0, 0, 0],
total: [0, 0, 0]
total: [0, 0, 0],
},
});
@ -407,14 +399,14 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [0, 0, 0],
total: [0, 0, 0]
total: [0, 0, 0],
},
});
}));
});
});
describe('Unique increment', () => {
it('Can updates', async(async () => {
it('Can updates', async () => {
await testUniqueChart.uniqueIncrement('alice');
await testUniqueChart.uniqueIncrement('alice');
await testUniqueChart.uniqueIncrement('bob');
@ -430,10 +422,10 @@ describe('Chart', () => {
assert.deepStrictEqual(chartDays, {
foo: [2, 0, 0],
});
}));
});
describe('Intersection', () => {
it('条件が満たされていない場合はカウントされない', async(async () => {
it('条件が満たされていない場合はカウントされない', async () => {
await testIntersectionChart.addA('alice');
await testIntersectionChart.addA('bob');
await testIntersectionChart.addB('carol');
@ -453,9 +445,9 @@ describe('Chart', () => {
b: [1, 0, 0],
aAndB: [0, 0, 0],
});
}));
});
it('条件が満たされている場合にカウントされる', async(async () => {
it('条件が満たされている場合にカウントされる', async () => {
await testIntersectionChart.addA('alice');
await testIntersectionChart.addA('bob');
await testIntersectionChart.addB('carol');
@ -476,12 +468,12 @@ describe('Chart', () => {
b: [2, 0, 0],
aAndB: [1, 0, 0],
});
}));
});
});
});
describe('Resync', () => {
it('Can resync', async(async () => {
it('Can resync', async () => {
testChart.total = 1;
await testChart.resync();
@ -493,7 +485,7 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [0, 0, 0],
total: [1, 0, 0]
total: [1, 0, 0],
},
});
@ -501,12 +493,12 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [0, 0, 0],
total: [1, 0, 0]
total: [1, 0, 0],
},
});
}));
});
it('Can resync (2)', async(async () => {
it('Can resync (2)', async () => {
await testChart.increment();
await testChart.save();
@ -523,7 +515,7 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [0, 1, 0],
total: [100, 1, 0]
total: [100, 1, 0],
},
});
@ -531,9 +523,9 @@ describe('Chart', () => {
foo: {
dec: [0, 0, 0],
inc: [1, 0, 0],
total: [100, 0, 0]
total: [100, 0, 0],
},
});
}));
});
});
});

View File

@ -1,7 +1,7 @@
import * as assert from 'assert';
import { extractMentions } from '../src/misc/extract-mentions.js';
import { parse } from 'mfm-js';
import { extractMentions } from '../src/misc/extract-mentions.js';
describe('Extract mentions', () => {
it('simple', () => {
@ -10,15 +10,15 @@ describe('Extract mentions', () => {
assert.deepStrictEqual(mentions, [{
username: 'foo',
acct: '@foo',
host: null
host: null,
}, {
username: 'bar',
acct: '@bar',
host: null
host: null,
}, {
username: 'baz',
acct: '@baz',
host: null
host: null,
}]);
});
@ -28,15 +28,15 @@ describe('Extract mentions', () => {
assert.deepStrictEqual(mentions, [{
username: 'foo',
acct: '@foo',
host: null
host: null,
}, {
username: 'bar',
acct: '@bar',
host: null
host: null,
}, {
username: 'baz',
acct: '@baz',
host: null
host: null,
}]);
});
});

View File

@ -2,8 +2,8 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as childProcess from 'child_process';
import { async, startServer, signup, post, request, simpleGet, port, shutdownServer } from './utils.js';
import * as openapi from '@redocly/openapi-core';
import { async, startServer, signup, post, request, simpleGet, port, shutdownServer } from './utils.js';
// Request Accept
const ONLY_AP = 'application/activity+json';
@ -26,7 +26,7 @@ describe('Fetch resource', () => {
p = await startServer();
alice = await signup({ username: 'alice' });
alicesPost = await post(alice, {
text: 'test'
text: 'test',
});
});
@ -70,7 +70,7 @@ describe('Fetch resource', () => {
const config = await openapi.loadConfig();
const result = await openapi.bundle({
config,
ref: `http://localhost:${port}/api.json`
ref: `http://localhost:${port}/api.json`,
});
for (const problem of result.problems) {

View File

@ -18,7 +18,7 @@ describe('Get file info', () => {
md5: 'd41d8cd98f00b204e9800998ecf8427e',
type: {
mime: 'application/octet-stream',
ext: null
ext: null,
},
width: undefined,
height: undefined,
@ -36,7 +36,7 @@ describe('Get file info', () => {
md5: '091b3f259662aa31e2ffef4519951168',
type: {
mime: 'image/jpeg',
ext: 'jpg'
ext: 'jpg',
},
width: 512,
height: 512,
@ -54,7 +54,7 @@ describe('Get file info', () => {
md5: '08189c607bea3b952704676bb3c979e0',
type: {
mime: 'image/apng',
ext: 'apng'
ext: 'apng',
},
width: 256,
height: 256,
@ -72,7 +72,7 @@ describe('Get file info', () => {
md5: '32c47a11555675d9267aee1a86571e7e',
type: {
mime: 'image/gif',
ext: 'gif'
ext: 'gif',
},
width: 256,
height: 256,
@ -90,7 +90,7 @@ describe('Get file info', () => {
md5: 'f73535c3e1e27508885b69b10cf6e991',
type: {
mime: 'image/png',
ext: 'png'
ext: 'png',
},
width: 256,
height: 256,
@ -108,7 +108,7 @@ describe('Get file info', () => {
md5: 'b6f52b4b021e7b92cdd04509c7267965',
type: {
mime: 'image/svg+xml',
ext: 'svg'
ext: 'svg',
},
width: 256,
height: 256,
@ -127,7 +127,7 @@ describe('Get file info', () => {
md5: '4b7a346cde9ccbeb267e812567e33397',
type: {
mime: 'image/svg+xml',
ext: 'svg'
ext: 'svg',
},
width: 256,
height: 256,
@ -145,7 +145,7 @@ describe('Get file info', () => {
md5: '268c5dde99e17cf8fe09f1ab3f97df56',
type: {
mime: 'application/octet-stream', // do not treat as image
ext: null
ext: null,
},
width: 25000,
height: 25000,
@ -163,7 +163,7 @@ describe('Get file info', () => {
md5: '68d5b2d8d1d1acbbce99203e3ec3857e',
type: {
mime: 'image/jpeg',
ext: 'jpg'
ext: 'jpg',
},
width: 512,
height: 256,

View File

@ -11,7 +11,7 @@ export class MockResolver extends Resolver {
public async _register(uri: string, content: string | Record<string, any>, type = 'application/activity+json') {
this._rs.set(uri, {
type,
content: typeof content === 'string' ? content : JSON.stringify(content)
content: typeof content === 'string' ? content : JSON.stringify(content),
});
}
@ -22,9 +22,9 @@ export class MockResolver extends Resolver {
if (!r) {
throw {
name: `StatusError`,
name: 'StatusError',
statusCode: 404,
message: `Not registed for mock`
message: 'Not registed for mock',
};
}

View File

@ -25,7 +25,7 @@ describe('Mute', () => {
it('ミュート作成', async(async () => {
const res = await request('/mute/create', {
userId: carol.id
userId: carol.id,
}, alice);
assert.strictEqual(res.status, 204);
@ -117,7 +117,7 @@ describe('Mute', () => {
const aliceNote = await post(alice);
const carolNote = await post(carol);
const bobNote = await post(bob, {
renoteId: carolNote.id
renoteId: carolNote.id,
});
const res = await request('/notes/local-timeline', {}, alice);

View File

@ -2,8 +2,8 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as childProcess from 'child_process';
import { async, signup, request, post, uploadFile, startServer, shutdownServer, initTestDb } from './utils.js';
import { Note } from '../src/models/entities/note.js';
import { async, signup, request, post, uploadFile, startServer, shutdownServer, initTestDb } from './utils.js';
describe('Note', () => {
let p: childProcess.ChildProcess;
@ -26,7 +26,7 @@ describe('Note', () => {
it('投稿できる', async(async () => {
const post = {
text: 'test'
text: 'test',
};
const res = await request('/notes/create', post, alice);
@ -40,7 +40,7 @@ describe('Note', () => {
const file = await uploadFile(alice);
const res = await request('/notes/create', {
fileIds: [file.id]
fileIds: [file.id],
}, alice);
assert.strictEqual(res.status, 200);
@ -53,7 +53,7 @@ describe('Note', () => {
const res = await request('/notes/create', {
text: 'test',
fileIds: [file.id]
fileIds: [file.id],
}, alice);
assert.strictEqual(res.status, 200);
@ -64,7 +64,7 @@ describe('Note', () => {
it('存在しないファイルは無視', async(async () => {
const res = await request('/notes/create', {
text: 'test',
fileIds: ['000000000000000000000000']
fileIds: ['000000000000000000000000'],
}, alice);
assert.strictEqual(res.status, 200);
@ -74,19 +74,19 @@ describe('Note', () => {
it('不正なファイルIDで怒られる', async(async () => {
const res = await request('/notes/create', {
fileIds: ['kyoppie']
fileIds: ['kyoppie'],
}, alice);
assert.strictEqual(res.status, 400);
}));
it('返信できる', async(async () => {
const bobPost = await post(bob, {
text: 'foo'
text: 'foo',
});
const alicePost = {
text: 'bar',
replyId: bobPost.id
replyId: bobPost.id,
};
const res = await request('/notes/create', alicePost, alice);
@ -100,11 +100,11 @@ describe('Note', () => {
it('renoteできる', async(async () => {
const bobPost = await post(bob, {
text: 'test'
text: 'test',
});
const alicePost = {
renoteId: bobPost.id
renoteId: bobPost.id,
};
const res = await request('/notes/create', alicePost, alice);
@ -117,12 +117,12 @@ describe('Note', () => {
it('引用renoteできる', async(async () => {
const bobPost = await post(bob, {
text: 'test'
text: 'test',
});
const alicePost = {
text: 'test',
renoteId: bobPost.id
renoteId: bobPost.id,
};
const res = await request('/notes/create', alicePost, alice);
@ -136,7 +136,7 @@ describe('Note', () => {
it('文字数ぎりぎりで怒られない', async(async () => {
const post = {
text: '!'.repeat(500)
text: '!'.repeat(500),
};
const res = await request('/notes/create', post, alice);
assert.strictEqual(res.status, 200);
@ -144,7 +144,7 @@ describe('Note', () => {
it('文字数オーバーで怒られる', async(async () => {
const post = {
text: '!'.repeat(501)
text: '!'.repeat(501),
};
const res = await request('/notes/create', post, alice);
assert.strictEqual(res.status, 400);
@ -153,7 +153,7 @@ describe('Note', () => {
it('存在しないリプライ先で怒られる', async(async () => {
const post = {
text: 'test',
replyId: '000000000000000000000000'
replyId: '000000000000000000000000',
};
const res = await request('/notes/create', post, alice);
assert.strictEqual(res.status, 400);
@ -161,7 +161,7 @@ describe('Note', () => {
it('存在しないrenote対象で怒られる', async(async () => {
const post = {
renoteId: '000000000000000000000000'
renoteId: '000000000000000000000000',
};
const res = await request('/notes/create', post, alice);
assert.strictEqual(res.status, 400);
@ -170,7 +170,7 @@ describe('Note', () => {
it('不正なリプライ先IDで怒られる', async(async () => {
const post = {
text: 'test',
replyId: 'foo'
replyId: 'foo',
};
const res = await request('/notes/create', post, alice);
assert.strictEqual(res.status, 400);
@ -178,7 +178,7 @@ describe('Note', () => {
it('不正なrenote対象IDで怒られる', async(async () => {
const post = {
renoteId: 'foo'
renoteId: 'foo',
};
const res = await request('/notes/create', post, alice);
assert.strictEqual(res.status, 400);
@ -186,7 +186,7 @@ describe('Note', () => {
it('存在しないユーザーにメンションできる', async(async () => {
const post = {
text: '@ghost yo'
text: '@ghost yo',
};
const res = await request('/notes/create', post, alice);
@ -198,7 +198,7 @@ describe('Note', () => {
it('同じユーザーに複数メンションしても内部的にまとめられる', async(async () => {
const post = {
text: '@bob @bob @bob yo'
text: '@bob @bob @bob yo',
};
const res = await request('/notes/create', post, alice);
@ -216,8 +216,8 @@ describe('Note', () => {
const res = await request('/notes/create', {
text: 'test',
poll: {
choices: ['foo', 'bar']
}
choices: ['foo', 'bar'],
},
}, alice);
assert.strictEqual(res.status, 200);
@ -227,7 +227,7 @@ describe('Note', () => {
it('投票の選択肢が無くて怒られる', async(async () => {
const res = await request('/notes/create', {
poll: {}
poll: {},
}, alice);
assert.strictEqual(res.status, 400);
}));
@ -235,8 +235,8 @@ describe('Note', () => {
it('投票の選択肢が無くて怒られる (空の配列)', async(async () => {
const res = await request('/notes/create', {
poll: {
choices: []
}
choices: [],
},
}, alice);
assert.strictEqual(res.status, 400);
}));
@ -244,8 +244,8 @@ describe('Note', () => {
it('投票の選択肢が1つで怒られる', async(async () => {
const res = await request('/notes/create', {
poll: {
choices: ['Strawberry Pasta']
}
choices: ['Strawberry Pasta'],
},
}, alice);
assert.strictEqual(res.status, 400);
}));
@ -254,13 +254,13 @@ describe('Note', () => {
const { body } = await request('/notes/create', {
text: 'test',
poll: {
choices: ['sakura', 'izumi', 'ako']
}
choices: ['sakura', 'izumi', 'ako'],
},
}, alice);
const res = await request('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 1
choice: 1,
}, alice);
assert.strictEqual(res.status, 204);
@ -270,18 +270,18 @@ describe('Note', () => {
const { body } = await request('/notes/create', {
text: 'test',
poll: {
choices: ['sakura', 'izumi', 'ako']
}
choices: ['sakura', 'izumi', 'ako'],
},
}, alice);
await request('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 0
choice: 0,
}, alice);
const res = await request('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 2
choice: 2,
}, alice);
assert.strictEqual(res.status, 400);
@ -292,23 +292,23 @@ describe('Note', () => {
text: 'test',
poll: {
choices: ['sakura', 'izumi', 'ako'],
multiple: true
}
multiple: true,
},
}, alice);
await request('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 0
choice: 0,
}, alice);
await request('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 1
choice: 1,
}, alice);
const res = await request('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 2
choice: 2,
}, alice);
assert.strictEqual(res.status, 204);
@ -319,15 +319,15 @@ describe('Note', () => {
text: 'test',
poll: {
choices: ['sakura', 'izumi', 'ako'],
expiredAfter: 1
}
expiredAfter: 1,
},
}, alice);
await new Promise(x => setTimeout(x, 2));
const res = await request('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 1
choice: 1,
}, alice);
assert.strictEqual(res.status, 400);
@ -341,11 +341,11 @@ describe('Note', () => {
}, alice);
const replyOneRes = await request('/notes/create', {
text: 'reply one',
replyId: mainNoteRes.body.createdNote.id
replyId: mainNoteRes.body.createdNote.id,
}, alice);
const replyTwoRes = await request('/notes/create', {
text: 'reply two',
replyId: mainNoteRes.body.createdNote.id
replyId: mainNoteRes.body.createdNote.id,
}, alice);
const deleteOneRes = await request('/notes/delete', {
@ -353,7 +353,7 @@ describe('Note', () => {
}, alice);
assert.strictEqual(deleteOneRes.status, 204);
let mainNote = await Notes.findOne({id: mainNoteRes.body.createdNote.id});
let mainNote = await Notes.findOne({ id: mainNoteRes.body.createdNote.id });
assert.strictEqual(mainNote.repliesCount, 1);
const deleteTwoRes = await request('/notes/delete', {
@ -361,7 +361,7 @@ describe('Note', () => {
}, alice);
assert.strictEqual(deleteTwoRes.status, 204);
mainNote = await Notes.findOne({id: mainNoteRes.body.createdNote.id});
mainNote = await Notes.findOne({ id: mainNoteRes.body.createdNote.id });
assert.strictEqual(mainNote.repliesCount, 0);
}));
});

View File

@ -6,7 +6,7 @@ describe('url', () => {
const s = query({
foo: 'ふぅ',
bar: 'b a r',
baz: undefined
baz: undefined,
});
assert.deepStrictEqual(s, 'foo=%E3%81%B5%E3%81%85&bar=b%20a%20r');
});

View File

@ -2,8 +2,8 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as childProcess from 'child_process';
import { connectStream, signup, request, post, startServer, shutdownServer, initTestDb } from './utils.js';
import { Following } from '../src/models/entities/following.js';
import { connectStream, signup, request, post, startServer, shutdownServer, initTestDb } from './utils.js';
describe('Streaming', () => {
let p: childProcess.ChildProcess;
@ -30,7 +30,7 @@ describe('Streaming', () => {
followerSharedInbox: null,
followeeHost: followee.host,
followeeInbox: null,
followeeSharedInbox: null
followeeSharedInbox: null,
});
};
@ -47,7 +47,7 @@ describe('Streaming', () => {
});
post(alice, {
text: 'foo @bob bar'
text: 'foo @bob bar',
});
}));
@ -55,7 +55,7 @@ describe('Streaming', () => {
const alice = await signup({ username: 'alice' });
const bob = await signup({ username: 'bob' });
const bobNote = await post(bob, {
text: 'foo'
text: 'foo',
});
const ws = await connectStream(bob, 'main', ({ type, body }) => {
@ -67,14 +67,14 @@ describe('Streaming', () => {
});
post(alice, {
renoteId: bobNote.id
renoteId: bobNote.id,
});
}));
describe('Home Timeline', () => {
it('自分の投稿が流れる', () => new Promise(async done => {
const post = {
text: 'foo'
text: 'foo',
};
const me = await signup();
@ -96,7 +96,7 @@ describe('Streaming', () => {
// Alice が Bob をフォロー
await request('/following/create', {
userId: bob.id
userId: bob.id,
}, alice);
const ws = await connectStream(alice, 'homeTimeline', ({ type, body }) => {
@ -108,7 +108,7 @@ describe('Streaming', () => {
});
post(bob, {
text: 'foo'
text: 'foo',
});
}));
@ -125,7 +125,7 @@ describe('Streaming', () => {
});
post(bob, {
text: 'foo'
text: 'foo',
});
setTimeout(() => {
@ -141,7 +141,7 @@ describe('Streaming', () => {
// Alice が Bob をフォロー
await request('/following/create', {
userId: bob.id
userId: bob.id,
}, alice);
const ws = await connectStream(alice, 'homeTimeline', ({ type, body }) => {
@ -157,7 +157,7 @@ describe('Streaming', () => {
post(bob, {
text: 'foo',
visibility: 'specified',
visibleUserIds: [alice.id]
visibleUserIds: [alice.id],
});
}));
@ -168,7 +168,7 @@ describe('Streaming', () => {
// Alice が Bob をフォロー
await request('/following/create', {
userId: bob.id
userId: bob.id,
}, alice);
let fired = false;
@ -183,7 +183,7 @@ describe('Streaming', () => {
post(bob, {
text: 'foo',
visibility: 'specified',
visibleUserIds: [carol.id]
visibleUserIds: [carol.id],
});
setTimeout(() => {
@ -207,7 +207,7 @@ describe('Streaming', () => {
});
post(me, {
text: 'foo'
text: 'foo',
});
}));
@ -224,7 +224,7 @@ describe('Streaming', () => {
});
post(bob, {
text: 'foo'
text: 'foo',
});
}));
@ -241,7 +241,7 @@ describe('Streaming', () => {
});
post(bob, {
text: 'foo'
text: 'foo',
});
setTimeout(() => {
@ -257,7 +257,7 @@ describe('Streaming', () => {
// Alice が Bob をフォロー
await request('/following/create', {
userId: bob.id
userId: bob.id,
}, alice);
let fired = false;
@ -269,7 +269,7 @@ describe('Streaming', () => {
});
post(bob, {
text: 'foo'
text: 'foo',
});
setTimeout(() => {
@ -294,7 +294,7 @@ describe('Streaming', () => {
// ホーム指定
post(bob, {
text: 'foo',
visibility: 'home'
visibility: 'home',
});
setTimeout(() => {
@ -310,7 +310,7 @@ describe('Streaming', () => {
// Alice が Bob をフォロー
await request('/following/create', {
userId: bob.id
userId: bob.id,
}, alice);
let fired = false;
@ -325,7 +325,7 @@ describe('Streaming', () => {
post(bob, {
text: 'foo',
visibility: 'specified',
visibleUserIds: [alice.id]
visibleUserIds: [alice.id],
});
setTimeout(() => {
@ -350,7 +350,7 @@ describe('Streaming', () => {
// フォロワー宛て投稿
post(bob, {
text: 'foo',
visibility: 'followers'
visibility: 'followers',
});
setTimeout(() => {
@ -374,7 +374,7 @@ describe('Streaming', () => {
});
post(me, {
text: 'foo'
text: 'foo',
});
}));
@ -391,7 +391,7 @@ describe('Streaming', () => {
});
post(bob, {
text: 'foo'
text: 'foo',
});
}));
@ -411,7 +411,7 @@ describe('Streaming', () => {
});
post(bob, {
text: 'foo'
text: 'foo',
});
}));
@ -428,7 +428,7 @@ describe('Streaming', () => {
});
post(bob, {
text: 'foo'
text: 'foo',
});
setTimeout(() => {
@ -444,7 +444,7 @@ describe('Streaming', () => {
// Alice が Bob をフォロー
await request('/following/create', {
userId: bob.id
userId: bob.id,
}, alice);
const ws = await connectStream(alice, 'hybridTimeline', ({ type, body }) => {
@ -460,7 +460,7 @@ describe('Streaming', () => {
post(bob, {
text: 'foo',
visibility: 'specified',
visibleUserIds: [alice.id]
visibleUserIds: [alice.id],
});
}));
@ -470,7 +470,7 @@ describe('Streaming', () => {
// Alice が Bob をフォロー
await request('/following/create', {
userId: bob.id
userId: bob.id,
}, alice);
const ws = await connectStream(alice, 'hybridTimeline', ({ type, body }) => {
@ -485,7 +485,7 @@ describe('Streaming', () => {
// ホーム投稿
post(bob, {
text: 'foo',
visibility: 'home'
visibility: 'home',
});
}));
@ -504,7 +504,7 @@ describe('Streaming', () => {
// ホーム投稿
post(bob, {
text: 'foo',
visibility: 'home'
visibility: 'home',
});
setTimeout(() => {
@ -529,7 +529,7 @@ describe('Streaming', () => {
// フォロワー宛て投稿
post(bob, {
text: 'foo',
visibility: 'followers'
visibility: 'followers',
});
setTimeout(() => {
@ -554,7 +554,7 @@ describe('Streaming', () => {
});
post(bob, {
text: 'foo'
text: 'foo',
});
}));
@ -571,7 +571,7 @@ describe('Streaming', () => {
});
post(bob, {
text: 'foo'
text: 'foo',
});
}));
@ -590,7 +590,7 @@ describe('Streaming', () => {
// ホーム投稿
post(bob, {
text: 'foo',
visibility: 'home'
visibility: 'home',
});
setTimeout(() => {
@ -608,13 +608,13 @@ describe('Streaming', () => {
// リスト作成
const list = await request('/users/lists/create', {
name: 'my list'
name: 'my list',
}, alice).then(x => x.body);
// Alice が Bob をリスイン
await request('/users/lists/push', {
listId: list.id,
userId: bob.id
userId: bob.id,
}, alice);
const ws = await connectStream(alice, 'userList', ({ type, body }) => {
@ -624,11 +624,11 @@ describe('Streaming', () => {
done();
}
}, {
listId: list.id
listId: list.id,
});
post(bob, {
text: 'foo'
text: 'foo',
});
}));
@ -638,7 +638,7 @@ describe('Streaming', () => {
// リスト作成
const list = await request('/users/lists/create', {
name: 'my list'
name: 'my list',
}, alice).then(x => x.body);
let fired = false;
@ -648,11 +648,11 @@ describe('Streaming', () => {
fired = true;
}
}, {
listId: list.id
listId: list.id,
});
post(bob, {
text: 'foo'
text: 'foo',
});
setTimeout(() => {
@ -669,13 +669,13 @@ describe('Streaming', () => {
// リスト作成
const list = await request('/users/lists/create', {
name: 'my list'
name: 'my list',
}, alice).then(x => x.body);
// Alice が Bob をリスイン
await request('/users/lists/push', {
listId: list.id,
userId: bob.id
userId: bob.id,
}, alice);
const ws = await connectStream(alice, 'userList', ({ type, body }) => {
@ -686,14 +686,14 @@ describe('Streaming', () => {
done();
}
}, {
listId: list.id
listId: list.id,
});
// Bob が Alice 宛てのダイレクト投稿
post(bob, {
text: 'foo',
visibility: 'specified',
visibleUserIds: [alice.id]
visibleUserIds: [alice.id],
});
}));
@ -704,13 +704,13 @@ describe('Streaming', () => {
// リスト作成
const list = await request('/users/lists/create', {
name: 'my list'
name: 'my list',
}, alice).then(x => x.body);
// Alice が Bob をリスイン
await request('/users/lists/push', {
listId: list.id,
userId: bob.id
userId: bob.id,
}, alice);
let fired = false;
@ -720,13 +720,13 @@ describe('Streaming', () => {
fired = true;
}
}, {
listId: list.id
listId: list.id,
});
// フォロワー宛て投稿
post(bob, {
text: 'foo',
visibility: 'followers'
visibility: 'followers',
});
setTimeout(() => {
@ -749,12 +749,12 @@ describe('Streaming', () => {
}
}, {
q: [
['foo']
]
['foo'],
],
});
post(me, {
text: '#foo'
text: '#foo',
});
}));
@ -773,20 +773,20 @@ describe('Streaming', () => {
}
}, {
q: [
['foo', 'bar']
]
['foo', 'bar'],
],
});
post(me, {
text: '#foo'
text: '#foo',
});
post(me, {
text: '#bar'
text: '#bar',
});
post(me, {
text: '#foo #bar'
text: '#foo #bar',
});
setTimeout(() => {
@ -816,24 +816,24 @@ describe('Streaming', () => {
}, {
q: [
['foo'],
['bar']
]
['bar'],
],
});
post(me, {
text: '#foo'
text: '#foo',
});
post(me, {
text: '#bar'
text: '#bar',
});
post(me, {
text: '#foo #bar'
text: '#foo #bar',
});
post(me, {
text: '#piyo'
text: '#piyo',
});
setTimeout(() => {
@ -866,28 +866,28 @@ describe('Streaming', () => {
}, {
q: [
['foo', 'bar'],
['piyo']
]
['piyo'],
],
});
post(me, {
text: '#foo'
text: '#foo',
});
post(me, {
text: '#bar'
text: '#bar',
});
post(me, {
text: '#foo #bar'
text: '#foo #bar',
});
post(me, {
text: '#piyo'
text: '#piyo',
});
post(me, {
text: '#waaa'
text: '#waaa',
});
setTimeout(() => {

View File

@ -23,13 +23,13 @@ describe('users/notes', () => {
const jpg = await uploadFile(alice, _dirname + '/resources/Lenna.jpg');
const png = await uploadFile(alice, _dirname + '/resources/Lenna.png');
jpgNote = await post(alice, {
fileIds: [jpg.id]
fileIds: [jpg.id],
});
pngNote = await post(alice, {
fileIds: [png.id]
fileIds: [png.id],
});
jpgPngNote = await post(alice, {
fileIds: [jpg.id, png.id]
fileIds: [jpg.id, png.id],
});
});
@ -40,7 +40,7 @@ describe('users/notes', () => {
it('ファイルタイプ指定 (jpg)', async(async () => {
const res = await request('/users/notes', {
userId: alice.id,
fileType: ['image/jpeg']
fileType: ['image/jpeg'],
}, alice);
assert.strictEqual(res.status, 200);
@ -53,7 +53,7 @@ describe('users/notes', () => {
it('ファイルタイプ指定 (jpg or png)', async(async () => {
const res = await request('/users/notes', {
userId: alice.id,
fileType: ['image/jpeg', 'image/png']
fileType: ['image/jpeg', 'image/png'],
}, alice);
assert.strictEqual(res.status, 200);

View File

@ -1,14 +1,15 @@
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import * as childProcess from 'child_process';
import * as http from 'node:http';
import { SIGKILL } from 'constants';
import * as WebSocket from 'ws';
import * as misskey from 'misskey-js';
import fetch from 'node-fetch';
import FormData from 'form-data';
import * as childProcess from 'child_process';
import * as http from 'node:http';
import { DataSource } from 'typeorm';
import loadConfig from '../src/config/load.js';
import { SIGKILL } from 'constants';
import { entities } from '../src/db/postgre.js';
const _filename = fileURLToPath(import.meta.url);
@ -27,29 +28,29 @@ export const async = (fn: Function) => (done: Function) => {
export const request = async (endpoint: string, params: any, me?: any): Promise<{ body: any, status: number }> => {
const auth = me ? {
i: me.token
i: me.token,
} : {};
const res = await fetch(`http://localhost:${port}/api${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
},
body: JSON.stringify(Object.assign(auth, params))
body: JSON.stringify(Object.assign(auth, params)),
});
const status = res.status;
const body = res.status !== 204 ? await res.json().catch() : null;
return {
body, status
body, status,
};
};
export const signup = async (params?: any): Promise<any> => {
const q = Object.assign({
username: 'test',
password: 'test'
password: 'test',
}, params);
const res = await request('/signup', q);
@ -59,7 +60,7 @@ export const signup = async (params?: any): Promise<any> => {
export const post = async (user: any, params?: misskey.Endpoints['notes/create']['req']): Promise<misskey.entities.Note> => {
const q = Object.assign({
text: 'test'
text: 'test',
}, params);
const res = await request('/notes/create', q, user);
@ -70,26 +71,26 @@ export const post = async (user: any, params?: misskey.Endpoints['notes/create']
export const react = async (user: any, note: any, reaction: string): Promise<any> => {
await request('/notes/reactions/create', {
noteId: note.id,
reaction: reaction
reaction: reaction,
}, user);
};
export const uploadFile = (user: any, path?: string): Promise<any> => {
const formData = new FormData();
formData.append('i', user.token);
formData.append('file', fs.createReadStream(path || _dirname + '/resources/Lenna.png'));
const formData = new FormData();
formData.append('i', user.token);
formData.append('file', fs.createReadStream(path || _dirname + '/resources/Lenna.png'));
return fetch(`http://localhost:${port}/api/drive/files/create`, {
method: 'post',
body: formData,
timeout: 30 * 1000,
}).then(res => {
if (!res.ok) {
throw `${res.status} ${res.statusText}`;
} else {
return res.json();
}
});
return fetch(`http://localhost:${port}/api/drive/files/create`, {
method: 'post',
body: formData,
timeout: 30 * 1000,
}).then(res => {
if (!res.ok) {
throw `${res.status} ${res.statusText}`;
} else {
return res.json();
}
});
};
export function connectStream(user: any, channel: string, listener: (message: Record<string, any>) => any, params?: any): Promise<WebSocket> {
@ -99,9 +100,9 @@ export function connectStream(user: any, channel: string, listener: (message: Re
ws.on('open', () => {
ws.on('message', data => {
const msg = JSON.parse(data.toString());
if (msg.type == 'channel' && msg.body.id == 'a') {
if (msg.type === 'channel' && msg.body.id === 'a') {
listener(msg.body);
} else if (msg.type == 'connected' && msg.body.id == 'a') {
} else if (msg.type === 'connected' && msg.body.id === 'a') {
res(ws);
}
});
@ -112,8 +113,8 @@ export function connectStream(user: any, channel: string, listener: (message: Re
channel: channel,
id: 'a',
pong: true,
params: params
}
params: params,
},
}));
});
});
@ -124,8 +125,8 @@ export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status?
return await new Promise((resolve, reject) => {
const req = http.request(`http://localhost:${port}${path}`, {
headers: {
Accept: accept
}
Accept: accept,
},
}, res => {
if (res.statusCode! >= 400) {
reject(res);
@ -146,7 +147,7 @@ export function launchServer(callbackSpawnedProcess: (p: childProcess.ChildProce
return (done: (err?: Error) => any) => {
const p = childProcess.spawn('node', [_dirname + '/../index.js'], {
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
env: { NODE_ENV: 'test', PATH: process.env.PATH }
env: { NODE_ENV: 'test', PATH: process.env.PATH },
});
callbackSpawnedProcess(p);
p.on('message', message => {
@ -158,12 +159,7 @@ export function launchServer(callbackSpawnedProcess: (p: childProcess.ChildProce
export async function initTestDb(justBorrow = false, initEntities?: any[]) {
if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test';
try {
const conn = await getConnection();
await conn.close();
} catch (e) {}
return await createConnection({
const db = new DataSource({
type: 'postgres',
host: config.db.host,
port: config.db.port,
@ -172,8 +168,12 @@ export async function initTestDb(justBorrow = false, initEntities?: any[]) {
database: config.db.db,
synchronize: true && !justBorrow,
dropSchema: true && !justBorrow,
entities: initEntities || entities
entities: initEntities || entities,
});
await db.initialize();
return db;
}
export function startServer(timeout = 30 * 1000): Promise<childProcess.ChildProcess> {
@ -185,7 +185,7 @@ export function startServer(timeout = 30 * 1000): Promise<childProcess.ChildProc
const p = childProcess.spawn('node', [_dirname + '/../built/index.js'], {
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
env: { NODE_ENV: 'test', PATH: process.env.PATH }
env: { NODE_ENV: 'test', PATH: process.env.PATH },
});
p.on('error', e => rej(e));

File diff suppressed because it is too large Load Diff

View File

@ -1,68 +1,79 @@
module.exports = {
root: true,
env: {
"node": false
'node': false,
},
parser: "vue-eslint-parser",
parser: 'vue-eslint-parser',
parserOptions: {
"parser": "@typescript-eslint/parser",
'parser': '@typescript-eslint/parser',
tsconfigRootDir: __dirname,
//project: ['./tsconfig.json'],
project: ['./tsconfig.json'],
extraFileExtensions: ['.vue'],
},
extends: [
//"../shared/.eslintrc.js",
"plugin:vue/vue3-recommended"
'../shared/.eslintrc.js',
'plugin:vue/vue3-recommended',
],
rules: {
// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため
// data の禁止理由: 抽象的すぎるため
// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
"id-denylist": ["error", "window", "data", "e"],
'id-denylist': ['error', 'window', 'data', 'e'],
'eqeqeq': ['error', 'always', { 'null': 'ignore' }],
"no-shadow": ["warn"],
"vue/attributes-order": ["error", {
"alphabetical": false
'no-shadow': ['warn'],
'vue/attributes-order': ['error', {
'alphabetical': false,
}],
"vue/no-use-v-if-with-v-for": ["error", {
"allowUsingIterationVar": false
'vue/no-use-v-if-with-v-for': ['error', {
'allowUsingIterationVar': false,
}],
"vue/no-ref-as-operand": "error",
"vue/no-multi-spaces": ["error", {
"ignoreProperties": false
'vue/no-ref-as-operand': 'error',
'vue/no-multi-spaces': ['error', {
'ignoreProperties': false,
}],
"vue/no-v-html": "error",
"vue/order-in-components": "error",
"vue/html-indent": ["warn", "tab", {
"attribute": 1,
"baseIndent": 0,
"closeBracket": 0,
"alignAttributesVertically": true,
"ignores": []
'vue/no-v-html': 'error',
'vue/order-in-components': 'error',
'vue/html-indent': ['warn', 'tab', {
'attribute': 1,
'baseIndent': 0,
'closeBracket': 0,
'alignAttributesVertically': true,
'ignores': [],
}],
"vue/html-closing-bracket-spacing": ["warn", {
"startTag": "never",
"endTag": "never",
"selfClosingTag": "never"
'vue/html-closing-bracket-spacing': ['warn', {
'startTag': 'never',
'endTag': 'never',
'selfClosingTag': 'never',
}],
"vue/multi-word-component-names": "warn",
"vue/require-v-for-key": "warn",
"vue/no-unused-components": "warn",
"vue/valid-v-for": "warn",
"vue/return-in-computed-property": "warn",
"vue/no-setup-props-destructure": "warn",
"vue/max-attributes-per-line": "off",
"vue/html-self-closing": "off",
"vue/singleline-html-element-content-newline": "off",
'vue/multi-word-component-names': 'warn',
'vue/require-v-for-key': 'warn',
'vue/no-unused-components': 'warn',
'vue/valid-v-for': 'warn',
'vue/return-in-computed-property': 'warn',
'vue/no-setup-props-destructure': 'warn',
'vue/max-attributes-per-line': 'off',
'vue/html-self-closing': 'off',
'vue/singleline-html-element-content-newline': 'off',
},
globals: {
"require": false,
"_DEV_": false,
"_LANGS_": false,
"_VERSION_": false,
"_ENV_": false,
"_PERF_PREFIX_": false,
"_DATA_TRANSFER_DRIVE_FILE_": false,
"_DATA_TRANSFER_DRIVE_FOLDER_": false,
"_DATA_TRANSFER_DECK_COLUMN_": false
}
}
// Node.js
'module': false,
'require': false,
'__dirname': false,
// Vue
'$$': false,
'$ref': false,
'$computed': false,
// Misskey
'_DEV_': false,
'_LANGS_': false,
'_VERSION_': false,
'_ENV_': false,
'_PERF_PREFIX_': false,
'_DATA_TRANSFER_DRIVE_FILE_': false,
'_DATA_TRANSFER_DRIVE_FOLDER_': false,
'_DATA_TRANSFER_DECK_COLUMN_': false,
},
};

View File

@ -1,5 +1,7 @@
import { Theme } from '../src/scripts/theme';
declare module '@/themes/*.json5' {
export = Theme;
import { Theme } from "@/scripts/theme";
const theme: Theme;
export default theme;
}

View File

@ -10,7 +10,7 @@
"lodash": "^4.17.21"
},
"dependencies": {
"@discordapp/twemoji": "13.1.1",
"@discordapp/twemoji": "14.0.2",
"@fortawesome/fontawesome-free": "6.1.1",
"@syuilo/aiscript": "0.11.1",
"abort-controller": "3.0.0",
@ -18,11 +18,11 @@
"autosize": "5.0.1",
"autwh": "0.1.0",
"blurhash": "1.1.5",
"broadcast-channel": "4.11.0",
"broadcast-channel": "4.12.0",
"browser-image-resizer": "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.2",
"chart.js": "3.7.1",
"chart.js": "3.8.0",
"chartjs-adapter-date-fns": "2.0.0",
"chartjs-plugin-gradient": "0.2.2",
"chartjs-plugin-gradient": "0.5.0",
"chartjs-plugin-zoom": "1.2.1",
"compare-versions": "4.1.3",
"content-disposition": "0.5.4",
@ -33,16 +33,14 @@
"idb-keyval": "6.1.0",
"insert-text-at-cursor": "0.3.0",
"json5": "2.2.1",
"katex": "0.15.3",
"katex": "0.15.6",
"matter-js": "0.18.0",
"mfm-js": "0.21.0",
"mfm-js": "0.22.1",
"misskey-js": "0.0.14",
"mocha": "9.2.2",
"mocha": "10.0.0",
"ms": "2.1.3",
"nested-property": "4.0.0",
"parse5": "6.0.1",
"photoswipe": "5.2.7",
"portscanner": "2.2.0",
"prismjs": "1.28.0",
"private-ip": "2.3.3",
"promise-limit": "2.7.0",
@ -54,32 +52,32 @@
"reflect-metadata": "0.1.13",
"rndstr": "1.0.0",
"s-age": "1.1.2",
"sass": "1.51.0",
"sass": "1.52.1",
"seedrandom": "3.0.5",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"syuilo-password-strength": "0.0.1",
"textarea-caret": "3.1.0",
"three": "0.140.2",
"throttle-debounce": "4.0.1",
"throttle-debounce": "5.0.0",
"tinycolor2": "1.4.2",
"tsc-alias": "1.5.0",
"tsconfig-paths": "3.14.1",
"tsc-alias": "1.6.7",
"tsconfig-paths": "4.0.0",
"twemoji-parser": "14.0.0",
"uuid": "8.3.2",
"v-debounce": "0.1.2",
"vanilla-tilt": "1.7.2",
"vue": "3.2.33",
"vue": "3.2.36",
"vue-prism-editor": "2.0.0-alpha.2",
"vue-router": "4.0.15",
"vuedraggable": "4.0.1",
"websocket": "1.0.34",
"@vitejs/plugin-vue": "2.3.3",
"@vue/compiler-sfc": "3.2.33",
"@vue/compiler-sfc": "3.2.36",
"@rollup/plugin-alias": "3.1.9",
"@rollup/plugin-json": "4.1.0",
"rollup": "2.73.0",
"typescript": "4.6.4",
"rollup": "2.74.1",
"typescript": "4.7.2",
"vite": "2.9.9",
"ws": "8.6.0"
},
@ -93,7 +91,6 @@
"@types/matter-js": "0.17.7",
"@types/mocha": "9.1.1",
"@types/oauth": "0.9.1",
"@types/parse5": "6.0.3",
"@types/punycode": "2.1.0",
"@types/qrcode": "1.4.2",
"@types/random-seed": "0.3.3",
@ -103,12 +100,12 @@
"@types/uuid": "8.3.4",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.3",
"@typescript-eslint/eslint-plugin": "5.23.0",
"@typescript-eslint/parser": "5.23.0",
"eslint": "8.15.0",
"eslint-plugin-vue": "8.7.1",
"@typescript-eslint/eslint-plugin": "5.26.0",
"@typescript-eslint/parser": "5.26.0",
"eslint": "8.16.0",
"eslint-plugin-vue": "9.0.1",
"cross-env": "7.0.3",
"cypress": "9.6.1",
"cypress": "9.7.0",
"eslint-plugin-import": "2.26.0",
"start-server-and-test": "1.14.0"
}

View File

@ -11,10 +11,10 @@ import { i18n } from './i18n';
type Account = misskey.entities.MeDetailed;
const data = localStorage.getItem('account');
const accountData = localStorage.getItem('account');
// TODO: 外部からはreadonlyに
export const $i = data ? reactive(JSON.parse(data) as Account) : null;
export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null;
export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator);
@ -52,7 +52,7 @@ export async function signout() {
return Promise.all(registrations.map(registration => registration.unregister()));
});
}
} catch (e) {}
} catch (err) {}
//#endregion
document.cookie = `igi=; path=/`;
@ -104,8 +104,8 @@ function fetchAccount(token: string): Promise<Account> {
});
}
export function updateAccount(data) {
for (const [key, value] of Object.entries(data)) {
export function updateAccount(accountData) {
for (const [key, value] of Object.entries(accountData)) {
$i[key] = value;
}
localStorage.setItem('account', JSON.stringify($i));

View File

@ -37,7 +37,7 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
(e: 'closed'): void;
(ev: 'closed'): void;
}>();
const window = ref<InstanceType<typeof XWindow>>();

View File

@ -2,7 +2,7 @@
<div class="bcekxzvu _card _gap">
<div class="_content target">
<MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true"/>
<MkA class="info" :to="userPage(report.targetUser)" v-user-preview="report.targetUserId">
<MkA v-user-preview="report.targetUserId" class="info" :to="userPage(report.targetUser)">
<MkUserName class="name" :user="report.targetUser"/>
<MkAcct class="acct" :user="report.targetUser" style="display: block;"/>
</MkA>

View File

@ -48,8 +48,8 @@ async function onClick() {
});
isFollowing.value = true;
}
} catch (e) {
console.error(e);
} catch (err) {
console.error(err);
} finally {
wait.value = false;
}

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