Compare commits
	
		
			74 Commits
		
	
	
		
			multiple-r
			...
			2024.9.0-a
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| ![github-actions[bot]](/assets/img/avatar_default.png)  | 67a5119072 | ||
|   | 00ccc2251a | ||
|   | 3d92ef193e | ||
|   | e9085e455f | ||
|   | 85f46f88c6 | ||
|   | 9cd784cdee | ||
|   | d4d15f338e | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | d3f1b0f090 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 2ee19ee22e | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a18a6ac264 | ||
|   | 7e9d54fa3a | ||
|   | f0834ca14c | ||
|   | 0b062f1407 | ||
|   | f585f70dcb | ||
|   | 8d23122fd6 | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | 2d0e9e0544 | ||
|   | f5563c8304 | ||
|   | 4ac8aad50a | ||
|   | ceb4640669 | ||
|   | 3bf63dd9c5 | ||
|   | ce95323e49 | ||
|   | daf9ae5d4a | ||
|   | a5e61b8c19 | ||
|   | cacdf9d939 | ||
|   | 0134e6e420 | ||
|   | 6bd6af440f | ||
|   | 7d7a12d7d6 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 887c709647 | ||
|   | 0e4b6d1dad | ||
|   | 07f26bc8dd | ||
|   | 366b79e459 | ||
|   | 6b2072f4b1 | ||
|   | 1544ba9153 | ||
|   | be0906a6c7 | ||
|   | e0f54d6a68 | ||
|   | 837a8e15d8 | ||
|   | 0c2cfe31a3 | ||
|   | 05c944c2cc | ||
|   | f393b6b898 | ||
|   | 672779a15f | ||
|   | 2cbe1d1210 | ||
|   | 0d0cd738f8 | ||
|   | 567acea2a3 | ||
|   | 8d19bdbb65 | ||
|   | cdb0566c5b | ||
|   | f7398faeac | ||
|   | c8f49b6ae7 | ||
|   | 74c93fcebe | ||
|   | 8be624aa44 | ||
|   | 3fe7e37f10 | ||
|   | 7fe3035059 | ||
|   | 06855f769f | ||
|   | 3e85052754 | ||
|   | b6fdd71957 | ||
|   | 36dff66883 | ||
|   | 255c8bd1b9 | ||
|   | 44f62160cb | ||
|   | 8032a4e12a | ||
|   | 2f009f7d49 | ||
|   | f85aa7b641 | ||
|   | 1008fa32a0 | ||
|   | 043ab1f69b | ||
|   | 21a3095eb0 | ||
|   | 1b5f0571f7 | ||
|   | 59e83605ac | ||
|   | 130ff361c3 | ||
|   | e78110a5cd | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | 6c5593d456 | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | 621626aad3 | ||
|   | f4f55ef012 | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | 2e8a1029a4 | ||
|   | b53ee54e4f | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | b708b27bc8 | ||
|   | 9ce44b24b8 | 
							
								
								
									
										211
									
								
								.config/cypress-devcontainer.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										211
									
								
								.config/cypress-devcontainer.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,211 @@ | ||||
| #━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||||
| # Misskey configuration | ||||
| #━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | ||||
|  | ||||
| #   ┌─────┐ | ||||
| #───┘ URL └───────────────────────────────────────────────────── | ||||
|  | ||||
| # Final accessible URL seen by a user. | ||||
| url: 'http://misskey.local' | ||||
|  | ||||
| # ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE | ||||
| # URL SETTINGS AFTER THAT! | ||||
|  | ||||
| #   ┌───────────────────────┐ | ||||
| #───┘ Port and TLS settings └─────────────────────────────────── | ||||
|  | ||||
| # | ||||
| # Misskey requires a reverse proxy to support HTTPS connections. | ||||
| # | ||||
| #                 +----- https://example.tld/ ------------+ | ||||
| #   +------+      |+-------------+      +----------------+| | ||||
| #   | User | ---> || Proxy (443) | ---> | Misskey (3000) || | ||||
| #   +------+      |+-------------+      +----------------+| | ||||
| #                 +---------------------------------------+ | ||||
| # | ||||
| #   You need to set up a reverse proxy. (e.g. nginx) | ||||
| #   An encrypted connection with HTTPS is highly recommended | ||||
| #   because tokens may be transferred in GET requests. | ||||
|  | ||||
| # The port that your Misskey server should listen on. | ||||
| port: 61812 | ||||
|  | ||||
| #   ┌──────────────────────────┐ | ||||
| #───┘ PostgreSQL configuration └──────────────────────────────── | ||||
|  | ||||
| db: | ||||
|   host: db | ||||
|   port: 5432 | ||||
|  | ||||
|   # Database name | ||||
|   db: misskey | ||||
|  | ||||
|   # Auth | ||||
|   user: postgres | ||||
|   pass: postgres | ||||
|  | ||||
|   # Whether disable Caching queries | ||||
|   #disableCache: true | ||||
|  | ||||
|   # Extra Connection options | ||||
|   #extra: | ||||
|   #  ssl: true | ||||
|  | ||||
| dbReplications: false | ||||
|  | ||||
| # You can configure any number of replicas here | ||||
| #dbSlaves: | ||||
| #  - | ||||
| #    host: | ||||
| #    port: | ||||
| #    db: | ||||
| #    user: | ||||
| #    pass: | ||||
| #  - | ||||
| #    host: | ||||
| #    port: | ||||
| #    db: | ||||
| #    user: | ||||
| #    pass: | ||||
|  | ||||
| #   ┌─────────────────────┐ | ||||
| #───┘ Redis configuration └───────────────────────────────────── | ||||
|  | ||||
| redis: | ||||
|   host: redis | ||||
|   port: 6379 | ||||
|   #family: 0  # 0=Both, 4=IPv4, 6=IPv6 | ||||
|   #pass: example-pass | ||||
|   #prefix: example-prefix | ||||
|   #db: 1 | ||||
|  | ||||
| #redisForPubsub: | ||||
| #  host: redis | ||||
| #  port: 6379 | ||||
| #  #family: 0  # 0=Both, 4=IPv4, 6=IPv6 | ||||
| #  #pass: example-pass | ||||
| #  #prefix: example-prefix | ||||
| #  #db: 1 | ||||
|  | ||||
| #redisForJobQueue: | ||||
| #  host: redis | ||||
| #  port: 6379 | ||||
| #  #family: 0  # 0=Both, 4=IPv4, 6=IPv6 | ||||
| #  #pass: example-pass | ||||
| #  #prefix: example-prefix | ||||
| #  #db: 1 | ||||
|  | ||||
| #redisForTimelines: | ||||
| #  host: redis | ||||
| #  port: 6379 | ||||
| #  #family: 0  # 0=Both, 4=IPv4, 6=IPv6 | ||||
| #  #pass: example-pass | ||||
| #  #prefix: example-prefix | ||||
| #  #db: 1 | ||||
|  | ||||
| #redisForReactions: | ||||
| #  host: redis | ||||
| #  port: 6379 | ||||
| #  #family: 0  # 0=Both, 4=IPv4, 6=IPv6 | ||||
| #  #pass: example-pass | ||||
| #  #prefix: example-prefix | ||||
| #  #db: 1 | ||||
|  | ||||
| #   ┌───────────────────────────┐ | ||||
| #───┘ MeiliSearch configuration └───────────────────────────── | ||||
|  | ||||
| #meilisearch: | ||||
| #  host: meilisearch | ||||
| #  port: 7700 | ||||
| #  apiKey: '' | ||||
| #  ssl: true | ||||
| #  index: '' | ||||
|  | ||||
| #   ┌───────────────┐ | ||||
| #───┘ ID generation └─────────────────────────────────────────── | ||||
|  | ||||
| # You can select the ID generation method. | ||||
| # You don't usually need to change this setting, but you can | ||||
| # change it according to your preferences. | ||||
|  | ||||
| # Available methods: | ||||
| # aid ... Short, Millisecond accuracy | ||||
| # aidx ... Millisecond accuracy | ||||
| # meid ... Similar to ObjectID, Millisecond accuracy | ||||
| # ulid ... Millisecond accuracy | ||||
| # objectid ... This is left for backward compatibility | ||||
|  | ||||
| # ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE | ||||
| # ID SETTINGS AFTER THAT! | ||||
|  | ||||
| id: 'aidx' | ||||
|  | ||||
| #   ┌────────────────┐ | ||||
| #───┘ Error tracking └────────────────────────────────────────── | ||||
|  | ||||
| # Sentry is available for error tracking. | ||||
| # See the Sentry documentation for more details on options. | ||||
|  | ||||
| #sentryForBackend: | ||||
| #  enableNodeProfiling: true | ||||
| #  options: | ||||
| #    dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' | ||||
|  | ||||
| #sentryForFrontend: | ||||
| #  options: | ||||
| #    dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' | ||||
|  | ||||
| #   ┌─────────────────────┐ | ||||
| #───┘ Other configuration └───────────────────────────────────── | ||||
|  | ||||
| # Whether disable HSTS | ||||
| #disableHsts: true | ||||
|  | ||||
| # Number of worker processes | ||||
| #clusterLimit: 1 | ||||
|  | ||||
| # Job concurrency per worker | ||||
| # deliverJobConcurrency: 128 | ||||
| # inboxJobConcurrency: 16 | ||||
|  | ||||
| # Job rate limiter | ||||
| # deliverJobPerSec: 128 | ||||
| # inboxJobPerSec: 32 | ||||
|  | ||||
| # Job attempts | ||||
| # deliverJobMaxAttempts: 12 | ||||
| # inboxJobMaxAttempts: 8 | ||||
|  | ||||
| # IP address family used for outgoing request (ipv4, ipv6 or dual) | ||||
| #outgoingAddressFamily: ipv4 | ||||
|  | ||||
| # Proxy for HTTP/HTTPS | ||||
| #proxy: http://127.0.0.1:3128 | ||||
|  | ||||
| proxyBypassHosts: | ||||
|   - api.deepl.com | ||||
|   - api-free.deepl.com | ||||
|   - www.recaptcha.net | ||||
|   - hcaptcha.com | ||||
|   - challenges.cloudflare.com | ||||
|  | ||||
| # Proxy for SMTP/SMTPS | ||||
| #proxySmtp: http://127.0.0.1:3128   # use HTTP/1.1 CONNECT | ||||
| #proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 | ||||
| #proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 | ||||
|  | ||||
| # Media Proxy | ||||
| #mediaProxy: https://example.com/proxy | ||||
|  | ||||
| # Proxy remote files (default: true) | ||||
| proxyRemoteFiles: true | ||||
|  | ||||
| # Sign to ActivityPub GET request (default: true) | ||||
| signToActivityPubGet: true | ||||
|  | ||||
| allowedPrivateNetworks: [ | ||||
|   '127.0.0.1/32' | ||||
| ] | ||||
|  | ||||
| # Upload or download file size limits (bytes) | ||||
| #maxFileSize: 262144000 | ||||
| @@ -106,6 +106,14 @@ redis: | ||||
| #  #prefix: example-prefix | ||||
| #  #db: 1 | ||||
|  | ||||
| #redisForReactions: | ||||
| #  host: redis | ||||
| #  port: 6379 | ||||
| #  #family: 0  # 0=Both, 4=IPv4, 6=IPv6 | ||||
| #  #pass: example-pass | ||||
| #  #prefix: example-prefix | ||||
| #  #db: 1 | ||||
|  | ||||
| #   ┌───────────────────────────┐ | ||||
| #───┘ MeiliSearch configuration └───────────────────────────── | ||||
|  | ||||
|   | ||||
| @@ -172,6 +172,16 @@ redis: | ||||
| #  # You can specify more ioredis options... | ||||
| #  #username: example-username | ||||
|  | ||||
| #redisForReactions: | ||||
| #  host: localhost | ||||
| #  port: 6379 | ||||
| #  #family: 0  # 0=Both, 4=IPv4, 6=IPv6 | ||||
| #  #pass: example-pass | ||||
| #  #prefix: example-prefix | ||||
| #  #db: 1 | ||||
| #  # You can specify more ioredis options... | ||||
| #  #username: example-username | ||||
|  | ||||
| #   ┌───────────────────────────┐ | ||||
| #───┘ MeiliSearch configuration └───────────────────────────── | ||||
|  | ||||
|   | ||||
| @@ -103,6 +103,14 @@ redis: | ||||
| #  #prefix: example-prefix | ||||
| #  #db: 1 | ||||
|  | ||||
| #redisForReactions: | ||||
| #  host: redis | ||||
| #  port: 6379 | ||||
| #  #family: 0  # 0=Both, 4=IPv4, 6=IPv6 | ||||
| #  #pass: example-pass | ||||
| #  #prefix: example-prefix | ||||
| #  #db: 1 | ||||
|  | ||||
| #   ┌───────────────────────────┐ | ||||
| #───┘ MeiliSearch configuration └───────────────────────────── | ||||
|  | ||||
|   | ||||
| @@ -3,6 +3,8 @@ | ||||
| set -xe | ||||
|  | ||||
| sudo chown node node_modules | ||||
| sudo apt-get update | ||||
| sudo apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2 libxtst6 xauth xvfb | ||||
| git config --global --add safe.directory /workspace | ||||
| git submodule update --init | ||||
| corepack install | ||||
| @@ -12,3 +14,4 @@ pnpm install --frozen-lockfile | ||||
| cp .devcontainer/devcontainer.yml .config/default.yml | ||||
| pnpm build | ||||
| pnpm migrate | ||||
| pnpm exec cypress install | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/api-misskey-js.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/api-misskey-js.yml
									
									
									
									
										vendored
									
									
								
							| @@ -21,7 +21,7 @@ jobs: | ||||
|       - run: corepack enable | ||||
|  | ||||
|       - name: Setup Node.js | ||||
|         uses: actions/setup-node@v4.0.3 | ||||
|         uses: actions/setup-node@v4.0.4 | ||||
|         with: | ||||
|           node-version-file: '.node-version' | ||||
|           cache: 'pnpm' | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/changelog-check.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/changelog-check.yml
									
									
									
									
										vendored
									
									
								
							| @@ -14,7 +14,7 @@ jobs: | ||||
|       - name: Checkout head | ||||
|         uses: actions/checkout@v4.1.1 | ||||
|       - name: Setup Node.js | ||||
|         uses: actions/setup-node@v4.0.3 | ||||
|         uses: actions/setup-node@v4.0.4 | ||||
|         with: | ||||
|           node-version-file: '.node-version' | ||||
|  | ||||
|   | ||||
| @@ -28,7 +28,7 @@ jobs: | ||||
|  | ||||
|       - name: setup node | ||||
|         id: setup-node | ||||
|         uses: actions/setup-node@v4.0.3 | ||||
|         uses: actions/setup-node@v4.0.4 | ||||
|         with: | ||||
|           node-version-file: '.node-version' | ||||
|           cache: pnpm | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/check-spdx-license-id.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/check-spdx-license-id.yml
									
									
									
									
										vendored
									
									
								
							| @@ -48,12 +48,14 @@ jobs: | ||||
|             "packages/backend/migration" | ||||
|             "packages/backend/src" | ||||
|             "packages/backend/test" | ||||
|             "packages/frontend-shared/src" | ||||
|             "packages/frontend/.storybook" | ||||
|             "packages/frontend/@types" | ||||
|             "packages/frontend/lib" | ||||
|             "packages/frontend/public" | ||||
|             "packages/frontend/src" | ||||
|             "packages/frontend/test" | ||||
|             "packages/frontend-embed/src" | ||||
|             "packages/misskey-bubble-game/src" | ||||
|             "packages/misskey-reversi/src" | ||||
|             "packages/sw/src" | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/get-api-diff.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/get-api-diff.yml
									
									
									
									
										vendored
									
									
								
							| @@ -33,7 +33,7 @@ jobs: | ||||
|     - name: Install pnpm | ||||
|       uses: pnpm/action-setup@v4 | ||||
|     - name: Use Node.js ${{ matrix.node-version }} | ||||
|       uses: actions/setup-node@v4.0.3 | ||||
|       uses: actions/setup-node@v4.0.4 | ||||
|       with: | ||||
|         node-version: ${{ matrix.node-version }} | ||||
|         cache: 'pnpm' | ||||
|   | ||||
							
								
								
									
										29
									
								
								.github/workflows/lint.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										29
									
								
								.github/workflows/lint.yml
									
									
									
									
										vendored
									
									
								
							| @@ -8,6 +8,8 @@ on: | ||||
|     paths: | ||||
|       - packages/backend/** | ||||
|       - packages/frontend/** | ||||
|       - packages/frontend-shared/** | ||||
|       - packages/frontend-embed/** | ||||
|       - packages/sw/** | ||||
|       - packages/misskey-js/** | ||||
|       - packages/shared/eslint.config.js | ||||
| @@ -16,6 +18,8 @@ on: | ||||
|     paths: | ||||
|       - packages/backend/** | ||||
|       - packages/frontend/** | ||||
|       - packages/frontend-shared/** | ||||
|       - packages/frontend-embed/** | ||||
|       - packages/sw/** | ||||
|       - packages/misskey-js/** | ||||
|       - packages/shared/eslint.config.js | ||||
| @@ -29,7 +33,7 @@ jobs: | ||||
|         fetch-depth: 0 | ||||
|         submodules: true | ||||
|     - uses: pnpm/action-setup@v4 | ||||
|     - uses: actions/setup-node@v4.0.3 | ||||
|     - uses: actions/setup-node@v4.0.4 | ||||
|       with: | ||||
|         node-version-file: '.node-version' | ||||
|         cache: 'pnpm' | ||||
| @@ -40,22 +44,25 @@ jobs: | ||||
|     needs: [pnpm_install] | ||||
|     runs-on: ubuntu-latest | ||||
|     continue-on-error: true | ||||
|     env: | ||||
|       eslint-cache-version: v1 | ||||
|     strategy: | ||||
|       matrix: | ||||
|         workspace: | ||||
|         - backend | ||||
|         - frontend | ||||
|         - frontend-shared | ||||
|         - frontend-embed | ||||
|         - sw | ||||
|         - misskey-js | ||||
|     env: | ||||
|       eslint-cache-version: v1 | ||||
|       eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }} | ||||
|     steps: | ||||
|     - uses: actions/checkout@v4.1.1 | ||||
|       with: | ||||
|         fetch-depth: 0 | ||||
|         submodules: true | ||||
|     - uses: pnpm/action-setup@v4 | ||||
|     - uses: actions/setup-node@v4.0.3 | ||||
|     - uses: actions/setup-node@v4.0.4 | ||||
|       with: | ||||
|         node-version-file: '.node-version' | ||||
|         cache: 'pnpm' | ||||
| @@ -64,11 +71,10 @@ jobs: | ||||
|     - name: Restore eslint cache | ||||
|       uses: actions/cache@v4.0.2 | ||||
|       with: | ||||
|         path: node_modules/.cache/eslint | ||||
|         key: eslint-${{ env.eslint-cache-version }}-${{ hashFiles('/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }} | ||||
|         restore-keys: | | ||||
|           eslint-${{ env.eslint-cache-version }}-${{ hashFiles('/pnpm-lock.yaml') }}- | ||||
|     - run: pnpm --filter ${{ matrix.workspace }} run eslint --cache --cache-location node_modules/.cache/eslint --cache-strategy content | ||||
|         path: ${{ env.eslint-cache-path }} | ||||
|         key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }} | ||||
|         restore-keys: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}- | ||||
|     - run: pnpm --filter ${{ matrix.workspace }} run eslint --cache --cache-location ${{ env.eslint-cache-path }} --cache-strategy content | ||||
|  | ||||
|   typecheck: | ||||
|     needs: [pnpm_install] | ||||
| @@ -78,6 +84,7 @@ jobs: | ||||
|       matrix: | ||||
|         workspace: | ||||
|         - backend | ||||
|         - sw | ||||
|         - misskey-js | ||||
|     steps: | ||||
|     - uses: actions/checkout@v4.1.1 | ||||
| @@ -85,14 +92,14 @@ jobs: | ||||
|         fetch-depth: 0 | ||||
|         submodules: true | ||||
|     - uses: pnpm/action-setup@v4 | ||||
|     - uses: actions/setup-node@v4.0.3 | ||||
|     - uses: actions/setup-node@v4.0.4 | ||||
|       with: | ||||
|         node-version-file: '.node-version' | ||||
|         cache: 'pnpm' | ||||
|     - run: corepack enable | ||||
|     - run: pnpm i --frozen-lockfile | ||||
|     - run: pnpm --filter misskey-js run build | ||||
|       if: ${{ matrix.workspace == 'backend' }} | ||||
|       if: ${{ matrix.workspace == 'backend' || matrix.workspace == 'sw' }} | ||||
|     - run: pnpm --filter misskey-reversi run build | ||||
|       if: ${{ matrix.workspace == 'backend' }} | ||||
|     - run: pnpm --filter ${{ matrix.workspace }} run typecheck | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/locale.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/locale.yml
									
									
									
									
										vendored
									
									
								
							| @@ -19,7 +19,7 @@ jobs: | ||||
|         fetch-depth: 0 | ||||
|         submodules: true | ||||
|     - uses: pnpm/action-setup@v4 | ||||
|     - uses: actions/setup-node@v4.0.3 | ||||
|     - uses: actions/setup-node@v4.0.4 | ||||
|       with: | ||||
|         node-version-file: '.node-version' | ||||
|         cache: 'pnpm' | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/on-release-created.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/on-release-created.yml
									
									
									
									
										vendored
									
									
								
							| @@ -26,7 +26,7 @@ jobs: | ||||
|       - name: Install pnpm | ||||
|         uses: pnpm/action-setup@v4 | ||||
|       - name: Use Node.js ${{ matrix.node-version }} | ||||
|         uses: actions/setup-node@v4.0.3 | ||||
|         uses: actions/setup-node@v4.0.4 | ||||
|         with: | ||||
|           node-version: ${{ matrix.node-version }} | ||||
|           cache: 'pnpm' | ||||
|   | ||||
							
								
								
									
										33
									
								
								.github/workflows/report-api-diff.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										33
									
								
								.github/workflows/report-api-diff.yml
									
									
									
									
										vendored
									
									
								
							| @@ -70,18 +70,27 @@ jobs: | ||||
|       - id: out-diff | ||||
|         name: Build diff Comment | ||||
|         run: | | ||||
|           cat <<- EOF > ./output.md | ||||
|           このPRによるapi.jsonの差分 | ||||
|           <details> | ||||
|           <summary>差分はこちら</summary> | ||||
|  | ||||
|           \`\`\`diff | ||||
|           $(cat ./api.json.diff) | ||||
|           \`\`\` | ||||
|           </details> | ||||
|  | ||||
|           [Get diff files from Workflow Page](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) | ||||
|           EOF | ||||
|           HEADER="このPRによるapi.jsonの差分" | ||||
|           FOOTER="[Get diff files from Workflow Page](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" | ||||
|           DIFF_BYTES="$(stat ./api.json.diff -c '%s' | tr -d '\n')" | ||||
|            | ||||
|           echo "$HEADER" > ./output.md | ||||
|            | ||||
|           if (( "$DIFF_BYTES" <= 1 )); then | ||||
|             echo '差分はありません。' >> ./output.md | ||||
|           else | ||||
|             cat <<- EOF >> ./output.md | ||||
|             <details> | ||||
|             <summary>差分はこちら</summary> | ||||
|              | ||||
|             \`\`\`diff | ||||
|             $(cat ./api.json.diff) | ||||
|             \`\`\` | ||||
|             </details> | ||||
|             EOF | ||||
|           fi | ||||
|            | ||||
|           echo "$FOOTER" >> ./output.md | ||||
|       - uses: thollander/actions-comment-pull-request@v2 | ||||
|         with: | ||||
|           pr_number: ${{ steps.load-pr-num.outputs.pr-number }} | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/storybook.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/storybook.yml
									
									
									
									
										vendored
									
									
								
							| @@ -41,7 +41,7 @@ jobs: | ||||
|     - name: Install pnpm | ||||
|       uses: pnpm/action-setup@v4 | ||||
|     - name: Use Node.js 20.x | ||||
|       uses: actions/setup-node@v4.0.3 | ||||
|       uses: actions/setup-node@v4.0.4 | ||||
|       with: | ||||
|         node-version-file: '.node-version' | ||||
|         cache: 'pnpm' | ||||
|   | ||||
							
								
								
									
										4
									
								
								.github/workflows/test-backend.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/test-backend.yml
									
									
									
									
										vendored
									
									
								
							| @@ -46,7 +46,7 @@ jobs: | ||||
|     - name: Install FFmpeg | ||||
|       uses: FedericoCarboni/setup-ffmpeg@v3 | ||||
|     - name: Use Node.js ${{ matrix.node-version }} | ||||
|       uses: actions/setup-node@v4.0.3 | ||||
|       uses: actions/setup-node@v4.0.4 | ||||
|       with: | ||||
|         node-version: ${{ matrix.node-version }} | ||||
|         cache: 'pnpm' | ||||
| @@ -93,7 +93,7 @@ jobs: | ||||
|       - name: Install pnpm | ||||
|         uses: pnpm/action-setup@v4 | ||||
|       - name: Use Node.js ${{ matrix.node-version }} | ||||
|         uses: actions/setup-node@v4.0.3 | ||||
|         uses: actions/setup-node@v4.0.4 | ||||
|         with: | ||||
|           node-version: ${{ matrix.node-version }} | ||||
|           cache: 'pnpm' | ||||
|   | ||||
							
								
								
									
										4
									
								
								.github/workflows/test-frontend.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/test-frontend.yml
									
									
									
									
										vendored
									
									
								
							| @@ -35,7 +35,7 @@ jobs: | ||||
|     - name: Install pnpm | ||||
|       uses: pnpm/action-setup@v4 | ||||
|     - name: Use Node.js ${{ matrix.node-version }} | ||||
|       uses: actions/setup-node@v4.0.3 | ||||
|       uses: actions/setup-node@v4.0.4 | ||||
|       with: | ||||
|         node-version: ${{ matrix.node-version }} | ||||
|         cache: 'pnpm' | ||||
| @@ -90,7 +90,7 @@ jobs: | ||||
|     - name: Install pnpm | ||||
|       uses: pnpm/action-setup@v4 | ||||
|     - name: Use Node.js ${{ matrix.node-version }} | ||||
|       uses: actions/setup-node@v4.0.3 | ||||
|       uses: actions/setup-node@v4.0.4 | ||||
|       with: | ||||
|         node-version: ${{ matrix.node-version }} | ||||
|         cache: 'pnpm' | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/test-misskey-js.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/test-misskey-js.yml
									
									
									
									
										vendored
									
									
								
							| @@ -31,7 +31,7 @@ jobs: | ||||
|       - run: corepack enable | ||||
|  | ||||
|       - name: Setup Node.js ${{ matrix.node-version }} | ||||
|         uses: actions/setup-node@v4.0.3 | ||||
|         uses: actions/setup-node@v4.0.4 | ||||
|         with: | ||||
|           node-version: ${{ matrix.node-version }} | ||||
|           cache: 'pnpm' | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/test-production.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/test-production.yml
									
									
									
									
										vendored
									
									
								
							| @@ -25,7 +25,7 @@ jobs: | ||||
|     - name: Install pnpm | ||||
|       uses: pnpm/action-setup@v4 | ||||
|     - name: Use Node.js ${{ matrix.node-version }} | ||||
|       uses: actions/setup-node@v4.0.3 | ||||
|       uses: actions/setup-node@v4.0.4 | ||||
|       with: | ||||
|         node-version: ${{ matrix.node-version }} | ||||
|         cache: 'pnpm' | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/validate-api-json.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/validate-api-json.yml
									
									
									
									
										vendored
									
									
								
							| @@ -27,7 +27,7 @@ jobs: | ||||
|     - name: Install pnpm | ||||
|       uses: pnpm/action-setup@v4 | ||||
|     - name: Use Node.js ${{ matrix.node-version }} | ||||
|       uses: actions/setup-node@v4.0.3 | ||||
|       uses: actions/setup-node@v4.0.4 | ||||
|       with: | ||||
|         node-version: ${{ matrix.node-version }} | ||||
|         cache: 'pnpm' | ||||
|   | ||||
							
								
								
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -35,6 +35,7 @@ coverage | ||||
| !/.config/example.yml | ||||
| !/.config/docker_example.yml | ||||
| !/.config/docker_example.env | ||||
| !/.config/cypress-devcontainer.yml | ||||
| docker-compose.yml | ||||
| compose.yml | ||||
| .devcontainer/compose.yml | ||||
| @@ -44,6 +45,7 @@ compose.yml | ||||
| /build | ||||
| built | ||||
| built-test | ||||
| js-built | ||||
| /data | ||||
| /.cache-loader | ||||
| /db | ||||
| @@ -63,6 +65,10 @@ temp | ||||
| tsdoc-metadata.json | ||||
| misskey-assets | ||||
|  | ||||
| # Vite temporary files | ||||
| vite.config.js.timestamp-* | ||||
| vite.config.ts.timestamp-* | ||||
|  | ||||
| # blender backups | ||||
| *.blend1 | ||||
| *.blend2 | ||||
|   | ||||
							
								
								
									
										33
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										33
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -1,3 +1,34 @@ | ||||
| ## 2024.9.0 | ||||
|  | ||||
| ### General | ||||
| - Feat: UserWebhookとSystemWebhookのテスト送信機能を追加 (#14445) | ||||
| - Enhance: ユーザーによるコンテンツインポートの可否をロールポリシーで制御できるように | ||||
|  | ||||
| ### Client | ||||
| - Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能 | ||||
|   - 埋め込みコードやウェブサイトへの実装方法の詳細は https://misskey-hub.net/docs/for-users/features/embed/ をご覧ください | ||||
| - Enhance: サイズ制限を超過するファイルをアップロードしようとした際にエラーを出すように | ||||
| - Enhance: アイコンデコレーション管理画面にプレビューを追加 | ||||
| - Enhance: コントロールパネル内のファイル一覧でセンシティブなファイルを区別しやすく | ||||
| - Enhance: ScratchpadにUIインスペクターを追加 | ||||
| - Fix: サーバーメトリクスが2つ以上あるとリロード直後の表示がおかしくなる問題を修正 | ||||
| - Fix: 月の違う同じ日はセパレータが表示されないのを修正 | ||||
| - Fix: 縦横比が極端なカスタム絵文字を表示する際にレイアウトが崩れる箇所があるのを修正   | ||||
|   (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/725) | ||||
| - Fix: 設定変更時のリロード確認ダイアログが複数個表示されることがある問題を修正 | ||||
| - Fix: ファイルの詳細ページのファイルの説明で改行が正しく表示されない問題を修正   | ||||
|   (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/bde6bb0bd2e8b0d027e724d2acdb8ae0585a8110) | ||||
|  | ||||
| ### Server | ||||
| - Feat: Misskey® Reactions Buffering Technology™ (RBT)により、リアクションの作成負荷を低減することが可能に | ||||
| - Fix: アンテナの書き込み時にキーワードが与えられなかった場合のエラーをApiErrorとして投げるように | ||||
|   - この変更により、公式フロントエンドでは入力の不備が内部エラーとして報告される代わりに一般的なエラーダイアログで報告されます | ||||
| - Fix: ファイルがサイズの制限を超えてアップロードされた際にエラーを返さなかった問題を修正 | ||||
| - Fix: 外部ページを解析する際に、ページに紐づけられた関連リソースも読み込まれてしまう問題を修正   | ||||
|   (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/26e0412fbb91447c37e8fb06ffb0487346063bb8) | ||||
| - Fix: `Retry-After`ヘッダーが送信されなかった問題を修正 | ||||
|   (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/8a982c61c01909e7540ff1be9f019df07c3f0624) | ||||
|  | ||||
| ## 2024.8.0 | ||||
|  | ||||
| ### General | ||||
| @@ -33,6 +64,8 @@ | ||||
| - Fix: 無制限にストリーミングのチャンネルに接続できる問題を修正 | ||||
| - Fix: ベースロールのポリシーを変更した際にモデログに記録されないのを修正   | ||||
|   (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/700) | ||||
| - Fix: Prevent memory leak from memory caches (#14310) | ||||
| - Fix: More reliable memory cache eviction (#14311) | ||||
|  | ||||
| ## 2024.7.0 | ||||
|  | ||||
|   | ||||
| @@ -21,7 +21,9 @@ WORKDIR /misskey | ||||
| COPY --link ["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "./"] | ||||
| COPY --link ["scripts", "./scripts"] | ||||
| COPY --link ["packages/backend/package.json", "./packages/backend/"] | ||||
| COPY --link ["packages/frontend-shared/package.json", "./packages/frontend-shared/"] | ||||
| COPY --link ["packages/frontend/package.json", "./packages/frontend/"] | ||||
| COPY --link ["packages/frontend-embed/package.json", "./packages/frontend-embed/"] | ||||
| COPY --link ["packages/sw/package.json", "./packages/sw/"] | ||||
| COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"] | ||||
| COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"] | ||||
|   | ||||
| @@ -124,6 +124,14 @@ redis: | ||||
| #  #prefix: example-prefix | ||||
| #  #db: 1 | ||||
|  | ||||
| #redisForReactions: | ||||
| #  host: redis | ||||
| #  port: 6379 | ||||
| #  #family: 0  # 0=Both, 4=IPv4, 6=IPv6 | ||||
| #  #pass: example-pass | ||||
| #  #prefix: example-prefix | ||||
| #  #db: 1 | ||||
|  | ||||
| #   ┌───────────────────────────┐ | ||||
| #───┘ MeiliSearch configuration └───────────────────────────── | ||||
|  | ||||
|   | ||||
| @@ -2316,6 +2316,7 @@ _pages: | ||||
|   eyeCatchingImageSet: "Set thumbnail" | ||||
|   eyeCatchingImageRemove: "Delete thumbnail" | ||||
|   chooseBlock: "Add a block" | ||||
|   enterSectionTitle: "Enter a section title" | ||||
|   selectType: "Select a type" | ||||
|   contentBlocks: "Content" | ||||
|   inputBlocks: "Input" | ||||
| @@ -2499,7 +2500,10 @@ _moderationLogTypes: | ||||
|   createAbuseReportNotificationRecipient: "Create a recipient for abuse reports" | ||||
|   updateAbuseReportNotificationRecipient: "Update recipients for abuse reports" | ||||
|   deleteAbuseReportNotificationRecipient: "Delete a recipient for abuse reports" | ||||
|   deleteAccount: "Delete the account" | ||||
|   deletePage: "Delete the page" | ||||
|   deleteFlash: "Delete Play" | ||||
|   deleteGalleryPost: "Delete the gallery post" | ||||
| _fileViewer: | ||||
|   title: "File details" | ||||
|   type: "File type" | ||||
|   | ||||
							
								
								
									
										108
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										108
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -2384,6 +2384,14 @@ export interface Locale extends ILocale { | ||||
|      * スクラッチパッドは、AiScriptの実験環境を提供します。Misskeyと対話するコードの記述、実行、結果の確認ができます。 | ||||
|      */ | ||||
|     "scratchpadDescription": string; | ||||
|     /** | ||||
|      * UIインスペクター | ||||
|      */ | ||||
|     "uiInspector": string; | ||||
|     /** | ||||
|      * メモリ上に存在しているUIコンポーネントのインスタンスの一覧を見ることができます。UIコンポーネントはUi:C:系関数により生成されます。 | ||||
|      */ | ||||
|     "uiInspectorDescription": string; | ||||
|     /** | ||||
|      * 出力 | ||||
|      */ | ||||
| @@ -3121,7 +3129,7 @@ export interface Locale extends ILocale { | ||||
|      */ | ||||
|     "narrow": string; | ||||
|     /** | ||||
|      * 設定はページリロード後に反映されます。今すぐリロードしますか? | ||||
|      * 設定はページリロード後に反映されます。 | ||||
|      */ | ||||
|     "reloadToApplySetting": string; | ||||
|     /** | ||||
| @@ -5068,6 +5076,22 @@ export interface Locale extends ILocale { | ||||
|      * 作成したアンテナ | ||||
|      */ | ||||
|     "createdAntennas": string; | ||||
|     /** | ||||
|      * {x}から | ||||
|      */ | ||||
|     "fromX": ParameterizedString<"x">; | ||||
|     /** | ||||
|      * 埋め込みコードを生成 | ||||
|      */ | ||||
|     "genEmbedCode": string; | ||||
|     /** | ||||
|      * このユーザーのノート一覧 | ||||
|      */ | ||||
|     "noteOfThisUser": string; | ||||
|     /** | ||||
|      * これ以上このクリップにノートを追加できません。 | ||||
|      */ | ||||
|     "clipNoteLimitExceeded": string; | ||||
|     "_delivery": { | ||||
|         /** | ||||
|          * 配信状態 | ||||
| @@ -5559,6 +5583,10 @@ export interface Locale extends ILocale { | ||||
|          * 有効にすると、タイムラインがキャッシュされていない場合にDBへ追加で問い合わせを行うフォールバック処理を行います。無効にすると、フォールバック処理を行わないことでさらにサーバーの負荷を軽減することができますが、タイムラインが取得できる範囲に制限が生じます。 | ||||
|          */ | ||||
|         "fanoutTimelineDbFallbackDescription": string; | ||||
|         /** | ||||
|          * 有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。 | ||||
|          */ | ||||
|         "reactionsBufferingDescription": string; | ||||
|         /** | ||||
|          * 問い合わせ先URL | ||||
|          */ | ||||
| @@ -6738,6 +6766,26 @@ export interface Locale extends ILocale { | ||||
|              * アイコンデコレーションの最大取付個数 | ||||
|              */ | ||||
|             "avatarDecorationLimit": string; | ||||
|             /** | ||||
|              * アンテナのインポートを許可 | ||||
|              */ | ||||
|             "canImportAntennas": string; | ||||
|             /** | ||||
|              * ブロックのインポートを許可 | ||||
|              */ | ||||
|             "canImportBlocking": string; | ||||
|             /** | ||||
|              * フォローのインポートを許可 | ||||
|              */ | ||||
|             "canImportFollowing": string; | ||||
|             /** | ||||
|              * ミュートのインポートを許可 | ||||
|              */ | ||||
|             "canImportMuting": string; | ||||
|             /** | ||||
|              * リストのインポートを許可 | ||||
|              */ | ||||
|             "canImportUserLists": string; | ||||
|         }; | ||||
|         "_condition": { | ||||
|             /** | ||||
| @@ -9461,6 +9509,10 @@ export interface Locale extends ILocale { | ||||
|          * Webhookを削除しますか? | ||||
|          */ | ||||
|         "deleteConfirm": string; | ||||
|         /** | ||||
|          * スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。 | ||||
|          */ | ||||
|         "testRemarks": string; | ||||
|     }; | ||||
|     "_abuseReport": { | ||||
|         "_notificationRecipient": { | ||||
| @@ -10192,6 +10244,60 @@ export interface Locale extends ILocale { | ||||
|          */ | ||||
|         "native": string; | ||||
|     }; | ||||
|     "_embedCodeGen": { | ||||
|         /** | ||||
|          * 埋め込みコードをカスタマイズ | ||||
|          */ | ||||
|         "title": string; | ||||
|         /** | ||||
|          * ヘッダーを表示 | ||||
|          */ | ||||
|         "header": string; | ||||
|         /** | ||||
|          * 自動で続きを読み込む(非推奨) | ||||
|          */ | ||||
|         "autoload": string; | ||||
|         /** | ||||
|          * 高さの最大値 | ||||
|          */ | ||||
|         "maxHeight": string; | ||||
|         /** | ||||
|          * 0で最大値の設定が無効になります。ウィジェットが縦に伸び続けるのを防ぐために、何らかの値に指定してください。 | ||||
|          */ | ||||
|         "maxHeightDescription": string; | ||||
|         /** | ||||
|          * 高さの最大値制限が無効(0)になっています。これが意図した変更ではない場合は、高さの最大値を何らかの値に設定してください。 | ||||
|          */ | ||||
|         "maxHeightWarn": string; | ||||
|         /** | ||||
|          * プレビュー画面で表示可能な範囲を超えたため、実際に埋め込んだ際とは表示が異なります。 | ||||
|          */ | ||||
|         "previewIsNotActual": string; | ||||
|         /** | ||||
|          * 角丸にする | ||||
|          */ | ||||
|         "rounded": string; | ||||
|         /** | ||||
|          * 外枠に枠線をつける | ||||
|          */ | ||||
|         "border": string; | ||||
|         /** | ||||
|          * プレビューに反映 | ||||
|          */ | ||||
|         "applyToPreview": string; | ||||
|         /** | ||||
|          * 埋め込みコードを作成 | ||||
|          */ | ||||
|         "generateCode": string; | ||||
|         /** | ||||
|          * コードが生成されました | ||||
|          */ | ||||
|         "codeGenerated": string; | ||||
|         /** | ||||
|          * 生成されたコードをウェブサイトに貼り付けてご利用ください。 | ||||
|          */ | ||||
|         "codeGeneratedDescription": string; | ||||
|     }; | ||||
| } | ||||
| declare const locales: { | ||||
|     [lang: string]: Locale; | ||||
|   | ||||
| @@ -592,6 +592,8 @@ ascendingOrder: "昇順" | ||||
| descendingOrder: "降順" | ||||
| scratchpad: "スクラッチパッド" | ||||
| scratchpadDescription: "スクラッチパッドは、AiScriptの実験環境を提供します。Misskeyと対話するコードの記述、実行、結果の確認ができます。" | ||||
| uiInspector: "UIインスペクター" | ||||
| uiInspectorDescription: "メモリ上に存在しているUIコンポーネントのインスタンスの一覧を見ることができます。UIコンポーネントはUi:C:系関数により生成されます。" | ||||
| output: "出力" | ||||
| script: "スクリプト" | ||||
| disablePagesScript: "Pagesのスクリプトを無効にする" | ||||
| @@ -776,7 +778,7 @@ left: "左" | ||||
| center: "中央" | ||||
| wide: "広い" | ||||
| narrow: "狭い" | ||||
| reloadToApplySetting: "設定はページリロード後に反映されます。今すぐリロードしますか?" | ||||
| reloadToApplySetting: "設定はページリロード後に反映されます。" | ||||
| needReloadToApply: "反映には再起動が必要です。" | ||||
| showTitlebar: "タイトルバーを表示する" | ||||
| clearCache: "キャッシュをクリア" | ||||
| @@ -1263,6 +1265,10 @@ confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示 | ||||
| sensitiveMediaRevealConfirm: "センシティブなメディアです。表示しますか?" | ||||
| createdLists: "作成したリスト" | ||||
| createdAntennas: "作成したアンテナ" | ||||
| fromX: "{x}から" | ||||
| genEmbedCode: "埋め込みコードを生成" | ||||
| noteOfThisUser: "このユーザーのノート一覧" | ||||
| clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。" | ||||
|  | ||||
| _delivery: | ||||
|   status: "配信状態" | ||||
| @@ -1405,6 +1411,7 @@ _serverSettings: | ||||
|   fanoutTimelineDescription: "有効にすると、各種タイムラインを取得する際のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。サーバーのメモリ容量が少ない場合、または動作が不安定な場合は無効にすることができます。" | ||||
|   fanoutTimelineDbFallback: "データベースへのフォールバック" | ||||
|   fanoutTimelineDbFallbackDescription: "有効にすると、タイムラインがキャッシュされていない場合にDBへ追加で問い合わせを行うフォールバック処理を行います。無効にすると、フォールバック処理を行わないことでさらにサーバーの負荷を軽減することができますが、タイムラインが取得できる範囲に制限が生じます。" | ||||
|   reactionsBufferingDescription: "有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。" | ||||
|   inquiryUrl: "問い合わせ先URL" | ||||
|   inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。" | ||||
|  | ||||
| @@ -1741,6 +1748,11 @@ _role: | ||||
|     canSearchNotes: "ノート検索の利用" | ||||
|     canUseTranslator: "翻訳機能の利用" | ||||
|     avatarDecorationLimit: "アイコンデコレーションの最大取付個数" | ||||
|     canImportAntennas: "アンテナのインポートを許可" | ||||
|     canImportBlocking: "ブロックのインポートを許可" | ||||
|     canImportFollowing: "フォローのインポートを許可" | ||||
|     canImportMuting: "ミュートのインポートを許可" | ||||
|     canImportUserLists: "リストのインポートを許可" | ||||
|   _condition: | ||||
|     roleAssignedTo: "マニュアルロールにアサイン済み" | ||||
|     isLocal: "ローカルユーザー" | ||||
| @@ -2508,6 +2520,7 @@ _webhookSettings: | ||||
|     abuseReportResolved: "ユーザーからの通報を処理したとき" | ||||
|     userCreated: "ユーザーが作成されたとき" | ||||
|   deleteConfirm: "Webhookを削除しますか?" | ||||
|   testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。" | ||||
|  | ||||
| _abuseReport: | ||||
|   _notificationRecipient: | ||||
| @@ -2717,3 +2730,18 @@ _contextMenu: | ||||
|   app: "アプリケーション" | ||||
|   appWithShift: "Shiftキーでアプリケーション" | ||||
|   native: "ブラウザのUI" | ||||
|  | ||||
| _embedCodeGen: | ||||
|   title: "埋め込みコードをカスタマイズ" | ||||
|   header: "ヘッダーを表示" | ||||
|   autoload: "自動で続きを読み込む(非推奨)" | ||||
|   maxHeight: "高さの最大値" | ||||
|   maxHeightDescription: "0で最大値の設定が無効になります。ウィジェットが縦に伸び続けるのを防ぐために、何らかの値に指定してください。" | ||||
|   maxHeightWarn: "高さの最大値制限が無効(0)になっています。これが意図した変更ではない場合は、高さの最大値を何らかの値に設定してください。" | ||||
|   previewIsNotActual: "プレビュー画面で表示可能な範囲を超えたため、実際に埋め込んだ際とは表示が異なります。" | ||||
|   rounded: "角丸にする" | ||||
|   border: "外枠に枠線をつける" | ||||
|   applyToPreview: "プレビューに反映" | ||||
|   generateCode: "埋め込みコードを作成" | ||||
|   codeGenerated: "コードが生成されました" | ||||
|   codeGeneratedDescription: "生成されたコードをウェブサイトに貼り付けてご利用ください。" | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| { | ||||
| 	"name": "misskey", | ||||
| 	"version": "2024.8.0-rc.3", | ||||
| 	"version": "2024.9.0-alpha.2", | ||||
| 	"codename": "nasubi", | ||||
| 	"repository": { | ||||
| 		"type": "git", | ||||
| @@ -8,7 +8,9 @@ | ||||
| 	}, | ||||
| 	"packageManager": "pnpm@9.6.0", | ||||
| 	"workspaces": [ | ||||
| 		"packages/frontend-shared", | ||||
| 		"packages/frontend", | ||||
| 		"packages/frontend-embed", | ||||
| 		"packages/backend", | ||||
| 		"packages/sw", | ||||
| 		"packages/misskey-js", | ||||
| @@ -35,6 +37,7 @@ | ||||
| 		"cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts", | ||||
| 		"cy:run": "pnpm cypress run", | ||||
| 		"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run", | ||||
| 		"e2e-dev-container": "cp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run", | ||||
| 		"jest": "cd packages/backend && pnpm jest", | ||||
| 		"jest-and-coverage": "cd packages/backend && pnpm jest-and-coverage", | ||||
| 		"test": "pnpm -r test", | ||||
|   | ||||
							
								
								
									
										31
									
								
								packages/backend/assets/embed.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								packages/backend/assets/embed.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: MIT | ||||
|  */ | ||||
| //@ts-check | ||||
| (() => { | ||||
| 	/** @type {NodeListOf<HTMLIFrameElement>} */ | ||||
| 	const els = document.querySelectorAll('iframe[data-misskey-embed-id]'); | ||||
|  | ||||
| 	window.addEventListener('message', function (event) { | ||||
| 		els.forEach((el) => { | ||||
| 			if (event.source !== el.contentWindow) { | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			const id = el.dataset.misskeyEmbedId; | ||||
|  | ||||
| 			if (event.data.type === 'misskey:embed:ready') { | ||||
| 				el.contentWindow?.postMessage({ | ||||
| 					type: 'misskey:embedParent:registerIframeId', | ||||
| 					payload: { | ||||
| 						iframeId: id, | ||||
| 					} | ||||
| 				}, '*'); | ||||
| 			} | ||||
| 			if (event.data.type === 'misskey:embed:changeHeight' && event.data.iframeId === id) { | ||||
| 				el.style.height = event.data.payload.height + 'px'; | ||||
| 			} | ||||
| 		}); | ||||
| 	}); | ||||
| })(); | ||||
| @@ -0,0 +1,16 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| export class ReactionsBuffering1726804538569 { | ||||
|     name = 'ReactionsBuffering1726804538569' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "meta" ADD "enableReactionsBuffering" boolean NOT NULL DEFAULT false`); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableReactionsBuffering"`); | ||||
|     } | ||||
| } | ||||
| @@ -100,7 +100,7 @@ | ||||
| 		"async-mutex": "0.5.0", | ||||
| 		"bcryptjs": "2.4.3", | ||||
| 		"blurhash": "2.0.5", | ||||
| 		"body-parser": "1.20.2", | ||||
| 		"body-parser": "1.20.3", | ||||
| 		"bullmq": "5.10.4", | ||||
| 		"cacheable-lookup": "7.0.0", | ||||
| 		"cbor": "9.0.2", | ||||
| @@ -119,7 +119,7 @@ | ||||
| 		"fluent-ffmpeg": "2.1.3", | ||||
| 		"form-data": "4.0.0", | ||||
| 		"got": "14.4.2", | ||||
| 		"happy-dom": "10.0.3", | ||||
| 		"happy-dom": "15.6.1", | ||||
| 		"hpagent": "1.2.0", | ||||
| 		"htmlescape": "1.1.1", | ||||
| 		"http-link-header": "1.1.3", | ||||
| @@ -132,6 +132,7 @@ | ||||
| 		"json5": "2.2.3", | ||||
| 		"jsonld": "8.3.2", | ||||
| 		"jsrsasign": "11.1.0", | ||||
| 		"juice": "11.0.0", | ||||
| 		"meilisearch": "0.41.0", | ||||
| 		"mfm-js": "0.24.0", | ||||
| 		"microformats-parser": "2.0.2", | ||||
|   | ||||
| @@ -78,11 +78,19 @@ const $redisForTimelines: Provider = { | ||||
| 	inject: [DI.config], | ||||
| }; | ||||
|  | ||||
| const $redisForReactions: Provider = { | ||||
| 	provide: DI.redisForReactions, | ||||
| 	useFactory: (config: Config) => { | ||||
| 		return new Redis.Redis(config.redisForReactions); | ||||
| 	}, | ||||
| 	inject: [DI.config], | ||||
| }; | ||||
|  | ||||
| @Global() | ||||
| @Module({ | ||||
| 	imports: [RepositoryModule], | ||||
| 	providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines], | ||||
| 	exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, RepositoryModule], | ||||
| 	providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions], | ||||
| 	exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions, RepositoryModule], | ||||
| }) | ||||
| export class GlobalModule implements OnApplicationShutdown { | ||||
| 	constructor( | ||||
| @@ -91,6 +99,7 @@ export class GlobalModule implements OnApplicationShutdown { | ||||
| 		@Inject(DI.redisForPub) private redisForPub: Redis.Redis, | ||||
| 		@Inject(DI.redisForSub) private redisForSub: Redis.Redis, | ||||
| 		@Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis, | ||||
| 		@Inject(DI.redisForReactions) private redisForReactions: Redis.Redis, | ||||
| 	) { } | ||||
|  | ||||
| 	public async dispose(): Promise<void> { | ||||
| @@ -103,6 +112,7 @@ export class GlobalModule implements OnApplicationShutdown { | ||||
| 			this.redisForPub.disconnect(), | ||||
| 			this.redisForSub.disconnect(), | ||||
| 			this.redisForTimelines.disconnect(), | ||||
| 			this.redisForReactions.disconnect(), | ||||
| 		]); | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -49,6 +49,7 @@ type Source = { | ||||
| 	redisForPubsub?: RedisOptionsSource; | ||||
| 	redisForJobQueue?: RedisOptionsSource; | ||||
| 	redisForTimelines?: RedisOptionsSource; | ||||
| 	redisForReactions?: RedisOptionsSource; | ||||
| 	meilisearch?: { | ||||
| 		host: string; | ||||
| 		port: string; | ||||
| @@ -133,7 +134,7 @@ export type Config = { | ||||
| 	proxySmtp: string | undefined; | ||||
| 	proxyBypassHosts: string[] | undefined; | ||||
| 	allowedPrivateNetworks: string[] | undefined; | ||||
| 	maxFileSize: number | undefined; | ||||
| 	maxFileSize: number; | ||||
| 	clusterLimit: number | undefined; | ||||
| 	id: string; | ||||
| 	outgoingAddress: string | undefined; | ||||
| @@ -160,8 +161,10 @@ export type Config = { | ||||
| 	authUrl: string; | ||||
| 	driveUrl: string; | ||||
| 	userAgent: string; | ||||
| 	clientEntry: string; | ||||
| 	clientManifestExists: boolean; | ||||
| 	frontendEntry: string; | ||||
| 	frontendManifestExists: boolean; | ||||
| 	frontendEmbedEntry: string; | ||||
| 	frontendEmbedManifestExists: boolean; | ||||
| 	mediaProxy: string; | ||||
| 	externalMediaProxyEnabled: boolean; | ||||
| 	videoThumbnailGenerator: string | null; | ||||
| @@ -169,6 +172,7 @@ export type Config = { | ||||
| 	redisForPubsub: RedisOptions & RedisOptionsSource; | ||||
| 	redisForJobQueue: RedisOptions & RedisOptionsSource; | ||||
| 	redisForTimelines: RedisOptions & RedisOptionsSource; | ||||
| 	redisForReactions: RedisOptions & RedisOptionsSource; | ||||
| 	sentryForBackend: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; } | undefined; | ||||
| 	sentryForFrontend: { options: Partial<Sentry.NodeOptions> } | undefined; | ||||
| 	perChannelMaxNoteCacheCount: number; | ||||
| @@ -196,10 +200,16 @@ const path = process.env.MISSKEY_CONFIG_YML | ||||
|  | ||||
| export function loadConfig(): Config { | ||||
| 	const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8')); | ||||
| 	const clientManifestExists = fs.existsSync(_dirname + '/../../../built/_vite_/manifest.json'); | ||||
| 	const clientManifest = clientManifestExists ? | ||||
| 		JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_vite_/manifest.json`, 'utf-8')) | ||||
|  | ||||
| 	const frontendManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_vite_/manifest.json'); | ||||
| 	const frontendEmbedManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_embed_vite_/manifest.json'); | ||||
| 	const frontendManifest = frontendManifestExists ? | ||||
| 		JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_vite_/manifest.json`, 'utf-8')) | ||||
| 		: { 'src/_boot_.ts': { file: 'src/_boot_.ts' } }; | ||||
| 	const frontendEmbedManifest = frontendEmbedManifestExists ? | ||||
| 		JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8')) | ||||
| 		: { 'src/boot.ts': { file: 'src/boot.ts' } }; | ||||
|  | ||||
| 	const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source; | ||||
|  | ||||
| 	const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? ''); | ||||
| @@ -243,6 +253,7 @@ export function loadConfig(): Config { | ||||
| 		redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis, | ||||
| 		redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis, | ||||
| 		redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis, | ||||
| 		redisForReactions: config.redisForReactions ? convertRedisOptions(config.redisForReactions, host) : redis, | ||||
| 		sentryForBackend: config.sentryForBackend, | ||||
| 		sentryForFrontend: config.sentryForFrontend, | ||||
| 		id: config.id, | ||||
| @@ -250,7 +261,7 @@ export function loadConfig(): Config { | ||||
| 		proxySmtp: config.proxySmtp, | ||||
| 		proxyBypassHosts: config.proxyBypassHosts, | ||||
| 		allowedPrivateNetworks: config.allowedPrivateNetworks, | ||||
| 		maxFileSize: config.maxFileSize, | ||||
| 		maxFileSize: config.maxFileSize ?? 262144000, | ||||
| 		clusterLimit: config.clusterLimit, | ||||
| 		outgoingAddress: config.outgoingAddress, | ||||
| 		outgoingAddressFamily: config.outgoingAddressFamily, | ||||
| @@ -270,8 +281,10 @@ export function loadConfig(): Config { | ||||
| 			config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator | ||||
| 			: null, | ||||
| 		userAgent: `Misskey/${version} (${config.url})`, | ||||
| 		clientEntry: clientManifest['src/_boot_.ts'], | ||||
| 		clientManifestExists: clientManifestExists, | ||||
| 		frontendEntry: frontendManifest['src/_boot_.ts'], | ||||
| 		frontendManifestExists: frontendManifestExists, | ||||
| 		frontendEmbedEntry: frontendEmbedManifest['src/boot.ts'], | ||||
| 		frontendEmbedManifestExists: frontendEmbedManifestExists, | ||||
| 		perChannelMaxNoteCacheCount: config.perChannelMaxNoteCacheCount ?? 1000, | ||||
| 		perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 500, | ||||
| 		deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7), | ||||
|   | ||||
| @@ -8,6 +8,8 @@ export const MAX_NOTE_TEXT_LENGTH = 3000; | ||||
| export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min | ||||
| export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days | ||||
|  | ||||
| export const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16; | ||||
|  | ||||
| //#region hard limits | ||||
| // If you change DB_* values, you must also change the DB schema. | ||||
|  | ||||
|   | ||||
| @@ -123,11 +123,14 @@ export class AntennaService implements OnApplicationShutdown { | ||||
| 		if (antenna.src === 'home') { | ||||
| 			// TODO | ||||
| 		} else if (antenna.src === 'list') { | ||||
| 			const listUsers = (await this.userListMembershipsRepository.findBy({ | ||||
| 				userListId: antenna.userListId!, | ||||
| 			})).map(x => x.userId); | ||||
|  | ||||
| 			if (!listUsers.includes(note.userId)) return false; | ||||
| 			if (antenna.userListId == null) return false; | ||||
| 			const exists = await this.userListMembershipsRepository.exists({ | ||||
| 				where: { | ||||
| 					userListId: antenna.userListId, | ||||
| 					userId: note.userId, | ||||
| 				}, | ||||
| 			}); | ||||
| 			if (!exists) return false; | ||||
| 		} else if (antenna.src === 'users') { | ||||
| 			const accts = antenna.users.map(x => { | ||||
| 				const { username, host } = Acct.parse(x); | ||||
|   | ||||
| @@ -29,7 +29,7 @@ export class AvatarDecorationService implements OnApplicationShutdown { | ||||
| 		private moderationLogService: ModerationLogService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 	) { | ||||
| 		this.cache = new MemorySingleCache<MiAvatarDecoration[]>(1000 * 60 * 30); | ||||
| 		this.cache = new MemorySingleCache<MiAvatarDecoration[]>(1000 * 60 * 30); // 30s | ||||
|  | ||||
| 		this.redisForSub.on('message', this.onMessage); | ||||
| 	} | ||||
|   | ||||
| @@ -56,10 +56,10 @@ export class CacheService implements OnApplicationShutdown { | ||||
| 	) { | ||||
| 		//this.onMessage = this.onMessage.bind(this); | ||||
|  | ||||
| 		this.userByIdCache = new MemoryKVCache<MiUser>(Infinity); | ||||
| 		this.localUserByNativeTokenCache = new MemoryKVCache<MiLocalUser | null>(Infinity); | ||||
| 		this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(Infinity); | ||||
| 		this.uriPersonCache = new MemoryKVCache<MiUser | null>(Infinity); | ||||
| 		this.userByIdCache = new MemoryKVCache<MiUser>(1000 * 60 * 5); // 5m | ||||
| 		this.localUserByNativeTokenCache = new MemoryKVCache<MiLocalUser | null>(1000 * 60 * 5); // 5m | ||||
| 		this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(1000 * 60 * 5); // 5m | ||||
| 		this.uriPersonCache = new MemoryKVCache<MiUser | null>(1000 * 60 * 5); // 5m | ||||
|  | ||||
| 		this.userProfileCache = new RedisKVCache<MiUserProfile>(this.redisClient, 'userProfile', { | ||||
| 			lifetime: 1000 * 60 * 30, // 30m | ||||
| @@ -135,14 +135,14 @@ export class CacheService implements OnApplicationShutdown { | ||||
| 					if (user == null) { | ||||
| 						this.userByIdCache.delete(body.id); | ||||
| 						this.localUserByIdCache.delete(body.id); | ||||
| 						for (const [k, v] of this.uriPersonCache.cache.entries()) { | ||||
| 						for (const [k, v] of this.uriPersonCache.entries) { | ||||
| 							if (v.value?.id === body.id) { | ||||
| 								this.uriPersonCache.delete(k); | ||||
| 							} | ||||
| 						} | ||||
| 					} else { | ||||
| 						this.userByIdCache.set(user.id, user); | ||||
| 						for (const [k, v] of this.uriPersonCache.cache.entries()) { | ||||
| 						for (const [k, v] of this.uriPersonCache.entries) { | ||||
| 							if (v.value?.id === user.id) { | ||||
| 								this.uriPersonCache.set(k, user); | ||||
| 							} | ||||
|   | ||||
| @@ -13,6 +13,7 @@ import { | ||||
| import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; | ||||
| import { SystemWebhookService } from '@/core/SystemWebhookService.js'; | ||||
| import { UserSearchService } from '@/core/UserSearchService.js'; | ||||
| import { WebhookTestService } from '@/core/WebhookTestService.js'; | ||||
| import { AccountMoveService } from './AccountMoveService.js'; | ||||
| import { AccountUpdateService } from './AccountUpdateService.js'; | ||||
| import { AiService } from './AiService.js'; | ||||
| @@ -49,6 +50,7 @@ import { PollService } from './PollService.js'; | ||||
| import { PushNotificationService } from './PushNotificationService.js'; | ||||
| import { QueryService } from './QueryService.js'; | ||||
| import { ReactionService } from './ReactionService.js'; | ||||
| import { ReactionsBufferingService } from './ReactionsBufferingService.js'; | ||||
| import { RelayService } from './RelayService.js'; | ||||
| import { RoleService } from './RoleService.js'; | ||||
| import { S3Service } from './S3Service.js'; | ||||
| @@ -192,6 +194,7 @@ const $ProxyAccountService: Provider = { provide: 'ProxyAccountService', useExis | ||||
| const $PushNotificationService: Provider = { provide: 'PushNotificationService', useExisting: PushNotificationService }; | ||||
| const $QueryService: Provider = { provide: 'QueryService', useExisting: QueryService }; | ||||
| const $ReactionService: Provider = { provide: 'ReactionService', useExisting: ReactionService }; | ||||
| const $ReactionsBufferingService: Provider = { provide: 'ReactionsBufferingService', useExisting: ReactionsBufferingService }; | ||||
| const $RelayService: Provider = { provide: 'RelayService', useExisting: RelayService }; | ||||
| const $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService }; | ||||
| const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service }; | ||||
| @@ -211,6 +214,7 @@ const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: Us | ||||
| const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService }; | ||||
| const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService }; | ||||
| const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService }; | ||||
| const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService }; | ||||
| const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService }; | ||||
| const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService }; | ||||
| const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService }; | ||||
| @@ -340,6 +344,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		PushNotificationService, | ||||
| 		QueryService, | ||||
| 		ReactionService, | ||||
| 		ReactionsBufferingService, | ||||
| 		RelayService, | ||||
| 		RoleService, | ||||
| 		S3Service, | ||||
| @@ -359,6 +364,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		VideoProcessingService, | ||||
| 		UserWebhookService, | ||||
| 		SystemWebhookService, | ||||
| 		WebhookTestService, | ||||
| 		UtilityService, | ||||
| 		FileInfoService, | ||||
| 		SearchService, | ||||
| @@ -484,6 +490,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		$PushNotificationService, | ||||
| 		$QueryService, | ||||
| 		$ReactionService, | ||||
| 		$ReactionsBufferingService, | ||||
| 		$RelayService, | ||||
| 		$RoleService, | ||||
| 		$S3Service, | ||||
| @@ -503,6 +510,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		$VideoProcessingService, | ||||
| 		$UserWebhookService, | ||||
| 		$SystemWebhookService, | ||||
| 		$WebhookTestService, | ||||
| 		$UtilityService, | ||||
| 		$FileInfoService, | ||||
| 		$SearchService, | ||||
| @@ -629,6 +637,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		PushNotificationService, | ||||
| 		QueryService, | ||||
| 		ReactionService, | ||||
| 		ReactionsBufferingService, | ||||
| 		RelayService, | ||||
| 		RoleService, | ||||
| 		S3Service, | ||||
| @@ -648,6 +657,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		VideoProcessingService, | ||||
| 		UserWebhookService, | ||||
| 		SystemWebhookService, | ||||
| 		WebhookTestService, | ||||
| 		UtilityService, | ||||
| 		FileInfoService, | ||||
| 		SearchService, | ||||
| @@ -772,6 +782,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		$PushNotificationService, | ||||
| 		$QueryService, | ||||
| 		$ReactionService, | ||||
| 		$ReactionsBufferingService, | ||||
| 		$RelayService, | ||||
| 		$RoleService, | ||||
| 		$S3Service, | ||||
| @@ -791,6 +802,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		$VideoProcessingService, | ||||
| 		$UserWebhookService, | ||||
| 		$SystemWebhookService, | ||||
| 		$WebhookTestService, | ||||
| 		$UtilityService, | ||||
| 		$FileInfoService, | ||||
| 		$SearchService, | ||||
|   | ||||
| @@ -24,7 +24,7 @@ const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/; | ||||
|  | ||||
| @Injectable() | ||||
| export class CustomEmojiService implements OnApplicationShutdown { | ||||
| 	private cache: MemoryKVCache<MiEmoji | null>; | ||||
| 	private emojisCache: MemoryKVCache<MiEmoji | null>; | ||||
| 	public localEmojisCache: RedisSingleCache<Map<string, MiEmoji>>; | ||||
|  | ||||
| 	constructor( | ||||
| @@ -40,7 +40,7 @@ export class CustomEmojiService implements OnApplicationShutdown { | ||||
| 		private moderationLogService: ModerationLogService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 	) { | ||||
| 		this.cache = new MemoryKVCache<MiEmoji | null>(1000 * 60 * 60 * 12); | ||||
| 		this.emojisCache = new MemoryKVCache<MiEmoji | null>(1000 * 60 * 60 * 12); // 12h | ||||
|  | ||||
| 		this.localEmojisCache = new RedisSingleCache<Map<string, MiEmoji>>(this.redisClient, 'localEmojis', { | ||||
| 			lifetime: 1000 * 60 * 30, // 30m | ||||
| @@ -334,7 +334,7 @@ export class CustomEmojiService implements OnApplicationShutdown { | ||||
| 			host, | ||||
| 		})) ?? null; | ||||
|  | ||||
| 		const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull); | ||||
| 		const emoji = await this.emojisCache.fetch(`${name} ${host}`, queryOrNull); | ||||
|  | ||||
| 		if (emoji == null) return null; | ||||
| 		return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) | ||||
| @@ -361,7 +361,7 @@ export class CustomEmojiService implements OnApplicationShutdown { | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> { | ||||
| 		const notCachedEmojis = emojis.filter(emoji => this.cache.get(`${emoji.name} ${emoji.host}`) == null); | ||||
| 		const notCachedEmojis = emojis.filter(emoji => this.emojisCache.get(`${emoji.name} ${emoji.host}`) == null); | ||||
| 		const emojisQuery: any[] = []; | ||||
| 		const hosts = new Set(notCachedEmojis.map(e => e.host)); | ||||
| 		for (const host of hosts) { | ||||
| @@ -376,7 +376,7 @@ export class CustomEmojiService implements OnApplicationShutdown { | ||||
| 			select: ['name', 'host', 'originalUrl', 'publicUrl'], | ||||
| 		}) : []; | ||||
| 		for (const emoji of _emojis) { | ||||
| 			this.cache.set(`${emoji.name} ${emoji.host}`, emoji); | ||||
| 			this.emojisCache.set(`${emoji.name} ${emoji.host}`, emoji); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| @@ -401,7 +401,7 @@ export class CustomEmojiService implements OnApplicationShutdown { | ||||
|  | ||||
| 	@bindThis | ||||
| 	public dispose(): void { | ||||
| 		this.cache.dispose(); | ||||
| 		this.emojisCache.dispose(); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
|   | ||||
| @@ -42,7 +42,7 @@ export class DownloadService { | ||||
|  | ||||
| 		const timeout = 30 * 1000; | ||||
| 		const operationTimeout = 60 * 1000; | ||||
| 		const maxSize = this.config.maxFileSize ?? 262144000; | ||||
| 		const maxSize = this.config.maxFileSize; | ||||
|  | ||||
| 		const urlObj = new URL(url); | ||||
| 		let filename = urlObj.pathname.split('/').pop() ?? 'untitled'; | ||||
|   | ||||
| @@ -5,6 +5,7 @@ | ||||
|  | ||||
| import { URLSearchParams } from 'node:url'; | ||||
| import * as nodemailer from 'nodemailer'; | ||||
| import juice from 'juice'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { validate as validateEmail } from 'deep-email-validator'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| @@ -61,14 +62,7 @@ export class EmailService { | ||||
| 			} : undefined, | ||||
| 		} as any); | ||||
|  | ||||
| 		try { | ||||
| 			// TODO: htmlサニタイズ | ||||
| 			const info = await transporter.sendMail({ | ||||
| 				from: meta.email!, | ||||
| 				to: to, | ||||
| 				subject: subject, | ||||
| 				text: text, | ||||
| 				html: `<!doctype html> | ||||
| 		const htmlContent = `<!doctype html> | ||||
| <html> | ||||
| 	<head> | ||||
| 		<meta charset="utf-8"> | ||||
| @@ -147,7 +141,18 @@ export class EmailService { | ||||
| 			<a href="${ this.config.url }">${ this.config.host }</a> | ||||
| 		</nav> | ||||
| 	</body> | ||||
| </html>`, | ||||
| </html>`; | ||||
|  | ||||
| 		const inlinedHtml = juice(htmlContent); | ||||
|  | ||||
| 		try { | ||||
| 			// TODO: htmlサニタイズ | ||||
| 			const info = await transporter.sendMail({ | ||||
| 				from: meta.email!, | ||||
| 				to: to, | ||||
| 				subject: subject, | ||||
| 				text: text, | ||||
| 				html: inlinedHtml, | ||||
| 			}); | ||||
|  | ||||
| 			this.logger.info(`Message sent: ${info.messageId}`); | ||||
|   | ||||
| @@ -87,6 +87,12 @@ export class QueueService { | ||||
| 			repeat: { pattern: '*/5 * * * *' }, | ||||
| 			removeOnComplete: true, | ||||
| 		}); | ||||
|  | ||||
| 		this.systemQueue.add('bakeBufferedReactions', { | ||||
| 		}, { | ||||
| 			repeat: { pattern: '0 0 * * *' }, | ||||
| 			removeOnComplete: true, | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| @@ -452,10 +458,15 @@ export class QueueService { | ||||
|  | ||||
| 	/** | ||||
| 	 * @see UserWebhookDeliverJobData | ||||
| 	 * @see WebhookDeliverProcessorService | ||||
| 	 * @see UserWebhookDeliverProcessorService | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public userWebhookDeliver(webhook: MiWebhook, type: typeof webhookEventTypes[number], content: unknown) { | ||||
| 	public userWebhookDeliver( | ||||
| 		webhook: MiWebhook, | ||||
| 		type: typeof webhookEventTypes[number], | ||||
| 		content: unknown, | ||||
| 		opts?: { attempts?: number }, | ||||
| 	) { | ||||
| 		const data: UserWebhookDeliverJobData = { | ||||
| 			type, | ||||
| 			content, | ||||
| @@ -468,7 +479,7 @@ export class QueueService { | ||||
| 		}; | ||||
|  | ||||
| 		return this.userWebhookDeliverQueue.add(webhook.id, data, { | ||||
| 			attempts: 4, | ||||
| 			attempts: opts?.attempts ?? 4, | ||||
| 			backoff: { | ||||
| 				type: 'custom', | ||||
| 			}, | ||||
| @@ -479,10 +490,15 @@ export class QueueService { | ||||
|  | ||||
| 	/** | ||||
| 	 * @see SystemWebhookDeliverJobData | ||||
| 	 * @see WebhookDeliverProcessorService | ||||
| 	 * @see SystemWebhookDeliverProcessorService | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public systemWebhookDeliver(webhook: MiSystemWebhook, type: SystemWebhookEventType, content: unknown) { | ||||
| 	public systemWebhookDeliver( | ||||
| 		webhook: MiSystemWebhook, | ||||
| 		type: SystemWebhookEventType, | ||||
| 		content: unknown, | ||||
| 		opts?: { attempts?: number }, | ||||
| 	) { | ||||
| 		const data: SystemWebhookDeliverJobData = { | ||||
| 			type, | ||||
| 			content, | ||||
| @@ -494,7 +510,7 @@ export class QueueService { | ||||
| 		}; | ||||
|  | ||||
| 		return this.systemWebhookDeliverQueue.add(webhook.id, data, { | ||||
| 			attempts: 4, | ||||
| 			attempts: opts?.attempts ?? 4, | ||||
| 			backoff: { | ||||
| 				type: 'custom', | ||||
| 			}, | ||||
|   | ||||
| @@ -4,7 +4,6 @@ | ||||
|  */ | ||||
|  | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import * as Redis from 'ioredis'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/_.js'; | ||||
| import { IdentifiableError } from '@/misc/identifiable-error.js'; | ||||
| @@ -30,9 +29,10 @@ import { RoleService } from '@/core/RoleService.js'; | ||||
| import { FeaturedService } from '@/core/FeaturedService.js'; | ||||
| import { trackPromise } from '@/misc/promise-tracker.js'; | ||||
| import { isQuote, isRenote } from '@/misc/is-renote.js'; | ||||
| import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; | ||||
| import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js'; | ||||
|  | ||||
| const FALLBACK = '\u2764'; | ||||
| const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16; | ||||
|  | ||||
| const legacies: Record<string, string> = { | ||||
| 	'like': '👍', | ||||
| @@ -71,9 +71,6 @@ const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/; | ||||
| @Injectable() | ||||
| export class ReactionService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.redis) | ||||
| 		private redisClient: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| @@ -93,6 +90,7 @@ export class ReactionService { | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private noteEntityService: NoteEntityService, | ||||
| 		private userBlockingService: UserBlockingService, | ||||
| 		private reactionsBufferingService: ReactionsBufferingService, | ||||
| 		private idService: IdService, | ||||
| 		private featuredService: FeaturedService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| @@ -174,7 +172,6 @@ export class ReactionService { | ||||
| 			reaction, | ||||
| 		}; | ||||
|  | ||||
| 		// Create reaction | ||||
| 		try { | ||||
| 			await this.noteReactionsRepository.insert(record); | ||||
| 		} catch (e) { | ||||
| @@ -198,16 +195,20 @@ export class ReactionService { | ||||
| 		} | ||||
|  | ||||
| 		// Increment reactions count | ||||
| 		const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; | ||||
| 		await this.notesRepository.createQueryBuilder().update() | ||||
| 			.set({ | ||||
| 				reactions: () => sql, | ||||
| 				...(note.reactionAndUserPairCache.length < PER_NOTE_REACTION_USER_PAIR_CACHE_MAX ? { | ||||
| 					reactionAndUserPairCache: () => `array_append("reactionAndUserPairCache", '${user.id}/${reaction}')`, | ||||
| 				} : {}), | ||||
| 			}) | ||||
| 			.where('id = :id', { id: note.id }) | ||||
| 			.execute(); | ||||
| 		if (meta.enableReactionsBuffering) { | ||||
| 			await this.reactionsBufferingService.create(note.id, user.id, reaction, note.reactionAndUserPairCache); | ||||
| 		} else { | ||||
| 			const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; | ||||
| 			await this.notesRepository.createQueryBuilder().update() | ||||
| 				.set({ | ||||
| 					reactions: () => sql, | ||||
| 					...(note.reactionAndUserPairCache.length < PER_NOTE_REACTION_USER_PAIR_CACHE_MAX ? { | ||||
| 						reactionAndUserPairCache: () => `array_append("reactionAndUserPairCache", '${user.id}/${reaction}')`, | ||||
| 					} : {}), | ||||
| 				}) | ||||
| 				.where('id = :id', { id: note.id }) | ||||
| 				.execute(); | ||||
| 		} | ||||
|  | ||||
| 		// 30%の確率、セルフではない、3日以内に投稿されたノートの場合ハイライト用ランキング更新 | ||||
| 		if ( | ||||
| @@ -304,15 +305,21 @@ export class ReactionService { | ||||
| 			throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); | ||||
| 		} | ||||
|  | ||||
| 		const meta = await this.metaService.fetch(); | ||||
|  | ||||
| 		// Decrement reactions count | ||||
| 		const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`; | ||||
| 		await this.notesRepository.createQueryBuilder().update() | ||||
| 			.set({ | ||||
| 				reactions: () => sql, | ||||
| 				reactionAndUserPairCache: () => `array_remove("reactionAndUserPairCache", '${user.id}/${exist.reaction}')`, | ||||
| 			}) | ||||
| 			.where('id = :id', { id: note.id }) | ||||
| 			.execute(); | ||||
| 		if (meta.enableReactionsBuffering) { | ||||
| 			await this.reactionsBufferingService.delete(note.id, user.id, exist.reaction); | ||||
| 		} else { | ||||
| 			const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`; | ||||
| 			await this.notesRepository.createQueryBuilder().update() | ||||
| 				.set({ | ||||
| 					reactions: () => sql, | ||||
| 					reactionAndUserPairCache: () => `array_remove("reactionAndUserPairCache", '${user.id}/${exist.reaction}')`, | ||||
| 				}) | ||||
| 				.where('id = :id', { id: note.id }) | ||||
| 				.execute(); | ||||
| 		} | ||||
|  | ||||
| 		this.globalEventService.publishNoteStream(note.id, 'unreacted', { | ||||
| 			reaction: this.decodeReaction(exist.reaction).reaction, | ||||
|   | ||||
							
								
								
									
										162
									
								
								packages/backend/src/core/ReactionsBufferingService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								packages/backend/src/core/ReactionsBufferingService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,162 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import * as Redis from 'ioredis'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { MiNote } from '@/models/Note.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import type { MiUser, NotesRepository } from '@/models/_.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js'; | ||||
|  | ||||
| const REDIS_DELTA_PREFIX = 'reactionsBufferDeltas'; | ||||
| const REDIS_PAIR_PREFIX = 'reactionsBufferPairs'; | ||||
|  | ||||
| @Injectable() | ||||
| export class ReactionsBufferingService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 		@Inject(DI.redisForReactions) | ||||
| 		private redisForReactions: Redis.Redis, // TODO: 専用のRedisインスタンスにする | ||||
|  | ||||
| 		@Inject(DI.notesRepository) | ||||
| 		private notesRepository: NotesRepository, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async create(noteId: MiNote['id'], userId: MiUser['id'], reaction: string, currentPairs: string[]): Promise<void> { | ||||
| 		const pipeline = this.redisForReactions.pipeline(); | ||||
| 		pipeline.hincrby(`${REDIS_DELTA_PREFIX}:${noteId}`, reaction, 1); | ||||
| 		for (let i = 0; i < currentPairs.length; i++) { | ||||
| 			pipeline.zadd(`${REDIS_PAIR_PREFIX}:${noteId}`, i, currentPairs[i]); | ||||
| 		} | ||||
| 		pipeline.zadd(`${REDIS_PAIR_PREFIX}:${noteId}`, Date.now(), `${userId}/${reaction}`); | ||||
| 		pipeline.zremrangebyrank(`${REDIS_PAIR_PREFIX}:${noteId}`, 0, -(PER_NOTE_REACTION_USER_PAIR_CACHE_MAX + 1)); | ||||
| 		await pipeline.exec(); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async delete(noteId: MiNote['id'], userId: MiUser['id'], reaction: string): Promise<void> { | ||||
| 		const pipeline = this.redisForReactions.pipeline(); | ||||
| 		pipeline.hincrby(`${REDIS_DELTA_PREFIX}:${noteId}`, reaction, -1); | ||||
| 		pipeline.zrem(`${REDIS_PAIR_PREFIX}:${noteId}`, `${userId}/${reaction}`); | ||||
| 		// TODO: 「消した要素一覧」も持っておかないとcreateされた時に上書きされて復活する | ||||
| 		await pipeline.exec(); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async get(noteId: MiNote['id']): Promise<{ | ||||
| 		deltas: Record<string, number>; | ||||
| 		pairs: ([MiUser['id'], string])[]; | ||||
| 	}> { | ||||
| 		const pipeline = this.redisForReactions.pipeline(); | ||||
| 		pipeline.hgetall(`${REDIS_DELTA_PREFIX}:${noteId}`); | ||||
| 		pipeline.zrange(`${REDIS_PAIR_PREFIX}:${noteId}`, 0, -1); | ||||
| 		const results = await pipeline.exec(); | ||||
|  | ||||
| 		const resultDeltas = results![0][1] as Record<string, string>; | ||||
| 		const resultPairs = results![1][1] as string[]; | ||||
|  | ||||
| 		const deltas = {} as Record<string, number>; | ||||
| 		for (const [name, count] of Object.entries(resultDeltas)) { | ||||
| 			deltas[name] = parseInt(count); | ||||
| 		} | ||||
|  | ||||
| 		const pairs = resultPairs.map(x => x.split('/') as [MiUser['id'], string]); | ||||
|  | ||||
| 		return { | ||||
| 			deltas, | ||||
| 			pairs, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async getMany(noteIds: MiNote['id'][]): Promise<Map<MiNote['id'], { | ||||
| 		deltas: Record<string, number>; | ||||
| 		pairs: ([MiUser['id'], string])[]; | ||||
| 	}>> { | ||||
| 		const map = new Map<MiNote['id'], { | ||||
| 			deltas: Record<string, number>; | ||||
| 			pairs: ([MiUser['id'], string])[]; | ||||
| 		}>(); | ||||
|  | ||||
| 		const pipeline = this.redisForReactions.pipeline(); | ||||
| 		for (const noteId of noteIds) { | ||||
| 			pipeline.hgetall(`${REDIS_DELTA_PREFIX}:${noteId}`); | ||||
| 			pipeline.zrange(`${REDIS_PAIR_PREFIX}:${noteId}`, 0, -1); | ||||
| 		} | ||||
| 		const results = await pipeline.exec(); | ||||
|  | ||||
| 		const opsForEachNotes = 2; | ||||
| 		for (let i = 0; i < noteIds.length; i++) { | ||||
| 			const noteId = noteIds[i]; | ||||
| 			const resultDeltas = results![i * opsForEachNotes][1] as Record<string, string>; | ||||
| 			const resultPairs = results![i * opsForEachNotes + 1][1] as string[]; | ||||
|  | ||||
| 			const deltas = {} as Record<string, number>; | ||||
| 			for (const [name, count] of Object.entries(resultDeltas)) { | ||||
| 				deltas[name] = parseInt(count); | ||||
| 			} | ||||
|  | ||||
| 			const pairs = resultPairs.map(x => x.split('/') as [MiUser['id'], string]); | ||||
|  | ||||
| 			map.set(noteId, { | ||||
| 				deltas, | ||||
| 				pairs, | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		return map; | ||||
| 	} | ||||
|  | ||||
| 	// TODO: scanは重い可能性があるので、別途 bufferedNoteIds を直接Redis上に持っておいてもいいかもしれない | ||||
| 	@bindThis | ||||
| 	public async bake(): Promise<void> { | ||||
| 		const bufferedNoteIds = []; | ||||
| 		let cursor = '0'; | ||||
| 		do { | ||||
| 			// https://github.com/redis/ioredis#transparent-key-prefixing | ||||
| 			const result = await this.redisForReactions.scan( | ||||
| 				cursor, | ||||
| 				'MATCH', | ||||
| 				`${this.config.redis.prefix}:${REDIS_DELTA_PREFIX}:*`, | ||||
| 				'COUNT', | ||||
| 				'1000'); | ||||
|  | ||||
| 			cursor = result[0]; | ||||
| 			bufferedNoteIds.push(...result[1].map(x => x.replace(`${this.config.redis.prefix}:${REDIS_DELTA_PREFIX}:`, ''))); | ||||
| 		} while (cursor !== '0'); | ||||
|  | ||||
| 		const bufferedMap = await this.getMany(bufferedNoteIds); | ||||
|  | ||||
| 		// clear | ||||
| 		const pipeline = this.redisForReactions.pipeline(); | ||||
| 		for (const noteId of bufferedNoteIds) { | ||||
| 			pipeline.del(`${REDIS_DELTA_PREFIX}:${noteId}`); | ||||
| 			pipeline.del(`${REDIS_PAIR_PREFIX}:${noteId}`); | ||||
| 		} | ||||
| 		await pipeline.exec(); | ||||
|  | ||||
| 		// TODO: SQL一個にまとめたい | ||||
| 		for (const [noteId, buffered] of bufferedMap) { | ||||
| 			const sql = Object.entries(buffered.deltas) | ||||
| 				.map(([reaction, count]) => | ||||
| 					`jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + ${count})::text::jsonb)`) | ||||
| 				.join(' || '); | ||||
|  | ||||
| 			this.notesRepository.createQueryBuilder().update() | ||||
| 				.set({ | ||||
| 					reactions: () => sql, | ||||
| 					reactionAndUserPairCache: buffered.pairs.map(x => x.join('/')), | ||||
| 				}) | ||||
| 				.where('id = :id', { id: noteId }) | ||||
| 				.execute(); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -35,7 +35,7 @@ export class RelayService { | ||||
| 		private createSystemUserService: CreateSystemUserService, | ||||
| 		private apRendererService: ApRendererService, | ||||
| 	) { | ||||
| 		this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10); | ||||
| 		this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10); // 10m | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
|   | ||||
| @@ -58,6 +58,11 @@ export type RolePolicies = { | ||||
| 	userEachUserListsLimit: number; | ||||
| 	rateLimitFactor: number; | ||||
| 	avatarDecorationLimit: number; | ||||
| 	canImportAntennas: boolean; | ||||
| 	canImportBlocking: boolean; | ||||
| 	canImportFollowing: boolean; | ||||
| 	canImportMuting: boolean; | ||||
| 	canImportUserLists: boolean; | ||||
| }; | ||||
|  | ||||
| export const DEFAULT_POLICIES: RolePolicies = { | ||||
| @@ -87,6 +92,11 @@ export const DEFAULT_POLICIES: RolePolicies = { | ||||
| 	userEachUserListsLimit: 50, | ||||
| 	rateLimitFactor: 1, | ||||
| 	avatarDecorationLimit: 1, | ||||
| 	canImportAntennas: true, | ||||
| 	canImportBlocking: true, | ||||
| 	canImportFollowing: true, | ||||
| 	canImportMuting: true, | ||||
| 	canImportUserLists: true, | ||||
| }; | ||||
|  | ||||
| @Injectable() | ||||
| @@ -127,10 +137,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { | ||||
| 		private moderationLogService: ModerationLogService, | ||||
| 		private fanoutTimelineService: FanoutTimelineService, | ||||
| 	) { | ||||
| 		//this.onMessage = this.onMessage.bind(this); | ||||
|  | ||||
| 		this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60 * 1); | ||||
| 		this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 60 * 1); | ||||
| 		this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h | ||||
| 		this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m | ||||
|  | ||||
| 		this.redisForSub.on('message', this.onMessage); | ||||
| 	} | ||||
| @@ -389,6 +397,11 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { | ||||
| 			userEachUserListsLimit: calc('userEachUserListsLimit', vs => Math.max(...vs)), | ||||
| 			rateLimitFactor: calc('rateLimitFactor', vs => Math.max(...vs)), | ||||
| 			avatarDecorationLimit: calc('avatarDecorationLimit', vs => Math.max(...vs)), | ||||
| 			canImportAntennas: calc('canImportAntennas', vs => vs.some(v => v === true)), | ||||
| 			canImportBlocking: calc('canImportBlocking', vs => vs.some(v => v === true)), | ||||
| 			canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)), | ||||
| 			canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)), | ||||
| 			canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)), | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -54,7 +54,7 @@ export class SystemWebhookService implements OnApplicationShutdown { | ||||
| 	 * SystemWebhook の一覧を取得する. | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async fetchSystemWebhooks(params?: { | ||||
| 	public fetchSystemWebhooks(params?: { | ||||
| 		ids?: MiSystemWebhook['id'][]; | ||||
| 		isActive?: MiSystemWebhook['isActive']; | ||||
| 		on?: MiSystemWebhook['on']; | ||||
| @@ -165,19 +165,24 @@ export class SystemWebhookService implements OnApplicationShutdown { | ||||
| 	/** | ||||
| 	 * SystemWebhook をWebhook配送キューに追加する | ||||
| 	 * @see QueueService.systemWebhookDeliver | ||||
| 	 * // TODO: contentの型を厳格化する | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async enqueueSystemWebhook(webhook: MiSystemWebhook | MiSystemWebhook['id'], type: SystemWebhookEventType, content: unknown) { | ||||
| 	public async enqueueSystemWebhook<T extends SystemWebhookEventType>( | ||||
| 		webhook: MiSystemWebhook | MiSystemWebhook['id'], | ||||
| 		type: T, | ||||
| 		content: unknown, | ||||
| 	) { | ||||
| 		const webhookEntity = typeof webhook === 'string' | ||||
| 			? (await this.fetchActiveSystemWebhooks()).find(a => a.id === webhook) | ||||
| 			: webhook; | ||||
| 		if (!webhookEntity || !webhookEntity.isActive) { | ||||
| 			this.logger.info(`Webhook is not active or not found : ${webhook}`); | ||||
| 			this.logger.info(`SystemWebhook is not active or not found : ${webhook}`); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		if (!webhookEntity.on.includes(type)) { | ||||
| 			this.logger.info(`Webhook ${webhookEntity.id} is not listening to ${type}`); | ||||
| 			this.logger.info(`SystemWebhook ${webhookEntity.id} is not listening to ${type}`); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
|   | ||||
| @@ -25,7 +25,7 @@ export class UserKeypairService implements OnApplicationShutdown { | ||||
| 	) { | ||||
| 		this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', { | ||||
| 			lifetime: 1000 * 60 * 60 * 24, // 24h | ||||
| 			memoryCacheLifetime: Infinity, | ||||
| 			memoryCacheLifetime: 1000 * 60 * 60, // 1h | ||||
| 			fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }), | ||||
| 			toRedisConverter: (value) => JSON.stringify(value), | ||||
| 			fromRedisConverter: (value) => JSON.parse(value), | ||||
|   | ||||
| @@ -5,8 +5,8 @@ | ||||
|  | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import * as Redis from 'ioredis'; | ||||
| import type { WebhooksRepository } from '@/models/_.js'; | ||||
| import type { MiWebhook } from '@/models/Webhook.js'; | ||||
| import { type WebhooksRepository } from '@/models/_.js'; | ||||
| import { MiWebhook } from '@/models/Webhook.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { GlobalEvents } from '@/core/GlobalEventService.js'; | ||||
| @@ -38,6 +38,31 @@ export class UserWebhookService implements OnApplicationShutdown { | ||||
| 		return this.activeWebhooks; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * UserWebhook の一覧を取得する. | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public fetchWebhooks(params?: { | ||||
| 		ids?: MiWebhook['id'][]; | ||||
| 		isActive?: MiWebhook['active']; | ||||
| 		on?: MiWebhook['on']; | ||||
| 	}): Promise<MiWebhook[]> { | ||||
| 		const query = this.webhooksRepository.createQueryBuilder('webhook'); | ||||
| 		if (params) { | ||||
| 			if (params.ids && params.ids.length > 0) { | ||||
| 				query.andWhere('webhook.id IN (:...ids)', { ids: params.ids }); | ||||
| 			} | ||||
| 			if (params.isActive !== undefined) { | ||||
| 				query.andWhere('webhook.active = :isActive', { isActive: params.isActive }); | ||||
| 			} | ||||
| 			if (params.on && params.on.length > 0) { | ||||
| 				query.andWhere(':on <@ webhook.on', { on: params.on }); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return query.getMany(); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	private async onMessage(_: string, data: string): Promise<void> { | ||||
| 		const obj = JSON.parse(data); | ||||
|   | ||||
							
								
								
									
										434
									
								
								packages/backend/src/core/WebhookTestService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										434
									
								
								packages/backend/src/core/WebhookTestService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,434 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { MiAbuseUserReport, MiNote, MiUser, MiWebhook } from '@/models/_.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { MiSystemWebhook, type SystemWebhookEventType } from '@/models/SystemWebhook.js'; | ||||
| import { SystemWebhookService } from '@/core/SystemWebhookService.js'; | ||||
| import { Packed } from '@/misc/json-schema.js'; | ||||
| import { type WebhookEventTypes } from '@/models/Webhook.js'; | ||||
| import { UserWebhookService } from '@/core/UserWebhookService.js'; | ||||
| import { QueueService } from '@/core/QueueService.js'; | ||||
|  | ||||
| const oneDayMillis = 24 * 60 * 60 * 1000; | ||||
|  | ||||
| function generateAbuseReport(override?: Partial<MiAbuseUserReport>): MiAbuseUserReport { | ||||
| 	return { | ||||
| 		id: 'dummy-abuse-report1', | ||||
| 		targetUserId: 'dummy-target-user', | ||||
| 		targetUser: null, | ||||
| 		reporterId: 'dummy-reporter-user', | ||||
| 		reporter: null, | ||||
| 		assigneeId: null, | ||||
| 		assignee: null, | ||||
| 		resolved: false, | ||||
| 		forwarded: false, | ||||
| 		comment: 'This is a dummy report for testing purposes.', | ||||
| 		targetUserHost: null, | ||||
| 		reporterHost: null, | ||||
| 		...override, | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| function generateDummyUser(override?: Partial<MiUser>): MiUser { | ||||
| 	return { | ||||
| 		id: 'dummy-user-1', | ||||
| 		updatedAt: new Date(Date.now() - oneDayMillis * 7), | ||||
| 		lastFetchedAt: new Date(Date.now() - oneDayMillis * 5), | ||||
| 		lastActiveDate: new Date(Date.now() - oneDayMillis * 3), | ||||
| 		hideOnlineStatus: false, | ||||
| 		username: 'dummy1', | ||||
| 		usernameLower: 'dummy1', | ||||
| 		name: 'DummyUser1', | ||||
| 		followersCount: 10, | ||||
| 		followingCount: 5, | ||||
| 		movedToUri: null, | ||||
| 		movedAt: null, | ||||
| 		alsoKnownAs: null, | ||||
| 		notesCount: 30, | ||||
| 		avatarId: null, | ||||
| 		avatar: null, | ||||
| 		bannerId: null, | ||||
| 		banner: null, | ||||
| 		avatarUrl: null, | ||||
| 		bannerUrl: null, | ||||
| 		avatarBlurhash: null, | ||||
| 		bannerBlurhash: null, | ||||
| 		avatarDecorations: [], | ||||
| 		tags: [], | ||||
| 		isSuspended: false, | ||||
| 		isLocked: false, | ||||
| 		isBot: false, | ||||
| 		isCat: true, | ||||
| 		isRoot: false, | ||||
| 		isExplorable: true, | ||||
| 		isHibernated: false, | ||||
| 		isDeleted: false, | ||||
| 		emojis: [], | ||||
| 		host: null, | ||||
| 		inbox: null, | ||||
| 		sharedInbox: null, | ||||
| 		featured: null, | ||||
| 		uri: null, | ||||
| 		followersUri: null, | ||||
| 		token: null, | ||||
| 		...override, | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| function generateDummyNote(override?: Partial<MiNote>): MiNote { | ||||
| 	return { | ||||
| 		id: 'dummy-note-1', | ||||
| 		replyId: null, | ||||
| 		reply: null, | ||||
| 		renoteId: null, | ||||
| 		renote: null, | ||||
| 		threadId: null, | ||||
| 		text: 'This is a dummy note for testing purposes.', | ||||
| 		name: null, | ||||
| 		cw: null, | ||||
| 		userId: 'dummy-user-1', | ||||
| 		user: null, | ||||
| 		localOnly: true, | ||||
| 		reactionAcceptance: 'likeOnly', | ||||
| 		renoteCount: 10, | ||||
| 		repliesCount: 5, | ||||
| 		clippedCount: 0, | ||||
| 		reactions: {}, | ||||
| 		visibility: 'public', | ||||
| 		uri: null, | ||||
| 		url: null, | ||||
| 		fileIds: [], | ||||
| 		attachedFileTypes: [], | ||||
| 		visibleUserIds: [], | ||||
| 		mentions: [], | ||||
| 		mentionedRemoteUsers: '[]', | ||||
| 		reactionAndUserPairCache: [], | ||||
| 		emojis: [], | ||||
| 		tags: [], | ||||
| 		hasPoll: false, | ||||
| 		channelId: null, | ||||
| 		channel: null, | ||||
| 		userHost: null, | ||||
| 		replyUserId: null, | ||||
| 		replyUserHost: null, | ||||
| 		renoteUserId: null, | ||||
| 		renoteUserHost: null, | ||||
| 		...override, | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| function toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Packed<'Note'> { | ||||
| 	return { | ||||
| 		id: note.id, | ||||
| 		createdAt: new Date().toISOString(), | ||||
| 		deletedAt: null, | ||||
| 		text: note.text, | ||||
| 		cw: note.cw, | ||||
| 		userId: note.userId, | ||||
| 		user: toPackedUserLite(note.user ?? generateDummyUser()), | ||||
| 		replyId: note.replyId, | ||||
| 		renoteId: note.renoteId, | ||||
| 		isHidden: false, | ||||
| 		visibility: note.visibility, | ||||
| 		mentions: note.mentions, | ||||
| 		visibleUserIds: note.visibleUserIds, | ||||
| 		fileIds: note.fileIds, | ||||
| 		files: [], | ||||
| 		tags: note.tags, | ||||
| 		poll: null, | ||||
| 		emojis: note.emojis, | ||||
| 		channelId: note.channelId, | ||||
| 		channel: note.channel, | ||||
| 		localOnly: note.localOnly, | ||||
| 		reactionAcceptance: note.reactionAcceptance, | ||||
| 		reactionEmojis: {}, | ||||
| 		reactions: {}, | ||||
| 		reactionCount: 0, | ||||
| 		renoteCount: note.renoteCount, | ||||
| 		repliesCount: note.repliesCount, | ||||
| 		uri: note.uri ?? undefined, | ||||
| 		url: note.url ?? undefined, | ||||
| 		reactionAndUserPairCache: note.reactionAndUserPairCache, | ||||
| 		...(detail ? { | ||||
| 			clippedCount: note.clippedCount, | ||||
| 			reply: note.reply ? toPackedNote(note.reply, false) : null, | ||||
| 			renote: note.renote ? toPackedNote(note.renote, true) : null, | ||||
| 			myReaction: null, | ||||
| 		} : {}), | ||||
| 		...override, | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| function toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Packed<'UserLite'> { | ||||
| 	return { | ||||
| 		id: user.id, | ||||
| 		name: user.name, | ||||
| 		username: user.username, | ||||
| 		host: user.host, | ||||
| 		avatarUrl: user.avatarUrl, | ||||
| 		avatarBlurhash: user.avatarBlurhash, | ||||
| 		avatarDecorations: user.avatarDecorations.map(it => ({ | ||||
| 			id: it.id, | ||||
| 			angle: it.angle, | ||||
| 			flipH: it.flipH, | ||||
| 			url: 'https://example.com/dummy-image001.png', | ||||
| 			offsetX: it.offsetX, | ||||
| 			offsetY: it.offsetY, | ||||
| 		})), | ||||
| 		isBot: user.isBot, | ||||
| 		isCat: user.isCat, | ||||
| 		emojis: user.emojis, | ||||
| 		onlineStatus: 'active', | ||||
| 		badgeRoles: [], | ||||
| 		...override, | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailedNotMe'>): Packed<'UserDetailedNotMe'> { | ||||
| 	return { | ||||
| 		...toPackedUserLite(user), | ||||
| 		url: null, | ||||
| 		uri: null, | ||||
| 		movedTo: null, | ||||
| 		alsoKnownAs: [], | ||||
| 		createdAt: new Date().toISOString(), | ||||
| 		updatedAt: user.updatedAt?.toISOString() ?? null, | ||||
| 		lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null, | ||||
| 		bannerUrl: user.bannerUrl, | ||||
| 		bannerBlurhash: user.bannerBlurhash, | ||||
| 		isLocked: user.isLocked, | ||||
| 		isSilenced: false, | ||||
| 		isSuspended: user.isSuspended, | ||||
| 		description: null, | ||||
| 		location: null, | ||||
| 		birthday: null, | ||||
| 		lang: null, | ||||
| 		fields: [], | ||||
| 		verifiedLinks: [], | ||||
| 		followersCount: user.followersCount, | ||||
| 		followingCount: user.followingCount, | ||||
| 		notesCount: user.notesCount, | ||||
| 		pinnedNoteIds: [], | ||||
| 		pinnedNotes: [], | ||||
| 		pinnedPageId: null, | ||||
| 		pinnedPage: null, | ||||
| 		publicReactions: true, | ||||
| 		followersVisibility: 'public', | ||||
| 		followingVisibility: 'public', | ||||
| 		twoFactorEnabled: false, | ||||
| 		usePasswordLessLogin: false, | ||||
| 		securityKeys: false, | ||||
| 		roles: [], | ||||
| 		memo: null, | ||||
| 		moderationNote: undefined, | ||||
| 		isFollowing: false, | ||||
| 		isFollowed: false, | ||||
| 		hasPendingFollowRequestFromYou: false, | ||||
| 		hasPendingFollowRequestToYou: false, | ||||
| 		isBlocking: false, | ||||
| 		isBlocked: false, | ||||
| 		isMuted: false, | ||||
| 		isRenoteMuted: false, | ||||
| 		notify: 'none', | ||||
| 		withReplies: true, | ||||
| 		...override, | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| const dummyUser1 = generateDummyUser(); | ||||
| const dummyUser2 = generateDummyUser({ | ||||
| 	id: 'dummy-user-2', | ||||
| 	updatedAt: new Date(Date.now() - oneDayMillis * 30), | ||||
| 	lastFetchedAt: new Date(Date.now() - oneDayMillis), | ||||
| 	lastActiveDate: new Date(Date.now() - oneDayMillis), | ||||
| 	username: 'dummy2', | ||||
| 	usernameLower: 'dummy2', | ||||
| 	name: 'DummyUser2', | ||||
| 	followersCount: 40, | ||||
| 	followingCount: 50, | ||||
| 	notesCount: 900, | ||||
| }); | ||||
| const dummyUser3 = generateDummyUser({ | ||||
| 	id: 'dummy-user-3', | ||||
| 	updatedAt: new Date(Date.now() - oneDayMillis * 15), | ||||
| 	lastFetchedAt: new Date(Date.now() - oneDayMillis * 2), | ||||
| 	lastActiveDate: new Date(Date.now() - oneDayMillis * 2), | ||||
| 	username: 'dummy3', | ||||
| 	usernameLower: 'dummy3', | ||||
| 	name: 'DummyUser3', | ||||
| 	followersCount: 60, | ||||
| 	followingCount: 70, | ||||
| 	notesCount: 15900, | ||||
| }); | ||||
|  | ||||
| @Injectable() | ||||
| export class WebhookTestService { | ||||
| 	public static NoSuchWebhookError = class extends Error {}; | ||||
|  | ||||
| 	constructor( | ||||
| 		private userWebhookService: UserWebhookService, | ||||
| 		private systemWebhookService: SystemWebhookService, | ||||
| 		private queueService: QueueService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * UserWebhookのテスト送信を行う. | ||||
| 	 * 送信されるペイロードはいずれもダミーの値で、実際にはデータベース上に存在しない. | ||||
| 	 * | ||||
| 	 * また、この関数経由で送信されるWebhookは以下の設定を無視する. | ||||
| 	 * - Webhookそのものの有効・無効設定(active) | ||||
| 	 * - 送信対象イベント(on)に関する設定 | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async testUserWebhook( | ||||
| 		params: { | ||||
| 			webhookId: MiWebhook['id'], | ||||
| 			type: WebhookEventTypes, | ||||
| 			override?: Partial<Omit<MiWebhook, 'id'>>, | ||||
| 		}, | ||||
| 		sender: MiUser | null, | ||||
| 	) { | ||||
| 		const webhooks = await this.userWebhookService.fetchWebhooks({ ids: [params.webhookId] }) | ||||
| 			.then(it => it.filter(it => it.userId === sender?.id)); | ||||
| 		if (webhooks.length === 0) { | ||||
| 			throw new WebhookTestService.NoSuchWebhookError(); | ||||
| 		} | ||||
|  | ||||
| 		const webhook = webhooks[0]; | ||||
| 		const send = (contents: unknown) => { | ||||
| 			const merged = { | ||||
| 				...webhook, | ||||
| 				...params.override, | ||||
| 			}; | ||||
|  | ||||
| 			// テスト目的なのでUserWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図). | ||||
| 			// また、Jobの試行回数も1回だけ. | ||||
| 			this.queueService.userWebhookDeliver(merged, params.type, contents, { attempts: 1 }); | ||||
| 		}; | ||||
|  | ||||
| 		const dummyNote1 = generateDummyNote({ | ||||
| 			userId: dummyUser1.id, | ||||
| 			user: dummyUser1, | ||||
| 		}); | ||||
| 		const dummyReply1 = generateDummyNote({ | ||||
| 			id: 'dummy-reply-1', | ||||
| 			replyId: dummyNote1.id, | ||||
| 			reply: dummyNote1, | ||||
| 			userId: dummyUser1.id, | ||||
| 			user: dummyUser1, | ||||
| 		}); | ||||
| 		const dummyRenote1 = generateDummyNote({ | ||||
| 			id: 'dummy-renote-1', | ||||
| 			renoteId: dummyNote1.id, | ||||
| 			renote: dummyNote1, | ||||
| 			userId: dummyUser2.id, | ||||
| 			user: dummyUser2, | ||||
| 			text: null, | ||||
| 		}); | ||||
| 		const dummyMention1 = generateDummyNote({ | ||||
| 			id: 'dummy-mention-1', | ||||
| 			userId: dummyUser1.id, | ||||
| 			user: dummyUser1, | ||||
| 			text: `@${dummyUser2.username} This is a mention to you.`, | ||||
| 			mentions: [dummyUser2.id], | ||||
| 		}); | ||||
|  | ||||
| 		switch (params.type) { | ||||
| 			case 'note': { | ||||
| 				send(toPackedNote(dummyNote1)); | ||||
| 				break; | ||||
| 			} | ||||
| 			case 'reply': { | ||||
| 				send(toPackedNote(dummyReply1)); | ||||
| 				break; | ||||
| 			} | ||||
| 			case 'renote': { | ||||
| 				send(toPackedNote(dummyRenote1)); | ||||
| 				break; | ||||
| 			} | ||||
| 			case 'mention': { | ||||
| 				send(toPackedNote(dummyMention1)); | ||||
| 				break; | ||||
| 			} | ||||
| 			case 'follow': { | ||||
| 				send(toPackedUserDetailedNotMe(dummyUser1)); | ||||
| 				break; | ||||
| 			} | ||||
| 			case 'followed': { | ||||
| 				send(toPackedUserLite(dummyUser2)); | ||||
| 				break; | ||||
| 			} | ||||
| 			case 'unfollow': { | ||||
| 				send(toPackedUserDetailedNotMe(dummyUser3)); | ||||
| 				break; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * SystemWebhookのテスト送信を行う. | ||||
| 	 * 送信されるペイロードはいずれもダミーの値で、実際にはデータベース上に存在しない. | ||||
| 	 * | ||||
| 	 * また、この関数経由で送信されるWebhookは以下の設定を無視する. | ||||
| 	 * - Webhookそのものの有効・無効設定(isActive) | ||||
| 	 * - 送信対象イベント(on)に関する設定 | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async testSystemWebhook( | ||||
| 		params: { | ||||
| 			webhookId: MiSystemWebhook['id'], | ||||
| 			type: SystemWebhookEventType, | ||||
| 			override?: Partial<Omit<MiSystemWebhook, 'id'>>, | ||||
| 		}, | ||||
| 	) { | ||||
| 		const webhooks = await this.systemWebhookService.fetchSystemWebhooks({ ids: [params.webhookId] }); | ||||
| 		if (webhooks.length === 0) { | ||||
| 			throw new WebhookTestService.NoSuchWebhookError(); | ||||
| 		} | ||||
|  | ||||
| 		const webhook = webhooks[0]; | ||||
| 		const send = (contents: unknown) => { | ||||
| 			const merged = { | ||||
| 				...webhook, | ||||
| 				...params.override, | ||||
| 			}; | ||||
|  | ||||
| 			// テスト目的なのでSystemWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図). | ||||
| 			// また、Jobの試行回数も1回だけ. | ||||
| 			this.queueService.systemWebhookDeliver(merged, params.type, contents, { attempts: 1 }); | ||||
| 		}; | ||||
|  | ||||
| 		switch (params.type) { | ||||
| 			case 'abuseReport': { | ||||
| 				send(generateAbuseReport({ | ||||
| 					targetUserId: dummyUser1.id, | ||||
| 					targetUser: dummyUser1, | ||||
| 					reporterId: dummyUser2.id, | ||||
| 					reporter: dummyUser2, | ||||
| 				})); | ||||
| 				break; | ||||
| 			} | ||||
| 			case 'abuseReportResolved': { | ||||
| 				send(generateAbuseReport({ | ||||
| 					targetUserId: dummyUser1.id, | ||||
| 					targetUser: dummyUser1, | ||||
| 					reporterId: dummyUser2.id, | ||||
| 					reporter: dummyUser2, | ||||
| 					assigneeId: dummyUser3.id, | ||||
| 					assignee: dummyUser3, | ||||
| 					resolved: true, | ||||
| 				})); | ||||
| 				break; | ||||
| 			} | ||||
| 			case 'userCreated': { | ||||
| 				send(toPackedUserLite(dummyUser1)); | ||||
| 				break; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -54,8 +54,8 @@ export class ApDbResolverService implements OnApplicationShutdown { | ||||
| 		private cacheService: CacheService, | ||||
| 		private apPersonService: ApPersonService, | ||||
| 	) { | ||||
| 		this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(Infinity); | ||||
| 		this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(Infinity); | ||||
| 		this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h | ||||
| 		this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
|   | ||||
| @@ -207,16 +207,41 @@ export class ApRequestService { | ||||
|  | ||||
| 		if ((contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' && _followAlternate === true) { | ||||
| 			const html = await res.text(); | ||||
| 			const window = new Window(); | ||||
| 			const window = new Window({ | ||||
| 				settings: { | ||||
| 					disableJavaScriptEvaluation: true, | ||||
| 					disableJavaScriptFileLoading: true, | ||||
| 					disableCSSFileLoading: true, | ||||
| 					disableComputedStyleRendering: true, | ||||
| 					handleDisabledFileLoadingAsSuccess: true, | ||||
| 					navigation: { | ||||
| 						disableMainFrameNavigation: true, | ||||
| 						disableChildFrameNavigation: true, | ||||
| 						disableChildPageNavigation: true, | ||||
| 						disableFallbackToSetURL: true, | ||||
| 					}, | ||||
| 					timer: { | ||||
| 						maxTimeout: 0, | ||||
| 						maxIntervalTime: 0, | ||||
| 						maxIntervalIterations: 0, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}); | ||||
| 			const document = window.document; | ||||
| 			document.documentElement.innerHTML = html; | ||||
| 			try { | ||||
| 				document.documentElement.innerHTML = html; | ||||
|  | ||||
| 			const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]'); | ||||
| 			if (alternate) { | ||||
| 				const href = alternate.getAttribute('href'); | ||||
| 				if (href) { | ||||
| 					return await this.signedGet(href, user, false); | ||||
| 				const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]'); | ||||
| 				if (alternate) { | ||||
| 					const href = alternate.getAttribute('href'); | ||||
| 					if (href) { | ||||
| 						return await this.signedGet(href, user, false); | ||||
| 					} | ||||
| 				} | ||||
| 			} catch (e) { | ||||
| 				// something went wrong parsing the HTML, ignore the whole thing | ||||
| 			} finally { | ||||
| 				window.close(); | ||||
| 			} | ||||
| 		} | ||||
| 		//#endregion | ||||
|   | ||||
| @@ -65,21 +65,21 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di | ||||
| 			this.followingsRepository.createQueryBuilder('following') | ||||
| 				.select('COUNT(DISTINCT following.followeeHost)') | ||||
| 				.where('following.followeeHost IS NOT NULL') | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) | ||||
| 				.getRawOne() | ||||
| 				.then(x => parseInt(x.count, 10)), | ||||
| 			this.followingsRepository.createQueryBuilder('following') | ||||
| 				.select('COUNT(DISTINCT following.followerHost)') | ||||
| 				.where('following.followerHost IS NOT NULL') | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) | ||||
| 				.getRawOne() | ||||
| 				.then(x => parseInt(x.count, 10)), | ||||
| 			this.followingsRepository.createQueryBuilder('following') | ||||
| 				.select('COUNT(DISTINCT following.followeeHost)') | ||||
| 				.where('following.followeeHost IS NOT NULL') | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) | ||||
| 				.andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`) | ||||
| 				.setParameters(pubsubSubQuery.getParameters()) | ||||
| @@ -88,7 +88,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di | ||||
| 			this.instancesRepository.createQueryBuilder('instance') | ||||
| 				.select('COUNT(instance.id)') | ||||
| 				.where(`instance.host IN (${ subInstancesQuery.getQuery() })`) | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere('instance.suspensionState = \'none\'') | ||||
| 				.andWhere('instance.isNotResponding = false') | ||||
| 				.getRawOne() | ||||
| @@ -96,7 +96,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di | ||||
| 			this.instancesRepository.createQueryBuilder('instance') | ||||
| 				.select('COUNT(instance.id)') | ||||
| 				.where(`instance.host IN (${ pubInstancesQuery.getQuery() })`) | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere('instance.suspensionState = \'none\'') | ||||
| 				.andWhere('instance.isNotResponding = false') | ||||
| 				.getRawOne() | ||||
|   | ||||
| @@ -129,6 +129,7 @@ export class MetaEntityService { | ||||
| 			mediaProxy: this.config.mediaProxy, | ||||
| 			enableUrlPreview: instance.urlPreviewEnabled, | ||||
| 			noteSearchableScope: (this.config.meilisearch == null || this.config.meilisearch.scope !== 'local') ? 'global' : 'local', | ||||
| 			maxFileSize: this.config.maxFileSize, | ||||
| 		}; | ||||
|  | ||||
| 		return packed; | ||||
|   | ||||
| @@ -11,24 +11,39 @@ import type { Packed } from '@/misc/json-schema.js'; | ||||
| import { awaitAll } from '@/misc/prelude/await-all.js'; | ||||
| import type { MiUser } from '@/models/User.js'; | ||||
| import type { MiNote } from '@/models/Note.js'; | ||||
| import type { MiNoteReaction } from '@/models/NoteReaction.js'; | ||||
| import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository } from '@/models/_.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { DebounceLoader } from '@/misc/loader.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import type { OnModuleInit } from '@nestjs/common'; | ||||
| import type { CustomEmojiService } from '../CustomEmojiService.js'; | ||||
| import type { ReactionService } from '../ReactionService.js'; | ||||
| import type { UserEntityService } from './UserEntityService.js'; | ||||
| import type { DriveFileEntityService } from './DriveFileEntityService.js'; | ||||
|  | ||||
| function mergeReactions(src: Record<string, number>, delta: Record<string, number>) { | ||||
| 	const reactions = { ...src }; | ||||
| 	for (const [name, count] of Object.entries(delta)) { | ||||
| 		if (reactions[name] != null) { | ||||
| 			reactions[name] += count; | ||||
| 		} else { | ||||
| 			reactions[name] = count; | ||||
| 		} | ||||
| 	} | ||||
| 	return reactions; | ||||
| } | ||||
|  | ||||
| @Injectable() | ||||
| export class NoteEntityService implements OnModuleInit { | ||||
| 	private userEntityService: UserEntityService; | ||||
| 	private driveFileEntityService: DriveFileEntityService; | ||||
| 	private customEmojiService: CustomEmojiService; | ||||
| 	private reactionService: ReactionService; | ||||
| 	private reactionsBufferingService: ReactionsBufferingService; | ||||
| 	private idService: IdService; | ||||
| 	private metaService: MetaService; | ||||
| 	private noteLoader = new DebounceLoader(this.findNoteOrFail); | ||||
|  | ||||
| 	constructor( | ||||
| @@ -59,6 +74,9 @@ export class NoteEntityService implements OnModuleInit { | ||||
| 		//private driveFileEntityService: DriveFileEntityService, | ||||
| 		//private customEmojiService: CustomEmojiService, | ||||
| 		//private reactionService: ReactionService, | ||||
| 		//private reactionsBufferingService: ReactionsBufferingService, | ||||
| 		//private idService: IdService, | ||||
| 		//private metaService: MetaService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| @@ -67,7 +85,9 @@ export class NoteEntityService implements OnModuleInit { | ||||
| 		this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); | ||||
| 		this.customEmojiService = this.moduleRef.get('CustomEmojiService'); | ||||
| 		this.reactionService = this.moduleRef.get('ReactionService'); | ||||
| 		this.reactionsBufferingService = this.moduleRef.get('ReactionsBufferingService'); | ||||
| 		this.idService = this.moduleRef.get('IdService'); | ||||
| 		this.metaService = this.moduleRef.get('MetaService'); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| @@ -287,6 +307,7 @@ export class NoteEntityService implements OnModuleInit { | ||||
| 			skipHide?: boolean; | ||||
| 			withReactionAndUserPairCache?: boolean; | ||||
| 			_hint_?: { | ||||
| 				bufferdReactions: Map<MiNote['id'], { deltas: Record<string, number>; pairs: ([MiUser['id'], string])[] }> | null; | ||||
| 				myReactions: Map<MiNote['id'], string | null>; | ||||
| 				packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>; | ||||
| 				packedUsers: Map<MiUser['id'], Packed<'UserLite'>> | ||||
| @@ -303,6 +324,22 @@ export class NoteEntityService implements OnModuleInit { | ||||
| 		const note = typeof src === 'object' ? src : await this.noteLoader.load(src); | ||||
| 		const host = note.userHost; | ||||
|  | ||||
| 		const meta = await this.metaService.fetch(); | ||||
|  | ||||
| 		const bufferdReactions = opts._hint_?.bufferdReactions != null | ||||
| 			? (opts._hint_.bufferdReactions.get(note.id) ?? { deltas: {}, pairs: [] }) | ||||
| 			: meta.enableReactionsBuffering | ||||
| 				? await this.reactionsBufferingService.get(note.id) | ||||
| 				: { deltas: {}, pairs: [] }; | ||||
| 		const reactions = mergeReactions(note.reactions, bufferdReactions.deltas ?? {}); | ||||
| 		for (const [name, count] of Object.entries(reactions)) { | ||||
| 			if (count <= 0) { | ||||
| 				delete reactions[name]; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		const reactionAndUserPairCache = note.reactionAndUserPairCache.concat(bufferdReactions.pairs.map(x => x.join('/'))); | ||||
|  | ||||
| 		let text = note.text; | ||||
|  | ||||
| 		if (note.name && (note.url ?? note.uri)) { | ||||
| @@ -315,7 +352,7 @@ export class NoteEntityService implements OnModuleInit { | ||||
| 				: await this.channelsRepository.findOneBy({ id: note.channelId }) | ||||
| 			: null; | ||||
|  | ||||
| 		const reactionEmojiNames = Object.keys(note.reactions) | ||||
| 		const reactionEmojiNames = Object.keys(reactions) | ||||
| 			.filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ | ||||
| 			.map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); | ||||
| 		const packedFiles = options?._hint_?.packedFiles; | ||||
| @@ -334,10 +371,10 @@ export class NoteEntityService implements OnModuleInit { | ||||
| 			visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, | ||||
| 			renoteCount: note.renoteCount, | ||||
| 			repliesCount: note.repliesCount, | ||||
| 			reactionCount: Object.values(note.reactions).reduce((a, b) => a + b, 0), | ||||
| 			reactions: this.reactionService.convertLegacyReactions(note.reactions), | ||||
| 			reactionCount: Object.values(reactions).reduce((a, b) => a + b, 0), | ||||
| 			reactions: reactions, | ||||
| 			reactionEmojis: this.customEmojiService.populateEmojis(reactionEmojiNames, host), | ||||
| 			reactionAndUserPairCache: opts.withReactionAndUserPairCache ? note.reactionAndUserPairCache : undefined, | ||||
| 			reactionAndUserPairCache: opts.withReactionAndUserPairCache ? reactionAndUserPairCache : undefined, | ||||
| 			emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined, | ||||
| 			tags: note.tags.length > 0 ? note.tags : undefined, | ||||
| 			fileIds: note.fileIds, | ||||
| @@ -376,8 +413,12 @@ export class NoteEntityService implements OnModuleInit { | ||||
|  | ||||
| 				poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, | ||||
|  | ||||
| 				...(meId && Object.keys(note.reactions).length > 0 ? { | ||||
| 					myReaction: this.populateMyReaction(note, meId, options?._hint_), | ||||
| 				...(meId && Object.keys(reactions).length > 0 ? { | ||||
| 					myReaction: this.populateMyReaction({ | ||||
| 						id: note.id, | ||||
| 						reactions: reactions, | ||||
| 						reactionAndUserPairCache: reactionAndUserPairCache, | ||||
| 					}, meId, options?._hint_), | ||||
| 				} : {}), | ||||
| 			} : {}), | ||||
| 		}); | ||||
| @@ -400,6 +441,10 @@ export class NoteEntityService implements OnModuleInit { | ||||
| 	) { | ||||
| 		if (notes.length === 0) return []; | ||||
|  | ||||
| 		const meta = await this.metaService.fetch(); | ||||
|  | ||||
| 		const bufferdReactions = meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany(notes.map(x => x.id)) : null; | ||||
|  | ||||
| 		const meId = me ? me.id : null; | ||||
| 		const myReactionsMap = new Map<MiNote['id'], string | null>(); | ||||
| 		if (meId) { | ||||
| @@ -410,23 +455,33 @@ export class NoteEntityService implements OnModuleInit { | ||||
|  | ||||
| 			for (const note of notes) { | ||||
| 				if (note.renote && (note.text == null && note.fileIds.length === 0)) { // pure renote | ||||
| 					const reactionsCount = Object.values(note.renote.reactions).reduce((a, b) => a + b, 0); | ||||
| 					const reactionsCount = Object.values(mergeReactions(note.renote.reactions, bufferdReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); | ||||
| 					if (reactionsCount === 0) { | ||||
| 						myReactionsMap.set(note.renote.id, null); | ||||
| 					} else if (reactionsCount <= note.renote.reactionAndUserPairCache.length) { | ||||
| 						const pair = note.renote.reactionAndUserPairCache.find(p => p.startsWith(meId)); | ||||
| 						myReactionsMap.set(note.renote.id, pair ? pair.split('/')[1] : null); | ||||
| 					} else if (reactionsCount <= note.renote.reactionAndUserPairCache.length + (bufferdReactions?.get(note.renote.id)?.pairs.length ?? 0)) { | ||||
| 						const pairInBuffer = bufferdReactions?.get(note.renote.id)?.pairs.find(p => p[0] === meId); | ||||
| 						if (pairInBuffer) { | ||||
| 							myReactionsMap.set(note.renote.id, pairInBuffer[1]); | ||||
| 						} else { | ||||
| 							const pair = note.renote.reactionAndUserPairCache.find(p => p.startsWith(meId)); | ||||
| 							myReactionsMap.set(note.renote.id, pair ? pair.split('/')[1] : null); | ||||
| 						} | ||||
| 					} else { | ||||
| 						idsNeedFetchMyReaction.add(note.renote.id); | ||||
| 					} | ||||
| 				} else { | ||||
| 					if (note.id < oldId) { | ||||
| 						const reactionsCount = Object.values(note.reactions).reduce((a, b) => a + b, 0); | ||||
| 						const reactionsCount = Object.values(mergeReactions(note.reactions, bufferdReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); | ||||
| 						if (reactionsCount === 0) { | ||||
| 							myReactionsMap.set(note.id, null); | ||||
| 						} else if (reactionsCount <= note.reactionAndUserPairCache.length) { | ||||
| 							const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId)); | ||||
| 							myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null); | ||||
| 						} else if (reactionsCount <= note.reactionAndUserPairCache.length + (bufferdReactions?.get(note.id)?.pairs.length ?? 0)) { | ||||
| 							const pairInBuffer = bufferdReactions?.get(note.id)?.pairs.find(p => p[0] === meId); | ||||
| 							if (pairInBuffer) { | ||||
| 								myReactionsMap.set(note.id, pairInBuffer[1]); | ||||
| 							} else { | ||||
| 								const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId)); | ||||
| 								myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null); | ||||
| 							} | ||||
| 						} else { | ||||
| 							idsNeedFetchMyReaction.add(note.id); | ||||
| 						} | ||||
| @@ -461,6 +516,7 @@ export class NoteEntityService implements OnModuleInit { | ||||
| 		return await Promise.all(notes.map(n => this.pack(n, me, { | ||||
| 			...options, | ||||
| 			_hint_: { | ||||
| 				bufferdReactions, | ||||
| 				myReactions: myReactionsMap, | ||||
| 				packedFiles, | ||||
| 				packedUsers, | ||||
|   | ||||
| @@ -10,8 +10,9 @@ | ||||
|  * The getter will return a .bind version of the function | ||||
|  * and memoize the result against a symbol on the instance | ||||
|  */ | ||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||
| export function bindThis(target: any, key: string, descriptor: any) { | ||||
| 	let fn = descriptor.value; | ||||
| 	const fn = descriptor.value; | ||||
|  | ||||
| 	if (typeof fn !== 'function') { | ||||
| 		throw new TypeError(`@bindThis decorator can only be applied to methods not: ${typeof fn}`); | ||||
| @@ -21,26 +22,18 @@ export function bindThis(target: any, key: string, descriptor: any) { | ||||
| 		configurable: true, | ||||
| 		get() { | ||||
| 			// eslint-disable-next-line no-prototype-builtins | ||||
| 			if (this === target.prototype || this.hasOwnProperty(key) || | ||||
|         typeof fn !== 'function') { | ||||
| 			if (this === target.prototype || this.hasOwnProperty(key)) { | ||||
| 				return fn; | ||||
| 			} | ||||
|  | ||||
| 			const boundFn = fn.bind(this); | ||||
| 			Object.defineProperty(this, key, { | ||||
| 			Reflect.defineProperty(this, key, { | ||||
| 				value: boundFn, | ||||
| 				configurable: true, | ||||
| 				get() { | ||||
| 					return boundFn; | ||||
| 				}, | ||||
| 				set(value) { | ||||
| 					fn = value; | ||||
| 					delete this[key]; | ||||
| 				}, | ||||
| 				writable: true, | ||||
| 			}); | ||||
|  | ||||
| 			return boundFn; | ||||
| 		}, | ||||
| 		set(value: any) { | ||||
| 			fn = value; | ||||
| 		}, | ||||
| 	}; | ||||
| } | ||||
|   | ||||
| @@ -11,6 +11,7 @@ export const DI = { | ||||
| 	redisForPub: Symbol('redisForPub'), | ||||
| 	redisForSub: Symbol('redisForSub'), | ||||
| 	redisForTimelines: Symbol('redisForTimelines'), | ||||
| 	redisForReactions: Symbol('redisForReactions'), | ||||
|  | ||||
| 	//#region Repositories | ||||
| 	usersRepository: Symbol('usersRepository'), | ||||
|   | ||||
| @@ -7,23 +7,23 @@ import * as Redis from 'ioredis'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
|  | ||||
| export class RedisKVCache<T> { | ||||
| 	private redisClient: Redis.Redis; | ||||
| 	private name: string; | ||||
| 	private lifetime: number; | ||||
| 	private memoryCache: MemoryKVCache<T>; | ||||
| 	private fetcher: (key: string) => Promise<T>; | ||||
| 	private toRedisConverter: (value: T) => string; | ||||
| 	private fromRedisConverter: (value: string) => T | undefined; | ||||
| 	private readonly lifetime: number; | ||||
| 	private readonly memoryCache: MemoryKVCache<T>; | ||||
| 	private readonly fetcher: (key: string) => Promise<T>; | ||||
| 	private readonly toRedisConverter: (value: T) => string; | ||||
| 	private readonly fromRedisConverter: (value: string) => T | undefined; | ||||
|  | ||||
| 	constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: { | ||||
| 		lifetime: RedisKVCache<T>['lifetime']; | ||||
| 		memoryCacheLifetime: number; | ||||
| 		fetcher: RedisKVCache<T>['fetcher']; | ||||
| 		toRedisConverter: RedisKVCache<T>['toRedisConverter']; | ||||
| 		fromRedisConverter: RedisKVCache<T>['fromRedisConverter']; | ||||
| 	}) { | ||||
| 		this.redisClient = redisClient; | ||||
| 		this.name = name; | ||||
| 	constructor( | ||||
| 		private redisClient: Redis.Redis, | ||||
| 		private name: string, | ||||
| 		opts: { | ||||
| 			lifetime: RedisKVCache<T>['lifetime']; | ||||
| 			memoryCacheLifetime: number; | ||||
| 			fetcher: RedisKVCache<T>['fetcher']; | ||||
| 			toRedisConverter: RedisKVCache<T>['toRedisConverter']; | ||||
| 			fromRedisConverter: RedisKVCache<T>['fromRedisConverter']; | ||||
| 		}, | ||||
| 	) { | ||||
| 		this.lifetime = opts.lifetime; | ||||
| 		this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime); | ||||
| 		this.fetcher = opts.fetcher; | ||||
| @@ -55,7 +55,13 @@ export class RedisKVCache<T> { | ||||
|  | ||||
| 		const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`); | ||||
| 		if (cached == null) return undefined; | ||||
| 		return this.fromRedisConverter(cached); | ||||
|  | ||||
| 		const value = this.fromRedisConverter(cached); | ||||
| 		if (value !== undefined) { | ||||
| 			this.memoryCache.set(key, value); | ||||
| 		} | ||||
|  | ||||
| 		return value; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| @@ -66,6 +72,10 @@ export class RedisKVCache<T> { | ||||
|  | ||||
| 	/** | ||||
| 	 * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します | ||||
| 	 * This awaits the call to Redis to ensure that the write succeeded, which is important for a few reasons: | ||||
| 	 *   * Other code uses this to synchronize changes between worker processes. A failed write can internally de-sync the cluster. | ||||
| 	 *   * Without an `await`, consecutive calls could race. An unlucky race could result in the older write overwriting the newer value. | ||||
| 	 *   * Not awaiting here makes the entire cache non-consistent. The prevents many possible uses. | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async fetch(key: string): Promise<T> { | ||||
| @@ -77,14 +87,14 @@ export class RedisKVCache<T> { | ||||
|  | ||||
| 		// Cache MISS | ||||
| 		const value = await this.fetcher(key); | ||||
| 		this.set(key, value); | ||||
| 		await this.set(key, value); | ||||
| 		return value; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async refresh(key: string) { | ||||
| 		const value = await this.fetcher(key); | ||||
| 		this.set(key, value); | ||||
| 		await this.set(key, value); | ||||
|  | ||||
| 		// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする | ||||
| 	} | ||||
| @@ -101,23 +111,23 @@ export class RedisKVCache<T> { | ||||
| } | ||||
|  | ||||
| export class RedisSingleCache<T> { | ||||
| 	private redisClient: Redis.Redis; | ||||
| 	private name: string; | ||||
| 	private lifetime: number; | ||||
| 	private memoryCache: MemorySingleCache<T>; | ||||
| 	private fetcher: () => Promise<T>; | ||||
| 	private toRedisConverter: (value: T) => string; | ||||
| 	private fromRedisConverter: (value: string) => T | undefined; | ||||
| 	private readonly lifetime: number; | ||||
| 	private readonly memoryCache: MemorySingleCache<T>; | ||||
| 	private readonly fetcher: () => Promise<T>; | ||||
| 	private readonly toRedisConverter: (value: T) => string; | ||||
| 	private readonly fromRedisConverter: (value: string) => T | undefined; | ||||
|  | ||||
| 	constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: { | ||||
| 		lifetime: RedisSingleCache<T>['lifetime']; | ||||
| 		memoryCacheLifetime: number; | ||||
| 		fetcher: RedisSingleCache<T>['fetcher']; | ||||
| 		toRedisConverter: RedisSingleCache<T>['toRedisConverter']; | ||||
| 		fromRedisConverter: RedisSingleCache<T>['fromRedisConverter']; | ||||
| 	}) { | ||||
| 		this.redisClient = redisClient; | ||||
| 		this.name = name; | ||||
| 	constructor( | ||||
| 		private redisClient: Redis.Redis, | ||||
| 		private name: string, | ||||
| 		opts: { | ||||
| 			lifetime: number; | ||||
| 			memoryCacheLifetime: number; | ||||
| 			fetcher: RedisSingleCache<T>['fetcher']; | ||||
| 			toRedisConverter: RedisSingleCache<T>['toRedisConverter']; | ||||
| 			fromRedisConverter: RedisSingleCache<T>['fromRedisConverter']; | ||||
| 		}, | ||||
| 	) { | ||||
| 		this.lifetime = opts.lifetime; | ||||
| 		this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime); | ||||
| 		this.fetcher = opts.fetcher; | ||||
| @@ -149,7 +159,13 @@ export class RedisSingleCache<T> { | ||||
|  | ||||
| 		const cached = await this.redisClient.get(`singlecache:${this.name}`); | ||||
| 		if (cached == null) return undefined; | ||||
| 		return this.fromRedisConverter(cached); | ||||
|  | ||||
| 		const value = this.fromRedisConverter(cached); | ||||
| 		if (value !== undefined) { | ||||
| 			this.memoryCache.set(value); | ||||
| 		} | ||||
|  | ||||
| 		return value; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| @@ -160,6 +176,10 @@ export class RedisSingleCache<T> { | ||||
|  | ||||
| 	/** | ||||
| 	 * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します | ||||
| 	 * This awaits the call to Redis to ensure that the write succeeded, which is important for a few reasons: | ||||
| 	 *   * Other code uses this to synchronize changes between worker processes. A failed write can internally de-sync the cluster. | ||||
| 	 *   * Without an `await`, consecutive calls could race. An unlucky race could result in the older write overwriting the newer value. | ||||
| 	 *   * Not awaiting here makes the entire cache non-consistent. The prevents many possible uses. | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async fetch(): Promise<T> { | ||||
| @@ -171,14 +191,14 @@ export class RedisSingleCache<T> { | ||||
|  | ||||
| 		// Cache MISS | ||||
| 		const value = await this.fetcher(); | ||||
| 		this.set(value); | ||||
| 		await this.set(value); | ||||
| 		return value; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async refresh() { | ||||
| 		const value = await this.fetcher(); | ||||
| 		this.set(value); | ||||
| 		await this.set(value); | ||||
|  | ||||
| 		// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする | ||||
| 	} | ||||
| @@ -187,22 +207,12 @@ export class RedisSingleCache<T> { | ||||
| // TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? | ||||
|  | ||||
| export class MemoryKVCache<T> { | ||||
| 	/** | ||||
| 	 * データを持つマップ | ||||
| 	 * @deprecated これを直接操作するべきではない | ||||
| 	 */ | ||||
| 	public cache: Map<string, { date: number; value: T; }>; | ||||
| 	private lifetime: number; | ||||
| 	private gcIntervalHandle: NodeJS.Timeout; | ||||
| 	private readonly cache = new Map<string, { date: number; value: T; }>(); | ||||
| 	private readonly gcIntervalHandle = setInterval(() => this.gc(), 1000 * 60 * 3); // 3m | ||||
|  | ||||
| 	constructor(lifetime: MemoryKVCache<never>['lifetime']) { | ||||
| 		this.cache = new Map(); | ||||
| 		this.lifetime = lifetime; | ||||
|  | ||||
| 		this.gcIntervalHandle = setInterval(() => { | ||||
| 			this.gc(); | ||||
| 		}, 1000 * 60 * 3); | ||||
| 	} | ||||
| 	constructor( | ||||
| 		private readonly lifetime: number, | ||||
| 	) {} | ||||
|  | ||||
| 	@bindThis | ||||
| 	/** | ||||
| @@ -287,10 +297,14 @@ export class MemoryKVCache<T> { | ||||
| 	@bindThis | ||||
| 	public gc(): void { | ||||
| 		const now = Date.now(); | ||||
|  | ||||
| 		for (const [key, { date }] of this.cache.entries()) { | ||||
| 			if ((now - date) > this.lifetime) { | ||||
| 				this.cache.delete(key); | ||||
| 			} | ||||
| 			// The map is ordered from oldest to youngest. | ||||
| 			// We can stop once we find an entry that's still active, because all following entries must *also* be active. | ||||
| 			const age = now - date; | ||||
| 			if (age < this.lifetime) break; | ||||
|  | ||||
| 			this.cache.delete(key); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| @@ -298,16 +312,19 @@ export class MemoryKVCache<T> { | ||||
| 	public dispose(): void { | ||||
| 		clearInterval(this.gcIntervalHandle); | ||||
| 	} | ||||
|  | ||||
| 	public get entries() { | ||||
| 		return this.cache.entries(); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export class MemorySingleCache<T> { | ||||
| 	private cachedAt: number | null = null; | ||||
| 	private value: T | undefined; | ||||
| 	private lifetime: number; | ||||
|  | ||||
| 	constructor(lifetime: MemorySingleCache<never>['lifetime']) { | ||||
| 		this.lifetime = lifetime; | ||||
| 	} | ||||
| 	constructor( | ||||
| 		private lifetime: number, | ||||
| 	) {} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public set(value: T): void { | ||||
|   | ||||
| @@ -144,7 +144,9 @@ export interface Schema extends OfSchema { | ||||
| 	readonly type?: TypeStringef; | ||||
| 	readonly nullable?: boolean; | ||||
| 	readonly optional?: boolean; | ||||
| 	readonly prefixItems?: ReadonlyArray<Schema>; | ||||
| 	readonly items?: Schema; | ||||
| 	readonly unevaluatedItems?: Schema | boolean; | ||||
| 	readonly properties?: Obj; | ||||
| 	readonly required?: ReadonlyArray<Extract<keyof NonNullable<this['properties']>, string>>; | ||||
| 	readonly description?: string; | ||||
| @@ -198,6 +200,7 @@ type UnionSchemaType<a extends readonly any[], X extends Schema = a[number]> = X | ||||
| //type UnionObjectSchemaType<a extends readonly any[], X extends Schema = a[number]> = X extends any ? ObjectSchemaType<X> : never; | ||||
| type UnionObjType<s extends Obj, a extends readonly any[], X extends ReadonlyArray<keyof s> = a[number]> = X extends any ? ObjType<s, X> : never; | ||||
| type ArrayUnion<T> = T extends any ? Array<T> : never; | ||||
| type ArrayToTuple<X extends ReadonlyArray<Schema>> = { [K in keyof X]: SchemaType<X[K]> }; | ||||
|  | ||||
| type ObjectSchemaTypeDef<p extends Schema> = | ||||
| 	p['ref'] extends keyof typeof refs ? Packed<p['ref']> : | ||||
| @@ -232,6 +235,12 @@ export type SchemaTypeDef<p extends Schema> = | ||||
| 			p['items']['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<NonNullable<p['items']['allOf']>>>[] : | ||||
| 			never | ||||
| 		) : | ||||
| 		p['prefixItems'] extends ReadonlyArray<Schema> ? ( | ||||
| 			p['items'] extends NonNullable<Schema> ? [...ArrayToTuple<p['prefixItems']>, ...SchemaType<p['items']>[]] : | ||||
| 			p['items'] extends false ? ArrayToTuple<p['prefixItems']> : | ||||
| 			p['unevaluatedItems'] extends false ? ArrayToTuple<p['prefixItems']> : | ||||
| 			[...ArrayToTuple<p['prefixItems']>, ...unknown[]] | ||||
| 		) : | ||||
| 		p['items'] extends NonNullable<Schema> ? SchemaType<p['items']>[] : | ||||
| 		any[] | ||||
| 	) : | ||||
|   | ||||
| @@ -589,6 +589,11 @@ export class MiMeta { | ||||
| 	}) | ||||
| 	public perUserListTimelineCacheMax: number; | ||||
|  | ||||
| 	@Column('boolean', { | ||||
| 		default: false, | ||||
| 	}) | ||||
| 	public enableReactionsBuffering: boolean; | ||||
|  | ||||
| 	@Column('integer', { | ||||
| 		default: 0, | ||||
| 	}) | ||||
|   | ||||
| @@ -85,7 +85,7 @@ export type MiNotification = { | ||||
| 	/** | ||||
| 	 * アプリ通知のbody | ||||
| 	 */ | ||||
| 	customBody: string | null; | ||||
| 	customBody: string; | ||||
|  | ||||
| 	/** | ||||
| 	 * アプリ通知のheader | ||||
|   | ||||
| @@ -8,6 +8,7 @@ import { id } from './util/id.js'; | ||||
| import { MiUser } from './User.js'; | ||||
|  | ||||
| export const webhookEventTypes = ['mention', 'unfollow', 'follow', 'followed', 'note', 'reply', 'renote', 'reaction'] as const; | ||||
| export type WebhookEventTypes = typeof webhookEventTypes[number]; | ||||
|  | ||||
| @Entity('webhook') | ||||
| export class MiWebhook { | ||||
|   | ||||
| @@ -253,6 +253,10 @@ export const packedMetaLiteSchema = { | ||||
| 			optional: false, nullable: false, | ||||
| 			default: 'local', | ||||
| 		}, | ||||
| 		maxFileSize: { | ||||
| 			type: 'number', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
|  | ||||
|   | ||||
| @@ -3,6 +3,7 @@ | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { ACHIEVEMENT_TYPES } from '@/core/AchievementService.js'; | ||||
| import { notificationTypes } from '@/types.js'; | ||||
|  | ||||
| const baseSchema = { | ||||
| @@ -294,6 +295,7 @@ export const packedNotificationSchema = { | ||||
| 			achievement: { | ||||
| 				type: 'string', | ||||
| 				optional: false, nullable: false, | ||||
| 				enum: ACHIEVEMENT_TYPES, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}, { | ||||
| @@ -311,11 +313,11 @@ export const packedNotificationSchema = { | ||||
| 			}, | ||||
| 			header: { | ||||
| 				type: 'string', | ||||
| 				optional: false, nullable: false, | ||||
| 				optional: false, nullable: true, | ||||
| 			}, | ||||
| 			icon: { | ||||
| 				type: 'string', | ||||
| 				optional: false, nullable: false, | ||||
| 				optional: false, nullable: true, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}, { | ||||
|   | ||||
| @@ -272,6 +272,26 @@ export const packedRolePoliciesSchema = { | ||||
| 			type: 'integer', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 		canImportAntennas: { | ||||
| 			type: 'boolean', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 		canImportBlocking: { | ||||
| 			type: 'boolean', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 		canImportFollowing: { | ||||
| 			type: 'boolean', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 		canImportMuting: { | ||||
| 			type: 'boolean', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 		canImportUserLists: { | ||||
| 			type: 'boolean', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
|  | ||||
|   | ||||
| @@ -14,6 +14,7 @@ import { InboxProcessorService } from './processors/InboxProcessorService.js'; | ||||
| import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; | ||||
| import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; | ||||
| import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; | ||||
| import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js'; | ||||
| import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; | ||||
| import { CleanProcessorService } from './processors/CleanProcessorService.js'; | ||||
| import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js'; | ||||
| @@ -51,6 +52,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor | ||||
| 		ResyncChartsProcessorService, | ||||
| 		CleanChartsProcessorService, | ||||
| 		CheckExpiredMutingsProcessorService, | ||||
| 		BakeBufferedReactionsProcessorService, | ||||
| 		CleanProcessorService, | ||||
| 		DeleteDriveFilesProcessorService, | ||||
| 		ExportCustomEmojisProcessorService, | ||||
|   | ||||
| @@ -39,6 +39,7 @@ import { TickChartsProcessorService } from './processors/TickChartsProcessorServ | ||||
| import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; | ||||
| import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; | ||||
| import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; | ||||
| import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js'; | ||||
| import { CleanProcessorService } from './processors/CleanProcessorService.js'; | ||||
| import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; | ||||
| import { QueueLoggerService } from './QueueLoggerService.js'; | ||||
| @@ -118,6 +119,7 @@ export class QueueProcessorService implements OnApplicationShutdown { | ||||
| 		private cleanChartsProcessorService: CleanChartsProcessorService, | ||||
| 		private aggregateRetentionProcessorService: AggregateRetentionProcessorService, | ||||
| 		private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, | ||||
| 		private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService, | ||||
| 		private cleanProcessorService: CleanProcessorService, | ||||
| 	) { | ||||
| 		this.logger = this.queueLoggerService.logger; | ||||
| @@ -147,6 +149,7 @@ export class QueueProcessorService implements OnApplicationShutdown { | ||||
| 					case 'cleanCharts': return this.cleanChartsProcessorService.process(); | ||||
| 					case 'aggregateRetention': return this.aggregateRetentionProcessorService.process(); | ||||
| 					case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process(); | ||||
| 					case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process(); | ||||
| 					case 'clean': return this.cleanProcessorService.process(); | ||||
| 					default: throw new Error(`unrecognized job type ${job.name} for system`); | ||||
| 				} | ||||
|   | ||||
| @@ -0,0 +1,40 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import type Logger from '@/logger.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import { QueueLoggerService } from '../QueueLoggerService.js'; | ||||
| import type * as Bull from 'bullmq'; | ||||
|  | ||||
| @Injectable() | ||||
| export class BakeBufferedReactionsProcessorService { | ||||
| 	private logger: Logger; | ||||
|  | ||||
| 	constructor( | ||||
| 		private reactionsBufferingService: ReactionsBufferingService, | ||||
| 		private metaService: MetaService, | ||||
| 		private queueLoggerService: QueueLoggerService, | ||||
| 	) { | ||||
| 		this.logger = this.queueLoggerService.logger.createSubLogger('bake-buffered-reactions'); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async process(): Promise<void> { | ||||
| 		const meta = await this.metaService.fetch(); | ||||
| 		if (!meta.enableReactionsBuffering) { | ||||
| 			this.logger.info('Reactions buffering is disabled. Skipping...'); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		this.logger.info('Baking buffered reactions...'); | ||||
|  | ||||
| 		await this.reactionsBufferingService.bake(); | ||||
|  | ||||
| 		this.logger.succ('All buffered reactions baked.'); | ||||
| 	} | ||||
| } | ||||
| @@ -45,7 +45,7 @@ export class DeliverProcessorService { | ||||
| 		private queueLoggerService: QueueLoggerService, | ||||
| 	) { | ||||
| 		this.logger = this.queueLoggerService.logger.createSubLogger('deliver'); | ||||
| 		this.suspendedHostsCache = new MemorySingleCache<MiInstance[]>(1000 * 60 * 60); | ||||
| 		this.suspendedHostsCache = new MemorySingleCache<MiInstance[]>(1000 * 60 * 60); // 1h | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
|   | ||||
| @@ -27,6 +27,9 @@ export class HealthServerService { | ||||
| 		@Inject(DI.redisForTimelines) | ||||
| 		private redisForTimelines: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.redisForReactions) | ||||
| 		private redisForReactions: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.db) | ||||
| 		private db: DataSource, | ||||
|  | ||||
| @@ -43,6 +46,7 @@ export class HealthServerService { | ||||
| 				this.redisForPub.ping(), | ||||
| 				this.redisForSub.ping(), | ||||
| 				this.redisForTimelines.ping(), | ||||
| 				this.redisForReactions.ping(), | ||||
| 				this.db.query('SELECT 1'), | ||||
| 				...(this.meilisearch ? [this.meilisearch.health()] : []), | ||||
| 			]).then(() => 200, () => 503)); | ||||
|   | ||||
| @@ -134,7 +134,7 @@ export class NodeinfoServerService { | ||||
| 			return document; | ||||
| 		}; | ||||
|  | ||||
| 		const cache = new MemorySingleCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10); | ||||
| 		const cache = new MemorySingleCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10); // 10m | ||||
|  | ||||
| 		fastify.get(nodeinfo2_1path, async (request, reply) => { | ||||
| 			const base = await cache.fetch(() => nodeinfo2(21)); | ||||
|   | ||||
| @@ -64,15 +64,6 @@ export class ApiCallService implements OnApplicationShutdown { | ||||
| 		let statusCode = err.httpStatusCode; | ||||
| 		if (err.httpStatusCode === 401) { | ||||
| 			reply.header('WWW-Authenticate', 'Bearer realm="Misskey"'); | ||||
| 		} else if (err.kind === 'client') { | ||||
| 			reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_request", error_description="${err.message}"`); | ||||
| 			statusCode = statusCode ?? 400; | ||||
| 		} else if (err.kind === 'permission') { | ||||
| 			// (ROLE_PERMISSION_DENIEDは関係ない) | ||||
| 			if (err.code === 'PERMISSION_DENIED') { | ||||
| 				reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`); | ||||
| 			} | ||||
| 			statusCode = statusCode ?? 403; | ||||
| 		} else if (err.code === 'RATE_LIMIT_EXCEEDED') { | ||||
| 			const info: unknown = err.info; | ||||
| 			const unixEpochInSeconds = Date.now(); | ||||
| @@ -83,6 +74,15 @@ export class ApiCallService implements OnApplicationShutdown { | ||||
| 			} else { | ||||
| 				this.logger.warn(`rate limit information has unexpected type ${typeof(err.info?.reset)}`); | ||||
| 			} | ||||
| 		} else if (err.kind === 'client') { | ||||
| 			reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_request", error_description="${err.message}"`); | ||||
| 			statusCode = statusCode ?? 400; | ||||
| 		} else if (err.kind === 'permission') { | ||||
| 			// (ROLE_PERMISSION_DENIEDは関係ない) | ||||
| 			if (err.code === 'PERMISSION_DENIED') { | ||||
| 				reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`); | ||||
| 			} | ||||
| 			statusCode = statusCode ?? 403; | ||||
| 		} else if (!statusCode) { | ||||
| 			statusCode = 500; | ||||
| 		} | ||||
| @@ -199,9 +199,18 @@ export class ApiCallService implements OnApplicationShutdown { | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		const [path] = await createTemp(); | ||||
| 		const [path, cleanup] = await createTemp(); | ||||
| 		await stream.pipeline(multipartData.file, fs.createWriteStream(path)); | ||||
|  | ||||
| 		// ファイルサイズが制限を超えていた場合 | ||||
| 		// なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある | ||||
| 		if (multipartData.file.truncated) { | ||||
| 			cleanup(); | ||||
| 			reply.code(413); | ||||
| 			reply.send(); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		const fields = {} as Record<string, unknown>; | ||||
| 		for (const [k, v] of Object.entries(multipartData.fields)) { | ||||
| 			fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined; | ||||
|   | ||||
| @@ -49,7 +49,7 @@ export class ApiServerService { | ||||
|  | ||||
| 		fastify.register(multipart, { | ||||
| 			limits: { | ||||
| 				fileSize: this.config.maxFileSize ?? 262144000, | ||||
| 				fileSize: this.config.maxFileSize, | ||||
| 				files: 1, | ||||
| 			}, | ||||
| 		}); | ||||
|   | ||||
| @@ -37,7 +37,7 @@ export class AuthenticateService implements OnApplicationShutdown { | ||||
|  | ||||
| 		private cacheService: CacheService, | ||||
| 	) { | ||||
| 		this.appCache = new MemoryKVCache<MiApp>(Infinity); | ||||
| 		this.appCache = new MemoryKVCache<MiApp>(1000 * 60 * 60 * 24 * 7); // 1w | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
|   | ||||
| @@ -92,6 +92,7 @@ import * as ep___admin_systemWebhook_delete from './endpoints/admin/system-webho | ||||
| import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js'; | ||||
| import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js'; | ||||
| import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js'; | ||||
| import * as ep___admin_systemWebhook_test from './endpoints/admin/system-webhook/test.js'; | ||||
| import * as ep___announcements from './endpoints/announcements.js'; | ||||
| import * as ep___announcements_show from './endpoints/announcements/show.js'; | ||||
| import * as ep___antennas_create from './endpoints/antennas/create.js'; | ||||
| @@ -258,6 +259,7 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; | ||||
| import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; | ||||
| import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; | ||||
| import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; | ||||
| import * as ep___i_webhooks_test from './endpoints/i/webhooks/test.js'; | ||||
| import * as ep___invite_create from './endpoints/invite/create.js'; | ||||
| import * as ep___invite_delete from './endpoints/invite/delete.js'; | ||||
| import * as ep___invite_list from './endpoints/invite/list.js'; | ||||
| @@ -475,6 +477,7 @@ const $admin_systemWebhook_delete: Provider = { provide: 'ep:admin/system-webhoo | ||||
| const $admin_systemWebhook_list: Provider = { provide: 'ep:admin/system-webhook/list', useClass: ep___admin_systemWebhook_list.default }; | ||||
| const $admin_systemWebhook_show: Provider = { provide: 'ep:admin/system-webhook/show', useClass: ep___admin_systemWebhook_show.default }; | ||||
| const $admin_systemWebhook_update: Provider = { provide: 'ep:admin/system-webhook/update', useClass: ep___admin_systemWebhook_update.default }; | ||||
| const $admin_systemWebhook_test: Provider = { provide: 'ep:admin/system-webhook/test', useClass: ep___admin_systemWebhook_test.default }; | ||||
| const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default }; | ||||
| const $announcements_show: Provider = { provide: 'ep:announcements/show', useClass: ep___announcements_show.default }; | ||||
| const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default }; | ||||
| @@ -641,6 +644,7 @@ const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep | ||||
| const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default }; | ||||
| const $i_webhooks_update: Provider = { provide: 'ep:i/webhooks/update', useClass: ep___i_webhooks_update.default }; | ||||
| const $i_webhooks_delete: Provider = { provide: 'ep:i/webhooks/delete', useClass: ep___i_webhooks_delete.default }; | ||||
| const $i_webhooks_test: Provider = { provide: 'ep:i/webhooks/test', useClass: ep___i_webhooks_test.default }; | ||||
| const $invite_create: Provider = { provide: 'ep:invite/create', useClass: ep___invite_create.default }; | ||||
| const $invite_delete: Provider = { provide: 'ep:invite/delete', useClass: ep___invite_delete.default }; | ||||
| const $invite_list: Provider = { provide: 'ep:invite/list', useClass: ep___invite_list.default }; | ||||
| @@ -862,6 +866,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ | ||||
| 		$admin_systemWebhook_list, | ||||
| 		$admin_systemWebhook_show, | ||||
| 		$admin_systemWebhook_update, | ||||
| 		$admin_systemWebhook_test, | ||||
| 		$announcements, | ||||
| 		$announcements_show, | ||||
| 		$antennas_create, | ||||
| @@ -1028,6 +1033,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ | ||||
| 		$i_webhooks_show, | ||||
| 		$i_webhooks_update, | ||||
| 		$i_webhooks_delete, | ||||
| 		$i_webhooks_test, | ||||
| 		$invite_create, | ||||
| 		$invite_delete, | ||||
| 		$invite_list, | ||||
| @@ -1243,6 +1249,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ | ||||
| 		$admin_systemWebhook_list, | ||||
| 		$admin_systemWebhook_show, | ||||
| 		$admin_systemWebhook_update, | ||||
| 		$admin_systemWebhook_test, | ||||
| 		$announcements, | ||||
| 		$announcements_show, | ||||
| 		$antennas_create, | ||||
| @@ -1409,6 +1416,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ | ||||
| 		$i_webhooks_show, | ||||
| 		$i_webhooks_update, | ||||
| 		$i_webhooks_delete, | ||||
| 		$i_webhooks_test, | ||||
| 		$invite_create, | ||||
| 		$invite_delete, | ||||
| 		$invite_list, | ||||
|   | ||||
| @@ -98,6 +98,7 @@ import * as ep___admin_systemWebhook_delete from './endpoints/admin/system-webho | ||||
| import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js'; | ||||
| import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js'; | ||||
| import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js'; | ||||
| import * as ep___admin_systemWebhook_test from './endpoints/admin/system-webhook/test.js'; | ||||
| import * as ep___announcements from './endpoints/announcements.js'; | ||||
| import * as ep___announcements_show from './endpoints/announcements/show.js'; | ||||
| import * as ep___antennas_create from './endpoints/antennas/create.js'; | ||||
| @@ -264,6 +265,7 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; | ||||
| import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; | ||||
| import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; | ||||
| import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; | ||||
| import * as ep___i_webhooks_test from './endpoints/i/webhooks/test.js'; | ||||
| import * as ep___invite_create from './endpoints/invite/create.js'; | ||||
| import * as ep___invite_delete from './endpoints/invite/delete.js'; | ||||
| import * as ep___invite_list from './endpoints/invite/list.js'; | ||||
| @@ -479,6 +481,7 @@ const eps = [ | ||||
| 	['admin/system-webhook/list', ep___admin_systemWebhook_list], | ||||
| 	['admin/system-webhook/show', ep___admin_systemWebhook_show], | ||||
| 	['admin/system-webhook/update', ep___admin_systemWebhook_update], | ||||
| 	['admin/system-webhook/test', ep___admin_systemWebhook_test], | ||||
| 	['announcements', ep___announcements], | ||||
| 	['announcements/show', ep___announcements_show], | ||||
| 	['antennas/create', ep___antennas_create], | ||||
| @@ -645,6 +648,7 @@ const eps = [ | ||||
| 	['i/webhooks/show', ep___i_webhooks_show], | ||||
| 	['i/webhooks/update', ep___i_webhooks_update], | ||||
| 	['i/webhooks/delete', ep___i_webhooks_delete], | ||||
| 	['i/webhooks/test', ep___i_webhooks_test], | ||||
| 	['invite/create', ep___invite_create], | ||||
| 	['invite/delete', ep___invite_delete], | ||||
| 	['invite/list', ep___invite_list], | ||||
|   | ||||
| @@ -377,6 +377,10 @@ export const meta = { | ||||
| 				type: 'number', | ||||
| 				optional: false, nullable: false, | ||||
| 			}, | ||||
| 			enableReactionsBuffering: { | ||||
| 				type: 'boolean', | ||||
| 				optional: false, nullable: false, | ||||
| 			}, | ||||
| 			notesPerOneAd: { | ||||
| 				type: 'number', | ||||
| 				optional: false, nullable: false, | ||||
| @@ -617,6 +621,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | ||||
| 				perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax, | ||||
| 				perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax, | ||||
| 				perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax, | ||||
| 				enableReactionsBuffering: instance.enableReactionsBuffering, | ||||
| 				notesPerOneAd: instance.notesPerOneAd, | ||||
| 				summalyProxy: instance.urlPreviewSummaryProxyUrl, | ||||
| 				urlPreviewEnabled: instance.urlPreviewEnabled, | ||||
|   | ||||
| @@ -21,16 +21,15 @@ export const meta = { | ||||
| 		items: { | ||||
| 			type: 'array', | ||||
| 			optional: false, nullable: false, | ||||
| 			items: { | ||||
| 				anyOf: [ | ||||
| 					{ | ||||
| 						type: 'string', | ||||
| 					}, | ||||
| 					{ | ||||
| 						type: 'number', | ||||
| 					}, | ||||
| 				], | ||||
| 			}, | ||||
| 			prefixItems: [ | ||||
| 				{ | ||||
| 					type: 'string', | ||||
| 				}, | ||||
| 				{ | ||||
| 					type: 'number', | ||||
| 				}, | ||||
| 			], | ||||
| 			unevaluatedItems: false, | ||||
| 		}, | ||||
| 		example: [[ | ||||
| 			'example.com', | ||||
|   | ||||
| @@ -21,16 +21,15 @@ export const meta = { | ||||
| 		items: { | ||||
| 			type: 'array', | ||||
| 			optional: false, nullable: false, | ||||
| 			items: { | ||||
| 				anyOf: [ | ||||
| 					{ | ||||
| 						type: 'string', | ||||
| 					}, | ||||
| 					{ | ||||
| 						type: 'number', | ||||
| 					}, | ||||
| 				], | ||||
| 			}, | ||||
| 			prefixItems: [ | ||||
| 				{ | ||||
| 					type: 'string', | ||||
| 				}, | ||||
| 				{ | ||||
| 					type: 'number', | ||||
| 				}, | ||||
| 			], | ||||
| 			unevaluatedItems: false, | ||||
| 		}, | ||||
| 		example: [[ | ||||
| 			'example.com', | ||||
|   | ||||
| @@ -0,0 +1,77 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import ms from 'ms'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { WebhookTestService } from '@/core/WebhookTestService.js'; | ||||
| import { ApiError } from '@/server/api/error.js'; | ||||
| import { systemWebhookEventTypes } from '@/models/SystemWebhook.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['webhooks'], | ||||
|  | ||||
| 	requireCredential: true, | ||||
| 	requireModerator: true, | ||||
| 	secure: true, | ||||
| 	kind: 'read:admin:system-webhook', | ||||
|  | ||||
| 	limit: { | ||||
| 		duration: ms('15min'), | ||||
| 		max: 60, | ||||
| 	}, | ||||
|  | ||||
| 	errors: { | ||||
| 		noSuchWebhook: { | ||||
| 			message: 'No such webhook.', | ||||
| 			code: 'NO_SUCH_WEBHOOK', | ||||
| 			id: '0c52149c-e913-18f8-5dc7-74870bfe0cf9', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
|  | ||||
| export const paramDef = { | ||||
| 	type: 'object', | ||||
| 	properties: { | ||||
| 		webhookId: { | ||||
| 			type: 'string', | ||||
| 			format: 'misskey:id', | ||||
| 		}, | ||||
| 		type: { | ||||
| 			type: 'string', | ||||
| 			enum: systemWebhookEventTypes, | ||||
| 		}, | ||||
| 		override: { | ||||
| 			type: 'object', | ||||
| 			properties: { | ||||
| 				url: { type: 'string', nullable: false }, | ||||
| 				secret: { type: 'string', nullable: false }, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}, | ||||
| 	required: ['webhookId', 'type'], | ||||
| } as const; | ||||
|  | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export | ||||
| 	constructor( | ||||
| 		private webhookTestService: WebhookTestService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps) => { | ||||
| 			try { | ||||
| 				await this.webhookTestService.testSystemWebhook({ | ||||
| 					webhookId: ps.webhookId, | ||||
| 					type: ps.type, | ||||
| 					override: ps.override, | ||||
| 				}); | ||||
| 			} catch (e) { | ||||
| 				if (e instanceof WebhookTestService.NoSuchWebhookError) { | ||||
| 					throw new ApiError(meta.errors.noSuchWebhook); | ||||
| 				} | ||||
| 				throw e; | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
| @@ -142,6 +142,7 @@ export const paramDef = { | ||||
| 		perRemoteUserUserTimelineCacheMax: { type: 'integer' }, | ||||
| 		perUserHomeTimelineCacheMax: { type: 'integer' }, | ||||
| 		perUserListTimelineCacheMax: { type: 'integer' }, | ||||
| 		enableReactionsBuffering: { type: 'boolean' }, | ||||
| 		notesPerOneAd: { type: 'integer' }, | ||||
| 		silencedHosts: { | ||||
| 			type: 'array', | ||||
| @@ -598,6 +599,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | ||||
| 				set.perUserListTimelineCacheMax = ps.perUserListTimelineCacheMax; | ||||
| 			} | ||||
|  | ||||
| 			if (ps.enableReactionsBuffering !== undefined) { | ||||
| 				set.enableReactionsBuffering = ps.enableReactionsBuffering; | ||||
| 			} | ||||
|  | ||||
| 			if (ps.notesPerOneAd !== undefined) { | ||||
| 				set.notesPerOneAd = ps.notesPerOneAd; | ||||
| 			} | ||||
|   | ||||
| @@ -34,6 +34,12 @@ export const meta = { | ||||
| 			code: 'TOO_MANY_ANTENNAS', | ||||
| 			id: 'faf47050-e8b5-438c-913c-db2b1576fde4', | ||||
| 		}, | ||||
|  | ||||
| 		emptyKeyword: { | ||||
| 			message: 'Either keywords or excludeKeywords is required.', | ||||
| 			code: 'EMPTY_KEYWORD', | ||||
| 			id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a', | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	res: { | ||||
| @@ -87,7 +93,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			if (ps.keywords.flat().every(x => x === '') && ps.excludeKeywords.flat().every(x => x === '')) { | ||||
| 				throw new Error('either keywords or excludeKeywords is required.'); | ||||
| 				throw new ApiError(meta.errors.emptyKeyword); | ||||
| 			} | ||||
|  | ||||
| 			const currentAntennasCount = await this.antennasRepository.countBy({ | ||||
|   | ||||
| @@ -32,6 +32,12 @@ export const meta = { | ||||
| 			code: 'NO_SUCH_USER_LIST', | ||||
| 			id: '1c6b35c9-943e-48c2-81e4-2844989407f7', | ||||
| 		}, | ||||
|  | ||||
| 		emptyKeyword: { | ||||
| 			message: 'Either keywords or excludeKeywords is required.', | ||||
| 			code: 'EMPTY_KEYWORD', | ||||
| 			id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4', | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	res: { | ||||
| @@ -85,7 +91,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			if (ps.keywords && ps.excludeKeywords) { | ||||
| 				if (ps.keywords.flat().every(x => x === '') && ps.excludeKeywords.flat().every(x => x === '')) { | ||||
| 					throw new Error('either keywords or excludeKeywords is required.'); | ||||
| 					throw new ApiError(meta.errors.emptyKeyword); | ||||
| 				} | ||||
| 			} | ||||
| 			// Fetch the antenna | ||||
|   | ||||
| @@ -16,6 +16,7 @@ import { ApiError } from '../../error.js'; | ||||
| export const meta = { | ||||
| 	secure: true, | ||||
| 	requireCredential: true, | ||||
| 	requireRolePolicy: 'canImportAntennas', | ||||
| 	prohibitMoved: true, | ||||
|  | ||||
| 	limit: { | ||||
|   | ||||
| @@ -15,6 +15,7 @@ import { ApiError } from '../../error.js'; | ||||
| export const meta = { | ||||
| 	secure: true, | ||||
| 	requireCredential: true, | ||||
| 	requireRolePolicy: 'canImportBlocking', | ||||
| 	prohibitMoved: true, | ||||
|  | ||||
| 	limit: { | ||||
|   | ||||
| @@ -15,6 +15,7 @@ import { ApiError } from '../../error.js'; | ||||
| export const meta = { | ||||
| 	secure: true, | ||||
| 	requireCredential: true, | ||||
| 	requireRolePolicy: 'canImportFollowing', | ||||
| 	prohibitMoved: true, | ||||
| 	limit: { | ||||
| 		duration: ms('1hour'), | ||||
|   | ||||
| @@ -15,6 +15,7 @@ import { ApiError } from '../../error.js'; | ||||
| export const meta = { | ||||
| 	secure: true, | ||||
| 	requireCredential: true, | ||||
| 	requireRolePolicy: 'canImportMuting', | ||||
| 	prohibitMoved: true, | ||||
|  | ||||
| 	limit: { | ||||
|   | ||||
| @@ -15,6 +15,7 @@ import { ApiError } from '../../error.js'; | ||||
| export const meta = { | ||||
| 	secure: true, | ||||
| 	requireCredential: true, | ||||
| 	requireRolePolicy: 'canImportUserLists', | ||||
| 	prohibitMoved: true, | ||||
| 	limit: { | ||||
| 		duration: ms('1hour'), | ||||
|   | ||||
| @@ -13,6 +13,7 @@ import { DI } from '@/di-symbols.js'; | ||||
| import { RoleService } from '@/core/RoleService.js'; | ||||
| import { ApiError } from '@/server/api/error.js'; | ||||
|  | ||||
| // TODO: UserWebhook schemaの適用 | ||||
| export const meta = { | ||||
| 	tags: ['webhooks'], | ||||
|  | ||||
|   | ||||
| @@ -9,6 +9,7 @@ import { webhookEventTypes } from '@/models/Webhook.js'; | ||||
| import type { WebhooksRepository } from '@/models/_.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| // TODO: UserWebhook schemaの適用 | ||||
| export const meta = { | ||||
| 	tags: ['webhooks', 'account'], | ||||
|  | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import type { WebhooksRepository } from '@/models/_.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
|  | ||||
| // TODO: UserWebhook schemaの適用 | ||||
| export const meta = { | ||||
| 	tags: ['webhooks'], | ||||
|  | ||||
|   | ||||
							
								
								
									
										76
									
								
								packages/backend/src/server/api/endpoints/i/webhooks/test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								packages/backend/src/server/api/endpoints/i/webhooks/test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,76 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import ms from 'ms'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { webhookEventTypes } from '@/models/Webhook.js'; | ||||
| import { WebhookTestService } from '@/core/WebhookTestService.js'; | ||||
| import { ApiError } from '@/server/api/error.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['webhooks'], | ||||
|  | ||||
| 	requireCredential: true, | ||||
| 	secure: true, | ||||
| 	kind: 'read:account', | ||||
|  | ||||
| 	limit: { | ||||
| 		duration: ms('15min'), | ||||
| 		max: 60, | ||||
| 	}, | ||||
|  | ||||
| 	errors: { | ||||
| 		noSuchWebhook: { | ||||
| 			message: 'No such webhook.', | ||||
| 			code: 'NO_SUCH_WEBHOOK', | ||||
| 			id: '0c52149c-e913-18f8-5dc7-74870bfe0cf9', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
|  | ||||
| export const paramDef = { | ||||
| 	type: 'object', | ||||
| 	properties: { | ||||
| 		webhookId: { | ||||
| 			type: 'string', | ||||
| 			format: 'misskey:id', | ||||
| 		}, | ||||
| 		type: { | ||||
| 			type: 'string', | ||||
| 			enum: webhookEventTypes, | ||||
| 		}, | ||||
| 		override: { | ||||
| 			type: 'object', | ||||
| 			properties: { | ||||
| 				url: { type: 'string' }, | ||||
| 				secret: { type: 'string' }, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}, | ||||
| 	required: ['webhookId', 'type'], | ||||
| } as const; | ||||
|  | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export | ||||
| 	constructor( | ||||
| 		private webhookTestService: WebhookTestService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			try { | ||||
| 				await this.webhookTestService.testUserWebhook({ | ||||
| 					webhookId: ps.webhookId, | ||||
| 					type: ps.type, | ||||
| 					override: ps.override, | ||||
| 				}, me); | ||||
| 			} catch (e) { | ||||
| 				if (e instanceof WebhookTestService.NoSuchWebhookError) { | ||||
| 					throw new ApiError(meta.errors.noSuchWebhook); | ||||
| 				} | ||||
| 				throw e; | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
| @@ -61,7 +61,8 @@ const staticAssets = `${_dirname}/../../../assets/`; | ||||
| const clientAssets = `${_dirname}/../../../../frontend/assets/`; | ||||
| const assets = `${_dirname}/../../../../../built/_frontend_dist_/`; | ||||
| const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`; | ||||
| const viteOut = `${_dirname}/../../../../../built/_vite_/`; | ||||
| const frontendViteOut = `${_dirname}/../../../../../built/_frontend_vite_/`; | ||||
| const frontendEmbedViteOut = `${_dirname}/../../../../../built/_frontend_embed_vite_/`; | ||||
| const tarball = `${_dirname}/../../../../../built/tarball/`; | ||||
|  | ||||
| @Injectable() | ||||
| @@ -277,15 +278,22 @@ export class ClientServerService { | ||||
| 		}); | ||||
|  | ||||
| 		//#region vite assets | ||||
| 		if (this.config.clientManifestExists) { | ||||
| 		if (this.config.frontendEmbedManifestExists) { | ||||
| 			fastify.register((fastify, options, done) => { | ||||
| 				fastify.register(fastifyStatic, { | ||||
| 					root: viteOut, | ||||
| 					root: frontendViteOut, | ||||
| 					prefix: '/vite/', | ||||
| 					maxAge: ms('30 days'), | ||||
| 					immutable: true, | ||||
| 					decorateReply: false, | ||||
| 				}); | ||||
| 				fastify.register(fastifyStatic, { | ||||
| 					root: frontendEmbedViteOut, | ||||
| 					prefix: '/embed_vite/', | ||||
| 					maxAge: ms('30 days'), | ||||
| 					immutable: true, | ||||
| 					decorateReply: false, | ||||
| 				}); | ||||
| 				fastify.addHook('onRequest', handleRequestRedirectToOmitSearch); | ||||
| 				done(); | ||||
| 			}); | ||||
| @@ -296,6 +304,13 @@ export class ClientServerService { | ||||
| 				prefix: '/vite', | ||||
| 				rewritePrefix: '/vite', | ||||
| 			}); | ||||
|  | ||||
| 			const embedPort = (process.env.EMBED_VITE_PORT ?? '5174'); | ||||
| 			fastify.register(fastifyProxy, { | ||||
| 				upstream: 'http://localhost:' + embedPort, | ||||
| 				prefix: '/embed_vite', | ||||
| 				rewritePrefix: '/embed_vite', | ||||
| 			}); | ||||
| 		} | ||||
| 		//#endregion | ||||
|  | ||||
| @@ -425,6 +440,13 @@ export class ClientServerService { | ||||
| 		// Manifest | ||||
| 		fastify.get('/manifest.json', async (request, reply) => await this.manifestHandler(reply)); | ||||
|  | ||||
| 		// Embed Javascript | ||||
| 		fastify.get('/embed.js', async (request, reply) => { | ||||
| 			return await reply.sendFile('/embed.js', staticAssets, { | ||||
| 				maxAge: ms('1 day'), | ||||
| 			}); | ||||
| 		}); | ||||
|  | ||||
| 		fastify.get('/robots.txt', async (request, reply) => { | ||||
| 			return await reply.sendFile('/robots.txt', staticAssets); | ||||
| 		}); | ||||
| @@ -762,7 +784,7 @@ export class ClientServerService { | ||||
| 		}); | ||||
| 		//#endregion | ||||
|  | ||||
| 		//region noindex pages | ||||
| 		//#region noindex pages | ||||
| 		// Tags | ||||
| 		fastify.get<{ Params: { clip: string; } }>('/tags/:tag', async (request, reply) => { | ||||
| 			return await renderBase(reply, { noindex: true }); | ||||
| @@ -772,7 +794,20 @@ export class ClientServerService { | ||||
| 		fastify.get<{ Params: { clip: string; } }>('/user-tags/:tag', async (request, reply) => { | ||||
| 			return await renderBase(reply, { noindex: true }); | ||||
| 		}); | ||||
| 		//endregion | ||||
| 		//#endregion | ||||
|  | ||||
| 		//#region embed pages | ||||
| 		fastify.get('/embed/*', async (request, reply) => { | ||||
| 			const meta = await this.metaService.fetch(); | ||||
|  | ||||
| 			reply.removeHeader('X-Frame-Options'); | ||||
|  | ||||
| 			reply.header('Cache-Control', 'public, max-age=3600'); | ||||
| 			return await reply.view('base-embed', { | ||||
| 				title: meta.name ?? 'Misskey', | ||||
| 				...await this.generateCommonPugData(meta), | ||||
| 			}); | ||||
| 		}); | ||||
|  | ||||
| 		fastify.get('/_info_card_', async (request, reply) => { | ||||
| 			const meta = await this.metaService.fetch(true); | ||||
| @@ -787,6 +822,7 @@ export class ClientServerService { | ||||
| 				originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }), | ||||
| 			}); | ||||
| 		}); | ||||
| 		//#endregion | ||||
|  | ||||
| 		fastify.get('/bios', async (request, reply) => { | ||||
| 			return await reply.view('bios', { | ||||
|   | ||||
							
								
								
									
										219
									
								
								packages/backend/src/server/web/boot.embed.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										219
									
								
								packages/backend/src/server/web/boot.embed.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,219 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| 'use strict'; | ||||
|  | ||||
| // ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので | ||||
| (async () => { | ||||
| 	window.onerror = (e) => { | ||||
| 		console.error(e); | ||||
| 		renderError('SOMETHING_HAPPENED'); | ||||
| 	}; | ||||
| 	window.onunhandledrejection = (e) => { | ||||
| 		console.error(e); | ||||
| 		renderError('SOMETHING_HAPPENED_IN_PROMISE'); | ||||
| 	}; | ||||
|  | ||||
| 	let forceError = localStorage.getItem('forceError'); | ||||
| 	if (forceError != null) { | ||||
| 		renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.'); | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	// パラメータに応じてsplashのスタイルを変更 | ||||
| 	const params = new URLSearchParams(location.search); | ||||
| 	if (params.has('rounded') && params.get('rounded') === 'false') { | ||||
| 		document.documentElement.classList.add('norounded'); | ||||
| 	} | ||||
| 	if (params.has('border') && params.get('border') === 'false') { | ||||
| 		document.documentElement.classList.add('noborder'); | ||||
| 	} | ||||
|  | ||||
| 	//#region Detect language & fetch translations | ||||
| 	if (!localStorage.hasOwnProperty('locale')) { | ||||
| 		const supportedLangs = LANGS; | ||||
| 		let lang = localStorage.getItem('lang'); | ||||
| 		if (lang == null || !supportedLangs.includes(lang)) { | ||||
| 			if (supportedLangs.includes(navigator.language)) { | ||||
| 				lang = navigator.language; | ||||
| 			} else { | ||||
| 				lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); | ||||
|  | ||||
| 				// Fallback | ||||
| 				if (lang == null) lang = 'en-US'; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		const metaRes = await window.fetch('/api/meta', { | ||||
| 			method: 'POST', | ||||
| 			body: JSON.stringify({}), | ||||
| 			credentials: 'omit', | ||||
| 			cache: 'no-cache', | ||||
| 			headers: { | ||||
| 				'Content-Type': 'application/json', | ||||
| 			}, | ||||
| 		}); | ||||
| 		if (metaRes.status !== 200) { | ||||
| 			renderError('META_FETCH'); | ||||
| 			return; | ||||
| 		} | ||||
| 		const meta = await metaRes.json(); | ||||
| 		const v = meta.version; | ||||
| 		if (v == null) { | ||||
| 			renderError('META_FETCH_V'); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		// for https://github.com/misskey-dev/misskey/issues/10202 | ||||
| 		if (lang == null || lang.toString == null || lang.toString() === 'null') { | ||||
| 			console.error('invalid lang value detected!!!', typeof lang, lang); | ||||
| 			lang = 'en-US'; | ||||
| 		} | ||||
|  | ||||
| 		const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`); | ||||
| 		if (localRes.status === 200) { | ||||
| 			localStorage.setItem('lang', lang); | ||||
| 			localStorage.setItem('locale', await localRes.text()); | ||||
| 			localStorage.setItem('localeVersion', v); | ||||
| 		} else { | ||||
| 			renderError('LOCALE_FETCH'); | ||||
| 			return; | ||||
| 		} | ||||
| 	} | ||||
| 	//#endregion | ||||
|  | ||||
| 	//#region Script | ||||
| 	async function importAppScript() { | ||||
| 		await import(`/embed_vite/${CLIENT_ENTRY}`) | ||||
| 			.catch(async e => { | ||||
| 				console.error(e); | ||||
| 				renderError('APP_IMPORT'); | ||||
| 			}); | ||||
| 	} | ||||
|  | ||||
| 	// タイミングによっては、この時点でDOMの構築が済んでいる場合とそうでない場合とがある | ||||
| 	if (document.readyState !== 'loading') { | ||||
| 		importAppScript(); | ||||
| 	} else { | ||||
| 		window.addEventListener('DOMContentLoaded', () => { | ||||
| 			importAppScript(); | ||||
| 		}); | ||||
| 	} | ||||
| 	//#endregion | ||||
|  | ||||
| 	async function addStyle(styleText) { | ||||
| 		let css = document.createElement('style'); | ||||
| 		css.appendChild(document.createTextNode(styleText)); | ||||
| 		document.head.appendChild(css); | ||||
| 	} | ||||
|  | ||||
| 	async function renderError(code) { | ||||
| 		// Cannot set property 'innerHTML' of null を回避 | ||||
| 		if (document.readyState === 'loading') { | ||||
| 			await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve)); | ||||
| 		} | ||||
| 		document.body.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M12 9v4" /><path d="M12 16v.01" /></svg> | ||||
| 		<div class="message">読み込みに失敗しました</div> | ||||
| 		<div class="submessage">Failed to initialize Misskey</div> | ||||
| 		<div class="submessage">Error Code: ${code}</div> | ||||
| 		<button onclick="location.reload(!0)"> | ||||
| 			<div>リロード</div> | ||||
| 			<div><small>Reload</small></div> | ||||
| 		</button>`; | ||||
| 		addStyle(` | ||||
| 		#misskey_app, | ||||
| 		#splash { | ||||
| 			display: none !important; | ||||
| 		} | ||||
|  | ||||
| 		html, | ||||
| 		body { | ||||
| 			margin: 0; | ||||
| 		} | ||||
|  | ||||
| 		body { | ||||
| 			position: relative; | ||||
| 			color: #dee7e4; | ||||
| 			font-family: Hiragino Maru Gothic Pro, BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; | ||||
| 			line-height: 1.35; | ||||
| 			display: flex; | ||||
| 			flex-direction: column; | ||||
| 			align-items: center; | ||||
| 			justify-content: center; | ||||
| 			min-height: 100vh; | ||||
| 			margin: 0; | ||||
| 			padding: 24px; | ||||
| 			box-sizing: border-box; | ||||
| 			overflow: hidden; | ||||
|  | ||||
| 			border-radius: var(--radius, 12px); | ||||
| 			border: 1px solid rgba(231, 255, 251, 0.14); | ||||
| 		} | ||||
|  | ||||
| 		body::before { | ||||
| 			content: ''; | ||||
| 			position: fixed; | ||||
| 			top: 0; | ||||
| 			left: 0; | ||||
| 			width: 100%; | ||||
| 			height: 100%; | ||||
| 			background: #192320; | ||||
| 			border-radius: var(--radius, 12px); | ||||
| 			z-index: -1; | ||||
| 		} | ||||
|  | ||||
| 		html.embed.norounded body, | ||||
| 		html.embed.norounded body::before { | ||||
| 			border-radius: 0; | ||||
| 		} | ||||
|  | ||||
| 		html.embed.noborder body { | ||||
| 			border: none; | ||||
| 		} | ||||
|  | ||||
| 		.icon { | ||||
| 			max-width: 60px; | ||||
| 			width: 100%; | ||||
| 			height: auto; | ||||
| 			margin-bottom: 20px; | ||||
| 			color: #dec340; | ||||
| 		} | ||||
|  | ||||
| 		.message { | ||||
| 			text-align: center; | ||||
| 			font-size: 20px; | ||||
| 			font-weight: 700; | ||||
| 			margin-bottom: 20px; | ||||
| 		} | ||||
|  | ||||
| 		.submessage { | ||||
| 			text-align: center; | ||||
| 			font-size: 90%; | ||||
| 			margin-bottom: 7.5px; | ||||
| 		} | ||||
|  | ||||
| 		.submessage:last-of-type { | ||||
| 			margin-bottom: 20px; | ||||
| 		} | ||||
|  | ||||
| 		button { | ||||
| 			padding: 7px 14px; | ||||
| 			min-width: 100px; | ||||
| 			font-weight: 700; | ||||
| 			font-family: Hiragino Maru Gothic Pro, BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; | ||||
| 			line-height: 1.35; | ||||
| 			border-radius: 99rem; | ||||
| 			background-color: #b4e900; | ||||
| 			color: #192320; | ||||
| 			border: none; | ||||
| 			cursor: pointer; | ||||
| 			-webkit-tap-highlight-color: transparent; | ||||
| 		} | ||||
|  | ||||
| 		button:hover { | ||||
| 			background-color: #c6ff03; | ||||
| 		}`); | ||||
| 	} | ||||
| })(); | ||||
| @@ -3,17 +3,6 @@ | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * BOOT LOADER | ||||
|  * サーバーからレスポンスされるHTMLに埋め込まれるスクリプトで、以下の役割を持ちます。 | ||||
|  * - 翻訳ファイルをフェッチする。 | ||||
|  * - バージョンに基づいて適切なメインスクリプトを読み込む。 | ||||
|  * - キャッシュされたコンパイル済みテーマを適用する。 | ||||
|  * - クライアントの設定値に基づいて対応するHTMLクラス等を設定する。 | ||||
|  * テーマをこの段階で設定するのは、メインスクリプトが読み込まれる間もテーマを適用したいためです。 | ||||
|  * 注: webpackは介さないため、このファイルではrequireやimportは使えません。 | ||||
|  */ | ||||
|  | ||||
| 'use strict'; | ||||
|  | ||||
| // ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので | ||||
| @@ -166,7 +155,7 @@ | ||||
|  | ||||
| 		if (!errorsElement) { | ||||
| 			document.body.innerHTML = ` | ||||
| 			<svg class="icon-warning" xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-alert-triangle" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> | ||||
| 			<svg class="icon-warning" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> | ||||
| 				<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> | ||||
| 				<path d="M12 9v2m0 4v.01"></path> | ||||
| 				<path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"></path> | ||||
| @@ -176,10 +165,10 @@ | ||||
| 				<span class="button-label-big">Reload / リロード</span> | ||||
| 			</button> | ||||
| 			<p><b>The following actions may solve the problem. / 以下を行うと解決する可能性があります。</b></p> | ||||
| 			<p>Clear the browser cache / ブラウザのキャッシュをクリアする</p> | ||||
| 			<p>Update your os and browser / ブラウザおよびOSを最新バージョンに更新する</p> | ||||
| 			<p>Disable an adblocker / アドブロッカーを無効にする</p> | ||||
| 	 		<p>(Tor Browser) Set dom.webaudio.enabled to true / dom.webaudio.enabledをtrueに設定する</p> | ||||
| 			<p>Clear the browser cache / ブラウザのキャッシュをクリアする</p> | ||||
| 			<p>(Tor Browser) Set dom.webaudio.enabled to true / dom.webaudio.enabledをtrueに設定する</p> | ||||
| 			<details style="color: #86b300;"> | ||||
| 				<summary>Other options / その他のオプション</summary> | ||||
| 				<a href="/flush"> | ||||
| @@ -212,7 +201,7 @@ | ||||
| 		<summary> | ||||
| 			<code>ERROR CODE: ${code}</code> | ||||
| 		</summary> | ||||
| 		<code>${JSON.stringify(details)}</code>`; | ||||
| 		<code>${details.toString()} ${JSON.stringify(details)}</code>`; | ||||
| 		errorsElement.appendChild(detailsElement); | ||||
| 		addStyle(` | ||||
| 		* { | ||||
| @@ -320,6 +309,6 @@ | ||||
| 			#errorInfo { | ||||
| 				width: 50%; | ||||
| 			} | ||||
| 		}`) | ||||
| 		}`); | ||||
| 	} | ||||
| })(); | ||||
|   | ||||
| @@ -47,6 +47,7 @@ html { | ||||
| 	transform: translateY(70px); | ||||
| 	color: var(--accent); | ||||
| } | ||||
|  | ||||
| #splashSpinner > .spinner { | ||||
| 	position: absolute; | ||||
| 	top: 0; | ||||
|   | ||||
							
								
								
									
										99
									
								
								packages/backend/src/server/web/style.embed.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								packages/backend/src/server/web/style.embed.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| html { | ||||
| 	background-color: var(--bg); | ||||
| 	color: var(--fg); | ||||
| } | ||||
|  | ||||
| html.embed { | ||||
| 	box-sizing: border-box; | ||||
| 	background-color: transparent; | ||||
| 	color-scheme: light dark; | ||||
| 	max-width: 500px; | ||||
| } | ||||
|  | ||||
| #splash { | ||||
| 	position: fixed; | ||||
| 	z-index: 10000; | ||||
| 	top: 0; | ||||
| 	left: 0; | ||||
| 	width: 100vw; | ||||
| 	height: 100vh; | ||||
| 	cursor: wait; | ||||
| 	background-color: var(--bg); | ||||
| 	opacity: 1; | ||||
| 	transition: opacity 0.5s ease; | ||||
| } | ||||
|  | ||||
| html.embed #splash { | ||||
| 	box-sizing: border-box; | ||||
| 	min-height: 300px; | ||||
| 	border-radius: var(--radius, 12px); | ||||
| 	border: 1px solid var(--divider, #e8e8e8); | ||||
| } | ||||
|  | ||||
| html.embed.norounded #splash { | ||||
| 	border-radius: 0; | ||||
| } | ||||
|  | ||||
| html.embed.noborder #splash { | ||||
| 	border: none; | ||||
| } | ||||
|  | ||||
| #splashIcon { | ||||
| 	position: absolute; | ||||
| 	top: 0; | ||||
| 	right: 0; | ||||
| 	bottom: 0; | ||||
| 	left: 0; | ||||
| 	margin: auto; | ||||
| 	width: 64px; | ||||
| 	height: 64px; | ||||
| 	pointer-events: none; | ||||
| } | ||||
|  | ||||
| #splashSpinner { | ||||
| 	position: absolute; | ||||
| 	top: 0; | ||||
| 	right: 0; | ||||
| 	bottom: 0; | ||||
| 	left: 0; | ||||
| 	margin: auto; | ||||
| 	display: inline-block; | ||||
| 	width: 28px; | ||||
| 	height: 28px; | ||||
| 	transform: translateY(70px); | ||||
| 	color: var(--accent); | ||||
| } | ||||
|  | ||||
| #splashSpinner > .spinner { | ||||
| 	position: absolute; | ||||
| 	top: 0; | ||||
| 	left: 0; | ||||
| 	width: 28px; | ||||
| 	height: 28px; | ||||
| 	fill-rule: evenodd; | ||||
| 	clip-rule: evenodd; | ||||
| 	stroke-linecap: round; | ||||
| 	stroke-linejoin: round; | ||||
| 	stroke-miterlimit: 1.5; | ||||
| } | ||||
| #splashSpinner > .spinner.bg { | ||||
| 	opacity: 0.275; | ||||
| } | ||||
| #splashSpinner > .spinner.fg { | ||||
| 	animation: splashSpinner 0.5s linear infinite; | ||||
| } | ||||
|  | ||||
| @keyframes splashSpinner { | ||||
| 	0% { | ||||
| 		transform: rotate(0deg); | ||||
| 	} | ||||
| 	100% { | ||||
| 		transform: rotate(360deg); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										67
									
								
								packages/backend/src/server/web/views/base-embed.pug
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								packages/backend/src/server/web/views/base-embed.pug
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| block vars | ||||
|  | ||||
| block loadClientEntry | ||||
| 	- const entry = config.frontendEmbedEntry; | ||||
|  | ||||
| doctype html | ||||
|  | ||||
| html(class='embed') | ||||
|  | ||||
| 	head | ||||
| 		meta(charset='utf-8') | ||||
| 		meta(name='application-name' content='Misskey') | ||||
| 		meta(name='referrer' content='origin') | ||||
| 		meta(name='theme-color' content= themeColor || '#86b300') | ||||
| 		meta(name='theme-color-orig' content= themeColor || '#86b300') | ||||
| 		meta(name='viewport' content='width=device-width, initial-scale=1') | ||||
| 		meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no') | ||||
| 		link(rel='icon' href= icon || '/favicon.ico') | ||||
| 		link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png') | ||||
| 		link(rel='modulepreload' href=`/embed_vite/${entry.file}`) | ||||
|  | ||||
| 		if !config.frontendEmbedManifestExists | ||||
| 				script(type="module" src="/embed_vite/@vite/client") | ||||
|  | ||||
| 		if Array.isArray(entry.css) | ||||
| 			each href in entry.css | ||||
| 				link(rel='stylesheet' href=`/embed_vite/${href}`) | ||||
|  | ||||
| 		title | ||||
| 			block title | ||||
| 				= title || 'Misskey' | ||||
|  | ||||
| 		block meta | ||||
| 			meta(name='robots' content='noindex') | ||||
|  | ||||
| 		style | ||||
| 			include ../style.embed.css | ||||
|  | ||||
| 		script. | ||||
| 			var VERSION = "#{version}"; | ||||
| 			var CLIENT_ENTRY = "#{entry.file}"; | ||||
|  | ||||
| 		script(type='application/json' id='misskey_meta' data-generated-at=now) | ||||
| 			!= metaJson | ||||
|  | ||||
| 		script | ||||
| 			include ../boot.embed.js | ||||
|  | ||||
| 	body | ||||
| 		noscript: p | ||||
| 			| JavaScriptを有効にしてください | ||||
| 			br | ||||
| 			| Please turn on your JavaScript | ||||
| 		div#splash | ||||
| 			img#splashIcon(src= icon || '/static-assets/splash.png') | ||||
| 			div#splashSpinner | ||||
| 				<svg class="spinner bg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg"> | ||||
| 					<g transform="matrix(1,0,0,1,12,12)"> | ||||
| 						<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:24px;"/> | ||||
| 					</g> | ||||
| 				</svg> | ||||
| 				<svg class="spinner fg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg"> | ||||
| 					<g transform="matrix(1,0,0,1,12,12)"> | ||||
| 						<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:24px;"/> | ||||
| 					</g> | ||||
| 				</svg> | ||||
| 		block content | ||||
| @@ -1,7 +1,7 @@ | ||||
| block vars | ||||
|  | ||||
| block loadClientEntry | ||||
| 	- const clientEntry = config.clientEntry; | ||||
| 	- const entry = config.frontendEntry; | ||||
|  | ||||
| doctype html | ||||
|  | ||||
| @@ -36,15 +36,13 @@ html | ||||
| 		link(rel='prefetch' href=serverErrorImageUrl) | ||||
| 		link(rel='prefetch' href=infoImageUrl) | ||||
| 		link(rel='prefetch' href=notFoundImageUrl) | ||||
| 		//- https://github.com/misskey-dev/misskey/issues/9842 | ||||
| 		link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v3.3.0') | ||||
| 		link(rel='modulepreload' href=`/vite/${clientEntry.file}`) | ||||
| 		link(rel='modulepreload' href=`/vite/${entry.file}`) | ||||
|  | ||||
| 		if !config.clientManifestExists | ||||
| 		if !config.frontendManifestExists | ||||
| 				script(type="module" src="/vite/@vite/client") | ||||
|  | ||||
| 		if Array.isArray(clientEntry.css) | ||||
| 			each href in clientEntry.css | ||||
| 		if Array.isArray(entry.css) | ||||
| 			each href in entry.css | ||||
| 				link(rel='stylesheet' href=`/vite/${href}`) | ||||
|  | ||||
| 		title | ||||
| @@ -70,7 +68,7 @@ html | ||||
|  | ||||
| 		script. | ||||
| 			var VERSION = "#{version}"; | ||||
| 			var CLIENT_ENTRY = "#{clientEntry.file}"; | ||||
| 			var CLIENT_ENTRY = "#{entry.file}"; | ||||
|  | ||||
| 		script(type='application/json' id='misskey_meta' data-generated-at=now) | ||||
| 			!= metaJson | ||||
|   | ||||
| @@ -228,6 +228,17 @@ describe('アンテナ', () => { | ||||
| 		assert.deepStrictEqual(response, expected); | ||||
| 	}); | ||||
|  | ||||
| 	test('を作成する時キーワードが指定されていないとエラーになる', async () => { | ||||
| 		await failedApiCall({ | ||||
| 			endpoint: 'antennas/create', | ||||
| 			parameters: { ...defaultParam, keywords: [[]], excludeKeywords: [[]] }, | ||||
| 			user: alice | ||||
| 		}, { | ||||
| 			status: 400, | ||||
| 			code: 'EMPTY_KEYWORD', | ||||
| 			id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a' | ||||
| 		}) | ||||
| 	}); | ||||
| 	//#endregion | ||||
| 	//#region 更新(antennas/update) | ||||
|  | ||||
| @@ -255,6 +266,18 @@ describe('アンテナ', () => { | ||||
| 			id: '1c6b35c9-943e-48c2-81e4-2844989407f7', | ||||
| 		}); | ||||
| 	}); | ||||
| 	test('を変更する時キーワードが指定されていないとエラーになる', async () => { | ||||
| 		const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice }); | ||||
| 		await failedApiCall({ | ||||
| 			endpoint: 'antennas/update', | ||||
| 			parameters: { ...defaultParam, antennaId: antenna.id, keywords: [[]], excludeKeywords: [[]] }, | ||||
| 			user: alice | ||||
| 		}, { | ||||
| 			status: 400, | ||||
| 			code: 'EMPTY_KEYWORD', | ||||
| 			id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4' | ||||
| 		}) | ||||
| 	}); | ||||
|  | ||||
| 	//#endregion | ||||
| 	//#region 表示(antennas/show) | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user