diff --git a/.config/ci.yml b/.config/ci.yml deleted file mode 100644 index c48fca49bf..0000000000 --- a/.config/ci.yml +++ /dev/null @@ -1,216 +0,0 @@ -#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# Misskey configuration -#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -# ┌─────┐ -#───┘ URL └───────────────────────────────────────────────────── - -# Final accessible URL seen by a user. -url: https://example.tld/ - -# 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: 3000 - -# ┌──────────────────────────┐ -#───┘ PostgreSQL configuration └──────────────────────────────── - -db: - host: postgres - port: 5432 - - # Database name - db: postgres - - # Auth - user: postgres - pass: ci - - # 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 - -# ┌───────────────────────────┐ -#───┘ MeiliSearch configuration └───────────────────────────── - -# You can set scope to local (default value) or global -# (include notes from remote). - -#meilisearch: -# host: meilisearch -# port: 7700 -# apiKey: '' -# ssl: true -# index: '' -# scope: global - -# ┌───────────────┐ -#───┘ 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' - -# ┌─────────────────────┐ -#───┘ Other configuration └───────────────────────────────────── - -# Whether disable HSTS -#disableHsts: true - -# Number of worker processes -#clusterLimit: 1 - -# Job concurrency per worker -# deliverJobConcurrency: 128 -# inboxJobConcurrency: 16 -# relashionshipJobConcurrency: 16 -# What's relashionshipJob?: -# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations. - -# Job rate limiter -# deliverJobPerSec: 128 -# inboxJobPerSec: 32 -# relashionshipJobPerSec: 64 - -# Job attempts -# deliverJobMaxAttempts: 12 -# inboxJobMaxAttempts: 8 - -# Local address used for outgoing requests -#outgoingAddress: 127.0.0.1 - -# IP address family used for outgoing request (ipv4, ipv6 or dual) -#outgoingAddressFamily: ipv4 - -# Amount of characters that can be used when writing notes (maximum: 8192, minimum: 1) -maxNoteLength: 3000 - -# 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) -# Proxy remote files by this instance or mediaProxy to prevent remote files from running in remote domains. -proxyRemoteFiles: true - -# Movie Thumbnail Generation URL -# There is no reference implementation. -# For example, Misskey will point to the following URL: -# https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4 -#videoThumbnailGenerator: https://example.com - -# Sign to ActivityPub GET request (default: true) -signToActivityPubGet: true -# check that inbound ActivityPub GET requests are signed ("authorized fetch") -checkActivityPubGetSignature: false - -# For security reasons, uploading attachments from the intranet is prohibited, -# but exceptions can be made from the following settings. Default value is "undefined". -# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)). -#allowedPrivateNetworks: [ -# '127.0.0.1/32' -#] - -#customMOTD: ['Hello World', 'The sharks rule all', 'Shonks'] - -# Upload or download file size limits (bytes) -#maxFileSize: 262144000 diff --git a/.config/docker_example.yml b/.config/docker_example.yml deleted file mode 100644 index c6c83a98bf..0000000000 --- a/.config/docker_example.yml +++ /dev/null @@ -1,216 +0,0 @@ -#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# Misskey configuration -#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -# ┌─────┐ -#───┘ URL └───────────────────────────────────────────────────── - -# Final accessible URL seen by a user. -url: https://example.tld/ - -# 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: 3000 - -# ┌──────────────────────────┐ -#───┘ PostgreSQL configuration └──────────────────────────────── - -db: - host: db - port: 5432 - - # Database name - db: misskey - - # Auth - user: example-misskey-user - pass: example-misskey-pass - - # 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 - -# ┌───────────────────────────┐ -#───┘ MeiliSearch configuration └───────────────────────────── - -# You can set scope to local (default value) or global -# (include notes from remote). - -#meilisearch: -# host: meilisearch -# port: 7700 -# apiKey: '' -# ssl: true -# index: '' -# scope: global - -# ┌───────────────┐ -#───┘ 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' - -# ┌─────────────────────┐ -#───┘ Other configuration └───────────────────────────────────── - -# Whether disable HSTS -#disableHsts: true - -# Number of worker processes -#clusterLimit: 1 - -# Job concurrency per worker -# deliverJobConcurrency: 128 -# inboxJobConcurrency: 16 -# relashionshipJobConcurrency: 16 -# What's relashionshipJob?: -# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations. - -# Job rate limiter -# deliverJobPerSec: 128 -# inboxJobPerSec: 32 -# relashionshipJobPerSec: 64 - -# Job attempts -# deliverJobMaxAttempts: 12 -# inboxJobMaxAttempts: 8 - -# Local address used for outgoing requests -#outgoingAddress: 127.0.0.1 - -# IP address family used for outgoing request (ipv4, ipv6 or dual) -#outgoingAddressFamily: ipv4 - -# Amount of characters that can be used when writing notes (maximum: 8192, minimum: 1) -maxNoteLength: 3000 - -# 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) -# Proxy remote files by this instance or mediaProxy to prevent remote files from running in remote domains. -proxyRemoteFiles: true - -# Movie Thumbnail Generation URL -# There is no reference implementation. -# For example, Misskey will point to the following URL: -# https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4 -#videoThumbnailGenerator: https://example.com - -# Sign to ActivityPub GET request (default: true) -signToActivityPubGet: true -# check that inbound ActivityPub GET requests are signed ("authorized fetch") -checkActivityPubGetSignature: false - -# For security reasons, uploading attachments from the intranet is prohibited, -# but exceptions can be made from the following settings. Default value is "undefined". -# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)). -#allowedPrivateNetworks: [ -# '127.0.0.1/32' -#] - -#customMOTD: ['Hello World', 'The sharks rule all', 'Shonks'] - -# Upload or download file size limits (bytes) -#maxFileSize: 262144000 diff --git a/.config/example.yml b/.config/example.yml index 4aa7757c61..8b9d9b4823 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -30,10 +30,6 @@ url: https://example.tld/ # The port that your Misskey server should listen on. port: 3000 -# You can also use UNIX domain socket. -# socket: /path/to/misskey.sock -# chmodSocket: '777' - # ┌──────────────────────────┐ #───┘ PostgreSQL configuration └──────────────────────────────── @@ -55,23 +51,6 @@ db: #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 └───────────────────────────────────── @@ -82,52 +61,16 @@ redis: #pass: example-pass #prefix: example-prefix #db: 1 - # You can specify more ioredis options... - #username: example-username -#redisForPubsub: +# ┌─────────────────────────────┐ +#───┘ Elasticsearch configuration └───────────────────────────── + +#elasticsearch: # 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 - -#redisForJobQueue: -# 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 - -#redisForTimelines: -# 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 └───────────────────────────── - -# You can set scope to local (default value) or global -# (include notes from remote). - -#meilisearch: -# host: localhost -# port: 7700 -# apiKey: '' -# ssl: true -# index: '' -# scope: global +# port: 9200 +# ssl: false +# user: +# pass: # ┌───────────────┐ #───┘ ID generation └─────────────────────────────────────────── @@ -138,7 +81,6 @@ redis: # 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 @@ -146,7 +88,7 @@ redis: # ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE # ID SETTINGS AFTER THAT! -id: 'aidx' +id: 'aid' # ┌─────────────────────┐ #───┘ Other configuration └───────────────────────────────────── @@ -158,39 +100,32 @@ id: 'aidx' #clusterLimit: 1 # Job concurrency per worker -#deliverJobConcurrency: 128 -#inboxJobConcurrency: 16 -#relashionshipJobConcurrency: 16 -# What's relashionshipJob?: -# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations. +# deliverJobConcurrency: 128 +# inboxJobConcurrency: 16 # Job rate limiter -#deliverJobPerSec: 128 -#inboxJobPerSec: 32 -#relashionshipJobPerSec: 64 +# deliverJobPerSec: 128 +# inboxJobPerSec: 16 # Job attempts -#deliverJobMaxAttempts: 12 -#inboxJobMaxAttempts: 8 - -# Local address used for outgoing requests -#outgoingAddress: 127.0.0.1 +# deliverJobMaxAttempts: 12 +# inboxJobMaxAttempts: 8 # IP address family used for outgoing request (ipv4, ipv6 or dual) #outgoingAddressFamily: ipv4 -# Amount of characters that can be used when writing notes (maximum: 8192, minimum: 1) -maxNoteLength: 3000 +# Syslog option +#syslog: +# host: localhost +# port: 514 # 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 +#proxyBypassHosts: [ +# 'example.com', +# '192.0.2.8' +#] # Proxy for SMTP/SMTPS #proxySmtp: http://127.0.0.1:3128 # use HTTP/1.1 CONNECT @@ -198,37 +133,17 @@ proxyBypassHosts: #proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 # Media Proxy -# Reference Implementation: https://github.com/misskey-dev/media-proxy -# * Deliver a common cache between instances -# * Perform image compression (on a different server resource than the main process) #mediaProxy: https://example.com/proxy -# Proxy remote files (default: true) -# Proxy remote files by this instance or mediaProxy to prevent remote files from running in remote domains. -proxyRemoteFiles: true +# Proxy remote files (default: false) +#proxyRemoteFiles: true -# Movie Thumbnail Generation URL -# There is no reference implementation. -# For example, Misskey will point to the following URL: -# https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4 -#videoThumbnailGenerator: https://example.com +# Sign to ActivityPub GET request (default: false) +#signToActivityPubGet: true -# Sign to ActivityPub GET request (default: true) -signToActivityPubGet: true -# check that inbound ActivityPub GET requests are signed ("authorized fetch") -checkActivityPubGetSignature: false - -# For security reasons, uploading attachments from the intranet is prohibited, -# but exceptions can be made from the following settings. Default value is "undefined". -# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)). #allowedPrivateNetworks: [ # '127.0.0.1/32' #] -#customMOTD: ['Hello World', 'The sharks rule all', 'Shonks'] - # Upload or download file size limits (bytes) #maxFileSize: 262144000 - -# PID File of master process -#pidFile: /tmp/misskey.pid diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index b6ebcf6ad3..0000000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1 +0,0 @@ -FROM mcr.microsoft.com/devcontainers/javascript-node:0-18 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index e409adf644..0000000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "Misskey", - "dockerComposeFile": "docker-compose.yml", - "service": "app", - "workspaceFolder": "/workspace", - "features": { - "ghcr.io/devcontainers-contrib/features/pnpm:2": { - "version": "8.9.2" - }, - "ghcr.io/devcontainers/features/node:1": { - "version": "20.10.0" - } - }, - "forwardPorts": [3000], - "postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh", - "customizations": { - "vscode": { - "extensions": [ - "editorconfig.editorconfig", - "dbaeumer.vscode-eslint", - "Vue.volar", - "Vue.vscode-typescript-vue-plugin", - "Orta.vscode-jest", - "dbaeumer.vscode-eslint", - "mrmlnc.vscode-json5" - ] - } - } -} diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml deleted file mode 100644 index 7ea0929469..0000000000 --- a/.devcontainer/devcontainer.yml +++ /dev/null @@ -1,188 +0,0 @@ -#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# Misskey configuration -#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -# ┌─────┐ -#───┘ URL └───────────────────────────────────────────────────── - -# Final accessible URL seen by a user. -url: http://127.0.0.1:3000/ - -# 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: 3000 - -# ┌──────────────────────────┐ -#───┘ 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 - -# ┌───────────────────────────┐ -#───┘ 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' - -# ┌─────────────────────┐ -#───┘ 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 diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml deleted file mode 100644 index 2809cd2ca4..0000000000 --- a/.devcontainer/docker-compose.yml +++ /dev/null @@ -1,53 +0,0 @@ -version: '3.8' - -services: - app: - build: - context: . - dockerfile: Dockerfile - - volumes: - - ../:/workspace:cached - - command: sleep infinity - - networks: - - internal_network - - external_network - - redis: - restart: unless-stopped - image: redis:7-alpine - networks: - - internal_network - volumes: - - redis-data:/data - healthcheck: - test: "redis-cli ping" - interval: 5s - retries: 20 - - db: - restart: unless-stopped - image: postgres:15-alpine - networks: - - internal_network - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: misskey - volumes: - - postgres-data:/var/lib/postgresql/data - healthcheck: - test: "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" - interval: 5s - retries: 20 - -volumes: - postgres-data: - redis-data: - -networks: - internal_network: - internal: true - external_network: diff --git a/.devcontainer/init.sh b/.devcontainer/init.sh deleted file mode 100755 index bcad3e6d85..0000000000 --- a/.devcontainer/init.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -set -xe - -sudo chown -R node /workspace -git submodule update --init -pnpm config set store-dir /home/node/.local/share/pnpm/store -pnpm install --frozen-lockfile -cp .devcontainer/devcontainer.yml .config/default.yml -pnpm build -pnpm migrate diff --git a/.dockerignore b/.dockerignore index 1de0c7982b..9ed558a258 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,24 +8,8 @@ build/ built/ db/ docker-compose.yml +elasticsearch/ node_modules/ -packages/*/node_modules redis/ files/ misskey-assets/ -fluent-emojis/ -.pnp.* - -# .yarn関連 -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions - -.pnpm-store - -.idea/ -packages/*/.vscode/ -packages/backend/test/docker-compose.yml diff --git a/.dockleignore b/.dockleignore deleted file mode 100644 index 2f93266456..0000000000 --- a/.dockleignore +++ /dev/null @@ -1,3 +0,0 @@ -DKL-DI-0005 -DKL-DI-0006 -DKL-LI-0003 diff --git a/.editorconfig b/.editorconfig index def7baa1a8..edccf3a9d5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,11 +5,6 @@ indent_style = tab indent_size = 2 charset = utf-8 insert_final_newline = true -end_of_line = lf -trim_trailing_whitespace = true -[*.md] -trim_trailing_whitespace = false - -[*.{yml,yaml}] +[*.yml] indent_style = space diff --git a/.forgejo/workflows/docker-develop.yml b/.forgejo/workflows/docker-develop.yml deleted file mode 100644 index 0c8338c4df..0000000000 --- a/.forgejo/workflows/docker-develop.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Publish Docker image (develop) - -on: - push: - branches: - - develop - paths: - - packages/** - - locales/** - workflow_dispatch: - -env: - REGISTRY: git.joinsharkey.org - -jobs: - push_to_registry: - name: Push Docker image to GHCR - runs-on: docker - steps: - - name: install packages - run: apt-get update && apt-get install -y wget git curl - - uses: https://code.forgejo.org/actions/setup-node@v3 - with: - node-version: 20 - - name: Install docker - run: | - echo deb http://deb.debian.org/debian bullseye-backports main | tee /etc/apt/sources.list.d/backports.list && apt-get -qq update - DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -qq -y -t bullseye-backports docker.io - - name: Check out the repo - uses: actions/checkout@v4.1.1 - - name: Set up Docker Buildx - id: buildx - uses: https://github.com/docker/setup-buildx-action@v3.0.0 - with: - platforms: linux/amd64,linux/arm64 - - name: Docker meta - id: meta - uses: https://github.com/docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/sharkey/sharkey - - name: Log in to GHCR - uses: https://github.com/docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: Marie - password: ${{ secrets.TOKEN }} - - name: Build and Push to GHCR - id: build - uses: https://github.com/docker/build-push-action@v5 - with: - builder: ${{ steps.buildx.outputs.name }} - context: . - push: true - platforms: ${{ steps.buildx.outputs.platforms }} - provenance: false - tags: ${{ env.REGISTRY }}/sharkey/sharkey:develop - labels: develop - build-args: NODE_ENV=development \ No newline at end of file diff --git a/.forgejo/workflows/docker.yml b/.forgejo/workflows/docker.yml deleted file mode 100644 index 155c5f7deb..0000000000 --- a/.forgejo/workflows/docker.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Publish Docker image - -on: - release: - types: [published] - workflow_dispatch: - -env: - REGISTRY: git.joinsharkey.org - -jobs: - push_to_registry: - name: Push Docker image to GHCR - runs-on: docker - - steps: - - name: install packages - run: apt-get update && apt-get install -y wget git curl - - uses: https://code.forgejo.org/actions/setup-node@v3 - with: - node-version: 20 - - name: Install docker - run: | - echo deb http://deb.debian.org/debian bullseye-backports main | tee /etc/apt/sources.list.d/backports.list && apt-get -qq update - DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -qq -y -t bullseye-backports docker.io - - name: Check out the repo - uses: actions/checkout@v4.1.1 - - name: Set up Docker Buildx - id: buildx - uses: https://github.com/docker/setup-buildx-action@v3.0.0 - with: - platforms: linux/amd64,linux/arm64 - - name: Docker meta - id: meta - uses: https://github.com/docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/sharkey/sharkey - tags: | - type=edge - type=ref,event=pr - type=ref,event=branch - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value=stable - - name: Log in to GHCR - uses: https://github.com/docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: Marie - password: ${{ secrets.TOKEN }} - - name: Build and Push to GHCR - id: build - uses: https://github.com/docker/build-push-action@v5 - with: - builder: ${{ steps.buildx.outputs.name }} - context: . - push: true - platforms: ${{ steps.buildx.outputs.platforms }} - provenance: false - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.forgejo/workflows/lint.yml b/.forgejo/workflows/lint.yml deleted file mode 100644 index 0a773d5fb0..0000000000 --- a/.forgejo/workflows/lint.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Lint - -on: - push: - branches: - - stable - - develop - paths: - - packages/** - pull_request: - -jobs: - pnpm_install: - runs-on: docker - steps: - - uses: actions/checkout@v4.1.1 - with: - fetch-depth: 0 - submodules: true - - uses: https://github.com/pnpm/action-setup@v2 - with: - version: 8 - run_install: false - - uses: https://code.forgejo.org/actions/setup-node@v4 - with: - node-version-file: '.node-version' - cache: 'pnpm' - - run: corepack enable - - run: pnpm i --frozen-lockfile - - lint: - needs: [pnpm_install] - runs-on: docker - continue-on-error: true - strategy: - matrix: - workspace: - - backend - - frontend - - sw - - misskey-js - steps: - - uses: actions/checkout@v4.1.1 - with: - fetch-depth: 0 - submodules: true - - uses: https://github.com/pnpm/action-setup@v2 - with: - version: 7 - run_install: false - - uses: https://code.forgejo.org/actions/setup-node@v4 - with: - node-version-file: '.node-version' - cache: 'pnpm' - - run: corepack enable - - run: pnpm i --frozen-lockfile - - run: pnpm --filter ${{ matrix.workspace }} run eslint - - typecheck: - needs: [pnpm_install] - runs-on: docker - continue-on-error: true - strategy: - matrix: - workspace: - - backend - - misskey-js - steps: - - uses: actions/checkout@v4.1.1 - with: - fetch-depth: 0 - submodules: true - - uses: https://github.com/pnpm/action-setup@v2 - with: - version: 7 - run_install: false - - uses: https://code.forgejo.org/actions/setup-node@v4 - 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' }} - - run: pnpm --filter ${{ matrix.workspace }} run typecheck diff --git a/.gitattributes b/.gitattributes index 246ecb0a60..a175917f31 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,4 +5,3 @@ *.glb -diff -text *.blend -diff -text *.afdesign -diff -text -* text=auto eol=lf diff --git a/.gitea/ISSUE_TEMPLATE/01_bug-report.yml b/.gitea/ISSUE_TEMPLATE/01_bug-report.yml deleted file mode 100644 index 6282cc43f9..0000000000 --- a/.gitea/ISSUE_TEMPLATE/01_bug-report.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: 🐛 Bug Report -description: Create a report to help us improve -title: 'bug: ' - -body: - - type: markdown - attributes: - value: | - Thanks for reporting! - First, in order to avoid duplicate Issues, please search to see if the problem you found has already been reported. - Also, If you are NOT owner/admin of server, PLEASE DONT REPORT SERVER SPECIFIC ISSUES TO HERE! (e.g. feature XXX is not working in misskey.example) Please try with another misskey servers, and if your issue is only reproducible with specific server, contact your server's owner/admin first. - - - type: textarea - attributes: - label: 💡 Summary - description: Tell us what the bug is - validations: - required: true - - - type: textarea - attributes: - label: 🥰 Expected Behavior - description: Tell us what should happen - validations: - required: true - - - type: textarea - attributes: - label: 🤬 Actual Behavior - description: | - Tell us what happens instead of the expected behavior. - Please include errors from the developer console and/or server log files if you have access to them. - validations: - required: true - - - type: textarea - attributes: - label: 📝 Steps to Reproduce - placeholder: | - 1. - 2. - 3. - validations: - required: false - - - type: textarea - attributes: - label: 💻 Frontend Environment - description: | - Tell us where on the platform it happens - DO NOT WRITE "latest". Please provide the specific version. - - Examples: - * Model and OS of the device(s): MacBook Pro (14inch, 2021), macOS Ventura 13.4 - * Browser: Chrome 113.0.5672.126 - * Server URL: misskey.io - * Misskey: 13.x.x - value: | - * Model and OS of the device(s): - * Browser: - * Server URL: - * Misskey: - render: markdown - validations: - required: false - - - type: textarea - attributes: - label: 🛰 Backend Environment (for server admin) - description: | - Tell us where on the platform it happens - DO NOT WRITE "latest". Please provide the specific version. - If you are using a managed service, put that after the version. - - Examples: - * Installation Method or Hosting Service: docker compose, k8s/docker, systemd, "Misskey install shell script", development environment - * Misskey: 13.x.x - * Node: 20.x.x - * PostgreSQL: 15.x.x - * Redis: 7.x.x - * OS and Architecture: Ubuntu 22.04.2 LTS aarch64 - value: | - * Installation Method or Hosting Service: - * Misskey: - * Node: - * PostgreSQL: - * Redis: - * OS and Architecture: - render: markdown - validations: - required: false - - - type: checkboxes - attributes: - label: Do you want to address this bug yourself? - options: - - label: Yes, I will patch the bug myself and send a pull request diff --git a/.gitea/ISSUE_TEMPLATE/02_feature-request.yml b/.gitea/ISSUE_TEMPLATE/02_feature-request.yml deleted file mode 100644 index d3bf64d869..0000000000 --- a/.gitea/ISSUE_TEMPLATE/02_feature-request.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: ✨ Feature Request -description: Suggest an idea for this project -title: 'feat: ' - -body: - - type: textarea - attributes: - label: Summary - description: Tell us what the suggestion is - validations: - required: true - - type: textarea - attributes: - label: Purpose - description: Describe the specific problem or need you think this feature will solve, and who it will help. - validations: - required: true - - type: checkboxes - attributes: - label: Do you want to implement this feature yourself? - options: - - label: Yes, I will implement this by myself and send a pull request \ No newline at end of file diff --git a/.gitea/ISSUE_TEMPLATE/config.yml b/.gitea/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index b845c1c9ac..0000000000 --- a/.gitea/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,4 +0,0 @@ -contact_links: - - name: 💬 Transfem.org Discord - url: https://discord.gg/HJcAanTR6H - about: Chat freely about Sharkey diff --git a/.gitea/pull_request_template.md b/.gitea/pull_request_template.md deleted file mode 100644 index 63eb2ab623..0000000000 --- a/.gitea/pull_request_template.md +++ /dev/null @@ -1,24 +0,0 @@ - - -## What - - - -## Why - - - -## Additional info (optional) - - - -## Checklist -- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md) -- [ ] Test working in a local environment -- [ ] (If needed) Add story of storybook -- [ ] (If needed) Update CHANGELOG.md -- [ ] (If possible) Add tests \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..c6b2a1611c --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +patreon: syuilo diff --git a/.github/ISSUE_TEMPLATE/01_bug-report.md b/.github/ISSUE_TEMPLATE/01_bug-report.md new file mode 100644 index 0000000000..0fecce2ee8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01_bug-report.md @@ -0,0 +1,42 @@ +--- +name: 🐛 Bug Report +about: Create a report to help us improve +title: '' +labels: ⚠️bug? +assignees: '' + +--- + + + +## 💡 Summary + + + +## 🥰 Expected Behavior + + + +## 🤬 Actual Behavior + + + +## 📝 Steps to Reproduce + +1. +2. +3. + +## 📌 Environment + + + +Misskey version: +Your OS: +Your browser: diff --git a/.github/ISSUE_TEMPLATE/02_feature-request.md b/.github/ISSUE_TEMPLATE/02_feature-request.md new file mode 100644 index 0000000000..5045b17712 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02_feature-request.md @@ -0,0 +1,12 @@ +--- +name: ✨ Feature Request +about: Suggest an idea for this project +title: '' +labels: ✨Feature +assignees: '' + +--- + +## Summary + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..730647b086 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,7 @@ +contact_links: + - name: 👪 Misskey Forum + url: https://forum.misskey.io/ + about: Ask questions and share knowledge + - name: 💬 Misskey official Discord + url: https://discord.gg/Wp8gVStHW3 + about: Chat freely about Misskey diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..79ca97dfa0 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ + + +# What + + + +# Why + + + +# Additional info (optional) + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..2625cf75d3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: +- package-ecosystem: npm + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 0 +- package-ecosystem: npm + directory: "/packages/backend" + schedule: + interval: daily + open-pull-requests-limit: 0 +- package-ecosystem: npm + directory: "/packages/client" + schedule: + interval: daily + open-pull-requests-limit: 0 diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000000..98f1d2e383 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,12 @@ +'⚙️Server': +- packages/backend/**/* + +'🖥️Client': +- packages/client/**/* + +'🧪Test': +- cypress/**/* +- packages/backend/test/**/* + +'‼️ wrong locales': +- any: ['locales/*.yml', '!locales/ja-JP.yml'] diff --git a/.github/misskey/test.yml b/.github/misskey/test.yml new file mode 100644 index 0000000000..cd33f8a93b --- /dev/null +++ b/.github/misskey/test.yml @@ -0,0 +1,15 @@ +url: 'http://misskey.local' + +# ローカルでテストするときにポートを被らないようにするためデフォルトのものとは変える(以下同じ) +port: 61812 + +db: + host: localhost + port: 54312 + db: test-misskey + user: postgres + pass: '' +redis: + host: localhost + port: 56312 +id: aid diff --git a/.github/workflows/docker-develop.yml b/.github/workflows/docker-develop.yml new file mode 100644 index 0000000000..09331edd1a --- /dev/null +++ b/.github/workflows/docker-develop.yml @@ -0,0 +1,33 @@ +name: Publish Docker image (develop) + +on: + push: + branches: + - develop + workflow_dispatch: + +jobs: + push_to_registry: + name: Push Docker image to Docker Hub + runs-on: ubuntu-latest + + steps: + - name: Check out the repo + uses: actions/checkout@v2 + - name: Docker meta + id: meta + uses: docker/metadata-action@v3 + with: + images: misskey/misskey + - name: Log in to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Build and Push to Docker Hub + uses: docker/build-push-action@v2 + with: + context: . + push: true + tags: misskey/misskey:develop + labels: develop diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000000..1c6ad343e3 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,32 @@ +name: Publish Docker image + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + push_to_registry: + name: Push Docker image to Docker Hub + runs-on: ubuntu-latest + + steps: + - name: Check out the repo + uses: actions/checkout@v2 + - name: Docker meta + id: meta + uses: docker/metadata-action@v3 + with: + images: misskey/misskey + - name: Log in to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Build and Push to Docker Hub + uses: docker/build-push-action@v2 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000000..fa4a58c3a9 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,16 @@ +name: "Pull Request Labeler" +on: + pull_request_target: + branches-ignore: + - 'l10n_develop' + +jobs: + triage: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v4 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000000..4e42fa9314 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,39 @@ +name: Lint + +on: + push: + branches: + - master + - develop + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + submodules: true + - uses: actions/setup-node@v3 + with: + node-version: 18.x + cache: 'yarn' + cache-dependency-path: | + packages/backend/yarn.lock + - run: yarn install + - run: yarn --cwd ./packages/backend lint + + client: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + submodules: true + - uses: actions/setup-node@v3 + with: + node-version: 18.x + cache: 'yarn' + cache-dependency-path: | + packages/client/yarn.lock + - run: yarn install + - run: yarn --cwd ./packages/client lint diff --git a/.github/workflows/ok-to-test.yml b/.github/workflows/ok-to-test.yml new file mode 100644 index 0000000000..87af3a6ba6 --- /dev/null +++ b/.github/workflows/ok-to-test.yml @@ -0,0 +1,36 @@ +# If someone with write access comments "/ok-to-test" on a pull request, emit a repository_dispatch event +name: Ok To Test + +on: + issue_comment: + types: [created] + +jobs: + ok-to-test: + runs-on: ubuntu-latest + # Only run for PRs, not issue comments + if: ${{ github.event.issue.pull_request }} + steps: + # Generate a GitHub App installation access token from an App ID and private key + # To create a new GitHub App: + # https://developer.github.com/apps/building-github-apps/creating-a-github-app/ + # See app.yml for an example app manifest + - name: Generate token + id: generate_token + uses: tibdex/github-app-token@v1 + with: + app_id: ${{ secrets.DEPLOYBOT_APP_ID }} + private_key: ${{ secrets.DEPLOYBOT_PRIVATE_KEY }} + + - name: Slash Command Dispatch + uses: peter-evans/slash-command-dispatch@v1 + env: + TOKEN: ${{ steps.generate_token.outputs.token }} + with: + token: ${{ env.TOKEN }} # GitHub App installation access token + # token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} # PAT or OAuth token will also work + reaction-token: ${{ secrets.GITHUB_TOKEN }} + issue-type: pull-request + commands: deploy + named-args: true + permission: write diff --git a/.github/workflows/pr-preview-deploy.yml b/.github/workflows/pr-preview-deploy.yml new file mode 100644 index 0000000000..fd43bce9e6 --- /dev/null +++ b/.github/workflows/pr-preview-deploy.yml @@ -0,0 +1,95 @@ +# Run secret-dependent integration tests only after /deploy approval +on: + pull_request: + types: [opened, reopened, synchronize] + repository_dispatch: + types: [deploy-command] + +name: Deploy preview environment + +jobs: + # Repo owner has commented /deploy on a (fork-based) pull request + deploy-preview-environment: + runs-on: ubuntu-latest + if: + github.event_name == 'repository_dispatch' && + github.event.client_payload.slash_command.sha != '' && + contains(github.event.client_payload.pull_request.head.sha, github.event.client_payload.slash_command.sha) + steps: + - uses: actions/github-script@v5 + id: check-id + env: + number: ${{ github.event.client_payload.pull_request.number }} + job: ${{ github.job }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + result-encoding: string + script: | + const { data: pull } = await github.rest.pulls.get({ + ...context.repo, + pull_number: process.env.number + }); + const ref = pull.head.sha; + + const { data: checks } = await github.rest.checks.listForRef({ + ...context.repo, + ref + }); + + const check = checks.check_runs.filter(c => c.name === process.env.job); + + return check[0].id; + + - uses: actions/github-script@v5 + env: + check_id: ${{ steps.check-id.outputs.result }} + details_url: ${{ github.server_url }}/${{ github.repository }}/runs/${{ github.run_id }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.checks.update({ + ...context.repo, + check_run_id: process.env.check_id, + status: 'in_progress', + details_url: process.env.details_url + }); + + # Check out merge commit + - name: Fork based /deploy checkout + uses: actions/checkout@v2 + with: + ref: 'refs/pull/${{ github.event.client_payload.pull_request.number }}/merge' + + # + - name: Context + uses: okteto/context@latest + with: + token: ${{ secrets.OKTETO_TOKEN }} + + - name: Deploy preview environment + uses: ikuradon/deploy-preview@latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + name: pr-${{ github.event.client_payload.pull_request.number }}-syuilo + timeout: 15m + + # Update check run called "integration-fork" + - uses: actions/github-script@v5 + id: update-check-run + if: ${{ always() }} + env: + # Conveniently, job.status maps to https://developer.github.com/v3/checks/runs/#update-a-check-run + conclusion: ${{ job.status }} + check_id: ${{ steps.check-id.outputs.result }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { data: result } = await github.rest.checks.update({ + ...context.repo, + check_run_id: process.env.check_id, + status: 'completed', + conclusion: process.env.conclusion + }); + + return result; diff --git a/.github/workflows/pr-preview-destroy.yml b/.github/workflows/pr-preview-destroy.yml new file mode 100644 index 0000000000..c14c3db5c5 --- /dev/null +++ b/.github/workflows/pr-preview-destroy.yml @@ -0,0 +1,21 @@ +# file: .github/workflows/preview-closed.yaml +on: + pull_request: + types: + - closed + +name: Destroy preview environment + +jobs: + destroy-preview-environment: + runs-on: ubuntu-latest + steps: + - name: Context + uses: okteto/context@latest + with: + token: ${{ secrets.OKTETO_TOKEN }} + + - name: Destroy preview environment + uses: okteto/destroy-preview@latest + with: + name: pr-${{ github.event.number }}-syuilo diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000..c32c82e2a1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,122 @@ +name: Test + +on: + push: + branches: + - master + - develop + pull_request: + +jobs: + mocha: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x] + + services: + postgres: + image: postgres:13 + ports: + - 54312:5432 + env: + POSTGRES_DB: test-misskey + POSTGRES_HOST_AUTH_METHOD: trust + redis: + image: redis:6 + ports: + - 56312:6379 + + steps: + - uses: actions/checkout@v2 + with: + submodules: true + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'yarn' + cache-dependency-path: | + packages/backend/yarn.lock + packages/client/yarn.lock + - name: Install dependencies + run: yarn install + - name: Check yarn.lock + run: git diff --exit-code yarn.lock + - name: Copy Configure + run: cp .github/misskey/test.yml .config + - name: Build + run: yarn build + - name: Test + run: yarn mocha + + e2e: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + node-version: [18.x] + browser: [chrome] + + services: + postgres: + image: postgres:13 + ports: + - 54312:5432 + env: + POSTGRES_DB: test-misskey + POSTGRES_HOST_AUTH_METHOD: trust + redis: + image: redis:6 + ports: + - 56312:6379 + + steps: + - uses: actions/checkout@v2 + with: + submodules: true + # https://github.com/cypress-io/cypress-docker-images/issues/150 + #- name: Install mplayer for FireFox + # run: sudo apt install mplayer -y + # if: ${{ matrix.browser == 'firefox' }} + #- uses: browser-actions/setup-firefox@latest + # if: ${{ matrix.browser == 'firefox' }} + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'yarn' + cache-dependency-path: | + packages/backend/yarn.lock + packages/client/yarn.lock + - name: Install dependencies + run: yarn install + - name: Check yarn.lock + run: git diff --exit-code yarn.lock + - name: Copy Configure + run: cp .github/misskey/test.yml .config + - name: Build + run: yarn build + # https://github.com/cypress-io/cypress/issues/4351#issuecomment-559489091 + - name: ALSA Env + run: echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc + - name: Cypress run + uses: cypress-io/github-action@v4 + with: + install: false + start: npm run start:test + wait-on: 'http://localhost:61812' + headless: false + browser: ${{ matrix.browser }} + - uses: actions/upload-artifact@v2 + if: failure() + with: + name: ${{ matrix.browser }}-cypress-screenshots + path: cypress/screenshots + - uses: actions/upload-artifact@v2 + if: always() + with: + name: ${{ matrix.browser }}-cypress-videos + path: cypress/videos diff --git a/.gitignore b/.gitignore index 11e69b2621..189f36370b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,20 +9,6 @@ node_modules report.*.json -# Yarn -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions -packages/frontend/.yarn/cache -packages/backend/.yarn/cache -packages/sw/.yarn/cache - -# pnpm -.pnpm-store - # Cypress cypress/screenshots cypress/videos @@ -33,10 +19,7 @@ coverage # config /.config/* !/.config/example.yml -!/.config/docker_example.yml !/.config/docker_example.env -docker-compose.yml -!/.devcontainer/docker-compose.yml # misskey /build @@ -44,7 +27,7 @@ built /data /.cache-loader /db -/meili_data +/elasticsearch npm-debug.log *.pem run.bat @@ -55,11 +38,6 @@ api-docs.json .DS_Store /files ormconfig.json -temp -/packages/frontend/src/**/*.stories.ts - -# Sharkey -/packages/megalodon/lib # blender backups *.blend1 @@ -67,6 +45,3 @@ temp *.blend3 *.blend4 *.blend5 - -# VSCode addon -.favorites.json diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index aea1307ca2..0000000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,107 +0,0 @@ -stages: - - test - - deploy - -testCommit: - stage: test - image: node:latest - services: - - postgres:15 - - redis - variables: - POSTGRES_PASSWORD: ci - script: - - apt-get update && apt-get install -y git wget curl build-essential python3 - - cp .config/ci.yml .config/default.yml - - corepack enable - - corepack prepare pnpm@latest --activate - - git submodule update --init - - pnpm install --frozen-lockfile - - pnpm run build - - pnpm run migrate - cache: - key: test - policy: pull-push - when: on_success - paths: - - node_modules/ - - packages/*/node_modules/ - only: - - develop - - stable - -getImageTag: - stage: deploy - image: ubuntu:latest - script: - - apt-get update && apt-get install -y jq - - | - if test -n "$CI_COMMIT_TAG"; then - tag="$CI_COMMIT_TAG" - elif test "$CI_COMMIT_BRANCH" == "stable"; then - tag="latest" - elif test "$CI_COMMIT_BRANCH" == "develop"; then - tag="develop" - else - tag="$CI_COMMIT_BRANCH" - fi - version=$(cat package.json | jq -r '.version') - - echo "REGISTRY_PUSH_TAG=$tag" >> build.env - - echo "REGISTRY_PUSH_VERSION=$version" >> build.env - artifacts: - reports: - dotenv: build.env - only: - - stable - - develop - - tags -buildDocker: - stage: deploy - needs: - - job: getImageTag - artifacts: true - parallel: - matrix: - - ARCH: amd64 - - ARCH: arm64 - tags: - - ${ARCH} - image: - name: gcr.io/kaniko-project/executor:debug - entrypoint: [""] - script: - - >- - /kaniko/executor - --context "${CI_PROJECT_DIR}" - --dockerfile "${CI_PROJECT_DIR}/Dockerfile" - --destination "${CI_REGISTRY_IMAGE}:${REGISTRY_PUSH_VERSION}-${ARCH}" - only: - - stable - - develop - - tags -mergeManifests: - stage: deploy - needs: - - job: buildDocker - artifacts: false - - job: getImageTag - artifacts: true - tags: - - docker - image: - name: mplatform/manifest-tool:alpine - entrypoint: [""] - script: - - >- - manifest-tool - --username=${CI_REGISTRY_USER} - --password=${CI_REGISTRY_PASSWORD} - push from-args - --platforms linux/amd64,linux/arm64 - --tags ${REGISTRY_PUSH_VERSION} - --template ${CI_REGISTRY_IMAGE}:${REGISTRY_PUSH_VERSION}-ARCH - --target ${CI_REGISTRY_IMAGE}:${REGISTRY_PUSH_TAG} - only: - - stable - - develop - - tags diff --git a/.gitmodules b/.gitmodules index 225a69a652..9246e09b8b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [submodule "misskey-assets"] path = misskey-assets url = https://github.com/misskey-dev/assets.git -[submodule "fluent-emojis"] - path = fluent-emojis - url = https://github.com/misskey-dev/emojis.git diff --git a/.node-version b/.node-version index d5a159609d..7fd023741b 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -20.10.0 +v16.15.0 diff --git a/.npmrc b/.npmrc index da702c3af5..6b5f38e890 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,2 @@ -@sharkey:registry=https://git.joinsharkey.org/api/packages/Sharkey/npm/ -engine-strict = true +save-exact = true +package-lock = false diff --git a/.vscode/extensions.json b/.vscode/extensions.json index baca8db246..42264548ea 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,11 +1,9 @@ { "recommendations": [ "editorconfig.editorconfig", + "eg2.vscode-npm-script", "dbaeumer.vscode-eslint", "Vue.volar", - "Vue.vscode-typescript-vue-plugin", - "Orta.vscode-jest", - "dbaeumer.vscode-eslint", - "mrmlnc.vscode-json5" + "Vue.vscode-typescript-vue-plugin" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index e2a82b1ffe..0000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "search.exclude": { - "**/node_modules": true - }, - "typescript.tsdk": "node_modules/typescript/lib", - "files.associations": { - "*.test.ts": "typescript" - }, - "jest.jestCommandLine": "pnpm run jest", - "jest.autoRun": "off", - "editor.codeActionsOnSave": { - "source.fixAll": "explicit" - }, - "editor.formatOnSave": false -} diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 0000000000..788570fcd5 --- /dev/null +++ b/.yarnrc @@ -0,0 +1 @@ +network-timeout 600000 diff --git a/CHANGELOG.md b/CHANGELOG.md index 30e2e57b7d..d97e34b776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,1450 +1,17 @@ -## 2023.12.2 - -### General -- v2023.12.1でDockerを利用してサーバーを起動できない問題を修正 - -### Client -- Enhance: 検索画面においてEnterキー押下で検索できるように - -## 2023.12.1 - -### Note -- アクセストークンの権限が再整理されたため、一部のAPIが古いAPIトークンでは動作しなくなりました。\ - 権限不足になる場合には権限を再設定して再生成してください。 - -### General -- Enhance: ローカリゼーションの更新 -- Fix: 自分のdirect noteがuser list timelineに追加されない - -### Client -- Feat: AiScript専用のMFM構文`$[clickable.ev=EVENTNAME ...]`を追加。`Mk:C:mfm`のオプション`onClickEv`に関数を渡すと、クリック時に`EVENTNAME`を引数にして呼び出す -- Enhance: MFM入力補助ボタンを投稿フォームに表示できるように #12787 -- Fix: 一部のモデログ(logYellowでの表示対象)について、表示の色が変わらない問題を修正 -- Fix: `fg`/`bg`MFMに長い単語を指定すると、オーバーフローされずはみ出る問題を修正 - -### Server -- Enhance: センシティブワードの設定がハッシュタグトレンドにも適用されるようになりました -- Enhance: `oauth/token`エンドポイントのCORS対応 -- Fix: 1702718871541-ffVisibility.jsのdownが壊れている -- Fix:「非センシティブのみ(リモートはいいねのみ)」を設定していても、センシティブに設定されたカスタム絵文字をリアクションできる問題を修正 -- Fix: ロールアサイン時の通知で,ロールアイコンが縮小されずに表示される問題を修正 -- Fix: サードパーティアプリケーションがWebsocket APIに無条件にアクセスできる問題を修正 -- Fix: サードパーティアプリケーションがユーザーの許可なしに非公開の情報を見ることができる問題を修正 - -## 2023.12.0 - -### Note -- 依存関係の更新に伴い、Node.js 20.10.0が最小要件になりました -- 絵文字の追加辞書を既にインストールしている場合は、お手数ですが再インストールのほどお願いします -- 絵文字ピッカーにピン留め表示する絵文字設定が「リアクション用」と「絵文字入力用」に分かれました。以前の設定は「リアクション用」として使用されます。 - - **影響:** - それにより、投稿フォームから表示される絵文字ピッカーのピン留め絵文字がリセットされたように感じるかもしれません(新設された"ピン留め(全般)"の設定が使われるため)。 - 投稿用のピン留め絵文字をアップデート前の状態にするには、以下の手順で操作します。 - - 1. 「設定」メニューに移動し、「絵文字ピッカー」タブを選択します。 - 2. 「ピン留 (全般)」のタブを選択します。 - 3. 「リアクション設定から上書きする」ボタンを押すことで、アップデート前の状態に戻すことができます。 - -### General -- Feat: メールアドレスの認証にverifymail.ioを使えるように (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/971ba07a44550f68d2ba31c62066db2d43a0caed) -- Feat: モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能を追加 (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/e0eb5a752f6e5616d6312bb7c9790302f9dbff83) -- Feat: TL上からノートが見えなくなるワードミュートであるハードミュートを追加 -- Enhance: 指定したドメインのメールアドレスの登録を弾くことができるように -- Enhance: 公開ロールにアサインされたときに通知が作成されるように -- Enhance: アイコンデコレーションを複数設定できるように -- Enhance: アイコンデコレーションの位置を微調整できるように -- Enhance: つながりの公開範囲をフォロー/フォロワーで個別に設定可能に #12072 -- Enhance: ローカリゼーションの更新 -- Enhance: 依存関係の更新 -- Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正 - -### Client -- Feat: 今日誕生日のフォロー中のユーザーを一覧表示できるウィジェットを追加 -- Feat: 画面に雪を降らせられるように -- Enhance: MFMのアニメーション要素(`tada`, `jelly`, `twitch`, `shake`, `spin`, `jump`, `bounce`, `rainbow`)に `delay` オプションを追加 -- Enhance: センシティブと判断されたウェブサイトのサムネイルを非表示に - - ウェブサイトをセンシティブと判断する仕組みが動いていないため、summalyProxyを使用しないと機能しません。 -- Enhance: 投稿フォームの絵文字ピッカーをリアクション時に使用するものと同じのを使用するように #12336 #12560 -- Enhance: リアクション用ピン留め絵文字と投稿時の絵文字入力用ピン留め絵文字を分けて設定できるように #12560 -- Enhance: 絵文字のオートコンプリート機能強化 #12364 -- Enhance: ユーザーのRawデータを表示するページが復活 -- Enhance: リアクション選択時に音を鳴らせるように -- Enhance: サウンドにドライブのファイルを使用できるように -- Enhance: ナビゲーションバーに項目「キャッシュを削除」を追加 -- Enhance: Shareページで投稿を完了すると、親ウィンドウ(親フレーム)にpostMessageするように -- Enhance: チャンネル、クリップ、ページ、Play、ギャラリーにURLのコピーボタンを設置 #11305 -- Enhance: ノートプレビューに「内容を隠す」が反映されるように -- Enhance: データセーバーでコードハイライトの読み込みを削減できるように -- Enhance: データセーバーの適用範囲を個別で設定できるように - - 従来のデータセーバーの設定はリセットされます -- Enhance: タイムライン上のタブからリスト、アンテナ、チャンネルの管理ページにジャンプできるように -- Enhance: ユーザー名、プロフィール、お知らせ、ページの編集画面でMFMや絵文字のオートコンプリートが使用できるように -- Enhance: プロフィール、お知らせの編集画面でMFMのプレビューを表示できるように -- Enhance: 絵文字の詳細ページに記載される情報を追加 -- Enhance: リアクションの表示幅制限を設定可能に -- Enhance: Unicode 15.0のサポート -- Enhance: コードブロックのハイライト機能を利用するには言語を明示的に指定させるように - - MFMでコードブロックを利用する際に意図しないハイライトが起こらないようになりました - - 逆に、MFMでコードハイライトを利用したい際は言語を明示的に指定する必要があります - (例: ` ```js ` → Javascript, ` ```ais ` → AiScript) -- Enhance: 絵文字などのオートコンプリートでShift+Tabを押すと前の候補を選択できるように -- Enhance: チャンネルに新規の投稿がある場合にバッジを表示させる -- Enhance: サウンド設定に「サウンドを出力しない」と「Misskeyがアクティブな時のみサウンドを出力する」を追加 -- Enhance: 設定したタグをトレンドに表示させないようにする項目を管理画面で設定できるように -- Enhance: 絵文字ピッカーのカテゴリに「/」を入れることでフォルダ分け表示できるように -- Fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正 -- Fix: ウィジェットのジョブキューにて音声の発音方法変更に追従できていなかったのを修正 #12367 -- Fix: コードエディタが正しく表示されない問題を修正 -- Fix: プロフィールの「ファイル」にセンシティブな画像がある際のデザインを修正 -- Fix: 一度に大量の通知が入った際に通知音が音割れする問題を修正 -- Fix: 共有機能をサポートしていないブラウザの場合は共有ボタンを非表示にする #11305 -- Fix: 通知のグルーピング設定を変更してもリロードされるまで表示が変わらない問題を修正 #12470 -- Fix: 長い名前のチャンネルにおける投稿フォームの表示が崩れる問題を修正 -- Fix: セキュリティ向上のためAiScriptの`Mk:apiExternal`を無効化 -- Fix: ノート中の絵文字をタップして「リアクションする」からリアクションした際にリアクションサウンドが鳴らない不具合を修正 -- Fix: ノート中のリアクションの表示を微調整 #12650 -- Fix: AiScriptの`readline`が不正な値を返すことがある問題を修正 -- Fix: 投票のみ/画像のみの引用RNが、通知欄でただのRNとして判定されるバグを修正 -- Fix: CWをつけて引用RNしても、普通のRNとして扱われてしまうバグを修正しました。 -- Fix: 「画像が1枚のみのメディアリストの高さ」を「デフォルト」以外に設定していると、CWの中などに添付された画像が見られないバグを修正 -- Fix: DeepL TranslationのPro accountトグルスイッチが表示されていなかったのを修正 -- Fix: twitterの埋め込みカード内リンクからリンク先を開けない問題を修正 -- Fix: WebKitブラウザー上でも「デバイスの画面を常にオンにする」機能が効くように -- Fix: ページ一覧ページの表示がモバイル環境において崩れているのを修正 -- Fix: MFMでルビの中のテキストがnyaizeされない問題を修正 - -### Server -- Enhance: MFM `$[ruby ]` が他ソフトウェアと連合されるように -- Enhance: Meilisearchを有効にした検索で、ユーザーのミュートやブロックを考慮するように -- Enhance: カスタム絵文字のインポート時の動作を改善 -- Enhance: json-schema(OpenAPIの戻り値として使用されるスキーマ定義)を出来る限り最新化 #12311 -- Fix: 時間経過により無効化されたアンテナを再有効化したとき、サーバ再起動までその状況が反映されないのを修正 #12303 -- Fix: ロールタイムラインが保存されない問題を修正 -- Fix: api.jsonの生成ロジックを改善 #12402 -- Fix: 招待コードが使い回せる問題を修正 -- Fix: 特定の条件下でチャンネルやユーザーのノート一覧に最新のノートが表示されなくなる問題を修正 -- Fix: 何もノートしていないユーザーのフィードにアクセスするとエラーになる問題を修正 -- Fix: リストタイムラインにてミュートが機能しないケースがある問題と、チャンネル投稿がストリーミングで流れてきてしまう問題を修正 #10443 -- Fix: 「みつける」のなかにミュートしたユーザが現れてしまう問題を修正 #12383 -- Fix: Social/Local/Home Timelineにてインスタンスミュートが効かない問題 -- Fix: ユーザのノート一覧にてインスタンスミュートが効かない問題 -- Fix: チャンネルのノート一覧にてインスタンスミュートが効かない問題 -- Fix: 「みつける」が年越し時に壊れる問題を修正 -- Fix: アカウントをブロックした際に、自身のユーザーのページでノートが相手に表示される問題を修正 -- Fix: モデレーションログがモデレーターは閲覧できないように修正 -- Fix: ハッシュタグのトレンド除外設定が即時に効果を持つように修正 -- Fix: HTTP Digestヘッダのアルゴリズム部分に大文字の"SHA-256"しか使えない - -## 2023.11.1 - -### Note -- 悪意のある第三者がリモートユーザーになりすました任意のアクティビティを受け取れてしまう問題を修正しました。詳しくは[GitHub security advisory](https://github.com/misskey-dev/misskey/security/advisories/GHSA-3f39-6537-3cgc)をご覧ください。 - -### General -- Feat: 管理者がコントロールパネルからメールアドレスの照会を行えるようになりました -- Enhance: ローカリゼーションの更新 -- Enhance: 依存関係の更新 - -### Client -- Enhance: MFMでルビを振れるように - - 例: `$[ruby 三須木 みすき]` -- Enhance: MFMでUNIX時間を指定して日時を表示できるように - - 例: `$[unixtime 1701356400]` -- Enhance: プラグインでエラーが発生した場合のハンドリングを強化 -- Enhance: 細かなUIのブラッシュアップ -- Fix: 効果音が再生されるとデバイスで再生している動画や音声が停止する問題を修正 #12339 -- Fix: デッキに表示されたチャンネルの表示先チャンネルを切り替えた際、即座に反映されない問題を修正 #12236 -- Fix: プラグインでノートの表示を書き換えられない問題を修正 -- Fix: アイコンデコレーションが見切れる場合がある問題を修正 -- Fix: 「フォロー中の人全員の返信を含める/含めないようにする」のボタンを押下した際の確認が機能していない問題を修正 -- Fix: 非ログイン時に「メモを追加」を表示しないように変更 #12309 -- Fix: 絵文字ピッカーでの検索が更新されない問題を修正 -- Fix: 特定の条件下でノートがnyaizeされない問題を修正 - -### Server -- Enhance: FTTのデータベースへのフォールバック処理を行うかどうかを設定可能に -- Fix: トークンのないプラグインをアンインストールするときにエラーが出ないように -- Fix: 投稿通知がオンでもダイレクト投稿はユーザーに通知されないようにされました -- Fix: ユーザタイムラインの「ノート」選択時にリノートが混ざり込んでしまうことがある問題の修正 #12306 -- Fix: LTLに特定条件下にてチャンネルへの投稿が混ざり込む現象を修正 -- Fix: ActivityPub: 追加情報のカスタム絵文字がユーザー情報のtagに含まれない問題を修正 -- Fix: ActivityPubに関するセキュリティの向上 -- Fix: 非公開の投稿に対して返信できないように - -## 2023.11.0 - -### Note -- iOS 16.4未満を使用している場合はiOS 16.4以上にアップデートをお願いします - -### General -- Feat: アイコンデコレーション機能 - - サーバーで用意された画像をアイコンに重ねることができます - - 画像のテンプレートはこちらです: https://misskey-hub.net/brand-assets/ - - 最大でも黄色いエリア内にデコレーションを収めることを推奨します。 - - 画像は512x512pxを推奨します。 -- Feat: チャンネル設定にリノート/引用リノートの可否を設定できる項目を追加 -- Enhance: アカウント登録時のメールアドレス認証に30分の有効期限を設定 - - 有効期限が切れた後であれば、登録時に使用した招待コードを再度利用できるように変更しました。 - - ユーザーが誤ったメールアドレスを入力した場合に招待コードが失効してしまう問題が解消されます。 -- Enhance: すでにフォローしたすべての人の返信をTLに追加できるように -- Enhance: 未読の通知数を表示できるように -- Enhance: 通知されず、確認の必要もないお知らせ(silence)を作成可能になりました -- Enhance: ローカリゼーションの更新 -- Enhance: 依存関係の更新 -- Change: CWを使用する場合、注釈を空にすることは許可されなくなりました - -### Client -- Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました - - 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください - https://misskey-hub.net/docs/for-developers/publish-on-your-website/ -- Feat: 通知をグルーピングして表示するオプション(オプトアウト) -- Feat: Misskeyの基本的なチュートリアルを実装 -- Feat: スワイプしてタイムラインを再読込できるように - - PCの場合は右上のボタンからでも再読込できます -- Enhance: タイムラインの自動更新を無効にできるように -- Enhance: コードのシンタックスハイライトエンジンをShikiに変更 - - AiScriptのシンタックスハイライトに対応 - - MFMでAiScriptをハイライトする場合、コードブロックの開始部分を ` ```is ` もしくは ` ```aiscript ` としてください -- Enhance: データセーバー有効時はアニメーション付きのアバター画像が停止するように -- Enhance: プラグインを削除した際には、使用されていたアクセストークンも同時に削除されるようになりました -- Enhance: プラグインで`Plugin:register_note_view_interruptor`を用いてnoteの代わりにnullを返却することでノートを非表示にできるようになりました -- Enhance: AiScript関数`Mk:nyaize()`が追加されました -- Enhance: 情報→ツール はナビゲーションバーにツールとして独立した項目になりました -- Enhance: ノート内の絵文字をクリックすることで、コピーおよびリアクションができるように -- Enhance: その他細かなブラッシュアップ -- Fix: 投稿フォームでのユーザー変更がプレビューに反映されない問題を修正 -- Fix: ユーザーページの ノート > ファイル付き タブにリプライが表示されてしまう -- Fix: 「検索」MFMにおいて一部の検索キーワードが正しく認識されない問題を修正 -- Fix: 一部の言語でMisskey Webがクラッシュする問題を修正 -- Fix: チャンネルの作成・更新時に失敗した場合何も表示されない問題を修正 #11983 -- Fix: 個人カードのemojiがバッテリーになっている問題を修正 -- Fix: 標準テーマと同じIDを使用してインストールできてしまう問題を修正 -- Fix: 絵文字ピッカーでバッテリーの絵文字が複数表示される問題を修正 #12197 -- Fix: 11以上されているリアクションにおいてツールチップで示されるリアクション数が本来よりも1多い問題を修正 #12174 -- Fix: サイレンス状態で公開範囲のパブリックを選択できてしまう問題を修正 #12224 -- Fix: In deck layout, replies option is not saved after refresh -- Fix: アーカイブしたお知らせがコントロールパネルに表示される問題を修正 -- Note: アップデート後、サウンドに関する設定が初期化されます - -### Server -- Feat: Registry APIがサードパーティから利用可能になりました -- Enhance: RedisへのTLのキャッシュ(FTT)をオフにできるように -- Enhance: フォローしているチャンネルをフォロー解除した時(またはその逆)、タイムラインに反映される間隔を改善 -- Enhance: プロフィールの自己紹介欄のMFMが連合するようになりました - - 相手がMisskey v2023.11.0以降である必要があります -- Enhance: チャンネル取得時のパフォーマンスを向上 -- Enhance: AP: ApplicationタイプのアカウントをisBotとして扱うように -- Fix: リストTLに自分のフォロワー限定投稿が含まれない問題を修正 -- Fix: ローカルタイムラインに投稿者自身の投稿への返信が含まれない問題を修正 -- Fix: 自分のフォローしているユーザーの自分のフォローしていないユーザーの visibility: followers な投稿への返信がストリーミングで流れてくる問題を修正 -- Fix: RedisへのTLキャッシュが有効の場合にHTL/LTL/STLが空になることがある問題を修正 -- Fix: STLでフォローしていないチャンネルが取得される問題を修正 -- Fix: `hashtags/trend`にてRedisからトレンドの情報が取得できない際にInternal Server Errorになる問題を修正 -- Fix: HTLをリロードまたは遡行したとき、フォローしているチャンネルのノートが含まれない問題を修正 #11765 #12181 -- Fix: リノートをリノートできるのを修正 -- Fix: アクセストークンを削除すると、通知が取得できなくなる場合がある問題を修正 -- Fix: 自身の宛先なしダイレクト投稿がストリーミングで流れてこない問題を修正 -- Fix: サーバーサイドからのテスト通知を正しく行えるように修正 -- Fix: GTLの「リノートを表示」オプションが機能しないのを修正 #12233 - -## 2023.10.2 - -### General -- Feat: アンテナでローカルの投稿のみ収集できるようになりました -- Feat: サーバーサイレンス機能が追加されました -- Enhance: 新規にフォローした人の返信をデフォルトでTLに追加できるオプションを追加 -- Enhance: HTL/LTL/STLを2023.10.0アップデート以前まで遡れるように -- Enhance: フォロー/フォロー解除したときに過去分のHTLにも含まれる投稿が反映されるように -- Enhance: ローカリゼーションの更新 -- Enhance: 依存関係の更新 - -### Client -- Enhance: TLの返信表示オプションを記憶するように -- Enhance: 投稿されてから時間が経過しているノートであることを視覚的に分かりやすく - -### Server -- Enhance: タイムライン取得時のパフォーマンスを向上 -- Enhance: ストリーミングAPIのパフォーマンスを向上 -- Fix: users/notesでDBから参照した際にチャンネル投稿のみ取得される問題を修正 -- Fix: コントロールパネルの設定項目が正しく保存できない問題を修正 -- Fix: 管理者権限のロールを持っていても一部のAPIが使用できないことがある問題を修正 -- Change: ユーザーのisCatがtrueでも、サーバーではnyaizeが行われなくなりました - - isCatな場合、クライアントでnyaize処理を行うことを推奨します - -## 2023.10.1 -### General -- Enhance: ローカルタイムライン、ソーシャルタイムラインで返信を含むかどうか設定可能に - -### Client -- Fix: 絵文字ピッカーで横に長いカスタム絵文字が見切れる問題を修正 - -### Server -- Fix: フォローしているユーザーからの自分の投稿への返信がタイムラインに含まれない問題を修正 -- Fix: users/notesでセンシティブチャンネルの投稿が含まれる場合がある問題を修正 - -## 2023.10.0 -### NOTE -- 2023.9.2で導入されたノート編集機能はクオリティの高い実装が困難であることが判明したため撤回されました -- アップデートを行うと、タイムラインが一時的にリセットされます - - アンテナ内のノートも含む -- ソフトミュート設定はクライアントではなくサーバー側に保存されるようになったため、アップデートを行うとソフトミュートの設定がリセットされます - -### Changes -- API: users/notes, notes/local-timeline で fileType 指定はできなくなりました -- API: notes/featured でページネーションは他APIと同様 untilId を使って行うようになりました - -### General -- Feat: ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました -- Feat: ユーザーリスト内のメンバーごとに他ユーザーへの返信をユーザーリストタイムラインに含めるか設定可能になりました -- Feat: ユーザーごとのハイライト -- Feat: プライバシーポリシー・運営者情報(Impressum)の指定が可能になりました - - プライバシーポリシーはサーバー登録時に同意確認が入ります -- Feat: タイムラインがリアルタイム更新中に広告を挿入できるようになりました - - デフォルトは無効 - - 頻度はコントロールパネルから設定できます。運営中のサーバーのTLの流速を見て、最適な値を指定してください。 -- Enhance: ソフトワードミュートとハードワードミュートは統合されました -- Enhance: モデレーションログ機能の強化 -- Enhance: ローカリゼーションの更新 -- Enhance: 依存関係の更新 -- Fix: ダイレクト投稿をリノートできてしまう問題を修正 -- Fix: ユーザーリストTLにチャンネル投稿が含まれる問題を修正 - -### Client -- Feat: 「ファイルの詳細」ページを追加 - - ドライブのファイルの拡大プレビューができるように - - ファイルが添付されたノートの一覧が表示できるように -- Enhance: 二要素認証のバックアップコード一覧をテキストファイルでダウンロード可能に -- Enhance: 動画再生時のデフォルトボリュームを30%に -- Fix: リアクションしたユーザ一覧のUIが稀に左上に残ってしまう不具合を修正 - -### Server -- Enhance: drive/files/attached-notes がページネーションに対応しました -- Enhance: タイムライン取得時のパフォーマンスを大幅に向上 -- Enhance: ハイライト取得時のパフォーマンスを大幅に向上 -- Enhance: トレンドハッシュタグ取得時のパフォーマンスを大幅に向上 -- Enhance: WebSocket接続が多い場合のパフォーマンスを向上 -- Enhance: 不要なPostgreSQLのインデックスを削除しパフォーマンスを向上 -- Fix: 連合なしアンケートに投票をするとUpdateがリモートに配信されてしまうのを修正 -- Fix: nodeinfoにおいてCORS用のヘッダーが設定されていないのを修正 -- Fix: 同じ種類のTLのストリーミングを複数接続できない問題を修正 -- Fix: アンテナTLを途中までしかページネーションできなくなることがある問題を修正 -- Fix: 「ファイル付きのみ」のTLでファイル無しの新着ノートが流れる問題を修正 -- Fix: プロセスが終了しない、あるいは非常に時間がかかる問題を修正 - -## 2023.9.3 -### General -- Enhance: ノートの翻訳機能の利用可否をロールで設定可能に - -### Client -- Enhance: AiScriptでホストのアドレスを参照する定数`SERVER_URL`を追加 -- Enhance: モデレーションログ機能の強化 -- Enhance: ローカリゼーションの更新 - -### Server -- Fix: Redisに古いバージョンのキャッシュが残っている場合、キャッシュが消えるまでの間通知が届かなくなる問題を修正 -- Fix: 後方互換性の修正 - -## 2023.9.2 - -### General -- Feat: ノートの編集をできるように - - ロールで編集可否を設定可能 -- Feat: 通知を種類ごとに 全員から受け取る/フォロー中のユーザーのみ受け取る/フォロワーのみ受け取る/相互のみ受け取る/指定したリストのメンバーのみ受け取る/受け取らない から選べるように -- Enhance: タイムラインからRenoteを除外するオプションを追加 -- Enhance: ユーザーページのノート一覧でRenoteを除外できるように -- Enhance: タイムラインでファイルが添付されたノートのみ表示するオプションを追加 -- Enhance: モデレーションログ機能の強化 -- Enhance: 依存関係の更新 -- Enhance: ローカリゼーションの更新 - -### Client -- Enhance: Plugin:register_post_form_actionを用いてCWを取得・変更できるように -- Enhance: admin/ad/listにて掲載中の広告が絞り込めるように -- Enhance: AiScriptにリモートサーバーのAPIを叩く用の関数を追加(`Mk:apiExternal`) - -### Server -- Enhance: MasterプロセスのPIDを書き出せるように -- Enhance: admin/ad/createにてレスポンス200、設定した広告情報を返すように - -## 2023.9.1 - -### General -- Enhance: モデレーションログ機能の強化 - -### Client -- Fix: ノートのメニューにある「詳細」ボタンの表示がログイン/ログアウト状態で統一されていない問題を修正 - -### Server -- Fix: お知らせのページネーションが機能しない -- Fix: 「ユーザーの新規投稿」の通知設定を切り替えるとサーバー内部エラーが出る - -## 2023.9.0 - -### Note -- meilisearchを使用する場合、v1.2以上が必要です - -### General -- Feat: OAuth 2.0のサポート -- Feat: お知らせ機能の強化 - - ユーザー個別のお知らせを作成可能に - - お知らせのバナー表示やダイアログ表示が可能に - - お知らせのアイコンを設定可能に -- Feat: チャンネルをセンシティブ指定できるようになりました - - センシティブチャンネルのNoteのReNoteはデフォルトでHome TLに流れるようになりました - - センシティブチャンネルのノートはユーザープロフィールに表示されません -- Feat: 二要素認証のバックアップコードが生成されるようになりました - - ref. https://github.com/MisskeyIO/misskey/pull/121 -- Feat: 二要素認証でパスキーをサポートするようになりました -- Feat: 指定したユーザーが投稿したときに通知できるようになりました -- Feat: プロフィールでのリンク検証 -- Feat: モデレーションログ機能 -- Feat: 通知をテストできるようになりました -- Feat: PWAのアイコンが設定できるようになりました -- Enhance: サーバー名の略称が設定できるようになりました -- Enhance: アンテナの受信ソースに指定したユーザを除外するものを追加 -- Enhance: 二要素認証設定時のセキュリティを強化 - - パスワード入力が必要な操作を行う際、二要素認証が有効であれば確認コードの入力も必要になりました -- Enhance: manifest.jsonをオーバーライド可能に -- Enhance: 依存関係の更新 -- Enhance: ローカリゼーションの更新 - -### Client -- Feat: 任意のユーザーリストをタイムラインページにピン留めできるように - - 設定->クライアント設定->全般 から設定可能です -- Feat: Playで直接投稿フォームを埋め込めるように(`Ui:C:postForm`) -- Feat: クライアントを起動している間、デバイスの画面が自動でオフになるのを防ぐオプションを追加 -- Feat: 新しい実績を追加 -- Enhance: ノート詳細ページでリノート一覧、リアクション一覧タブを追加 - - ノートのメニューからは当該項目は消えました -- Enhance: センシティブなメディアを目立たせる設定を追加 -- Enhance: プロフィールにその人が作ったPlayの一覧出せるように -- Enhance: メニューのスイッチの動作を改善 -- Enhance: 絵文字ピッカーの検索の表示件数を100件に増加 -- Enhance: 投稿フォームのプレビューの表示状態を記憶するように -- Enhance: ユーザーメニューでスイッチでユーザーリストに追加・削除できるように -- Enhance: 自分が押したリアクションのデザインを改善 -- Enhance: ノート検索にローカルのみ検索可能なオプションの追加 -- Enhance: Renote自体を通報できるように -- Enhance: データセーバーモードの強化 -- Enhance: Renoteを管理者権限で削除可能に -- Enhance: `$[rainbow ]`記法が、動きのあるMFMが無効になっていても使用できるようになりました -- Enhance: Playの操作を行うAPI TokenをAPIコンソールから発行できるように -- Enhance: リアクションの表示サイズをより大きくできるように -- Enhance: AiScriptを0.16.0に更新 -- Enhance: AiScriptからMisskeyサーバーAPIを呼び出す際の制限を撤廃 -- Enhance: AiScriptで`LOCALE`として現在の設定言語を取得できるように -- Enhance: Mk:apiが失敗した時にエラー型の値(AiScript 0.16.0で追加)を返すように -- Enhance: ScratchpadでAsync:系関数やボタンのコールバックなどのエラーにもダイアログを出すように(試験的なためPlayなどには未実装) -- Enhance: ノート詳細ページ読み込み時のパフォーマンスが向上しました -- Enhance: タイムラインでリスト/アンテナ選択時のパフォーマンスを改善 -- Enhance: 「Moderation note」、「Add moderation note」をローカライズできるように -- Enhance: プラグインのソースコードを確認・コピーできるように -- Enhance: 細かなデザインの調整 -- Fix: サーバー情報画面(`/instance-info/{domain}`)でブロックができないのを修正 -- Fix: 未読のお知らせの「わかった」をクリック・タップしてもその場で「わかった」が消えない問題を修正 -- Fix: iOSで画面を回転させるとテキストサイズが変わる問題を修正 -- Fix: word mute for sub note is not applied -- Fix: タイムラインを下にスクロールしてノート画面に移動して再び戻ったら以前のスクロール位置を失う問題を修正 -- Fix: Misskeyプラグインをインストールする際のAiScriptバージョンのチェックが0.14.0以降に対応していない問題を修正 -- Fix: 他のサーバーのユーザーへ「メッセージを送信」した時の初期テキストのメンションが間違っている問題を修正 -- Fix: 環境によってはMisskey Webが開けない問題を修正 -- Fix: プラグインの権限リストが見れない問題を修正 -- Fix: 複数の階層があるメニューで、短くタップすると正常に動かない場合がある問題を修正 -- Fix: アニメーションがオフのとき、スマホで子メニューの選択ができない問題を修正 -- Fix: ドロワーメニューで、親メニュー項目をマウスでホバーすると子メニューが表示されてしまう問題を修正 -- Fix: AiScriptでMk:apiが外部と通信できる問題を修正 - -### Server -- Change: cacheRemoteFilesの初期値はfalseになりました -- Enhance: ファイルアップロード時等にファイル名の拡張子を修正する関数(correctFilename)の挙動を改善 -- Enhance: Webhookのペイロードにサーバーのurlが含まれるようになりました -- Enhance: Webhook設定でsecretを空に出来るように -- Enhance: 使われていないアンテナの自動停止を設定可能に -- Enhance: nodeinfo 2.1対応 -- Enhance: 自分へのメンション一覧を取得する際のパフォーマンスを向上 -- Enhance: Docker環境でjemallocを使用することでメモリ使用量を削減 -- Enhance: ID生成方式としてaidxを追加、かつデフォルトに -- Enhance: Add address bind config option (outgoingAddress) -- Fix: MK_ONLY_SERVERオプションを指定した際にクラッシュする問題を修正 -- Fix: notes/reactionsのページネーションが機能しない問題を修正 -- Fix: ノート検索 `notes/search` にてhostを指定した際に検索結果に反映されるように -- Fix: 一部のfeatured noteを照会できない問題を修正 -- Fix: muteがapiからのuser list timeline取得で機能しない問題を修正 -- Fix: ジョブキュー管理画面の認証を回避できる問題を修正 -- Fix: 一部のサーバー内部エラーがスタックトレースを返さないように修正 -- Fix: 一部のリモートユーザーをフォローすることができない問題を修正 - -## 13.14.2 - -### Client -- リストTLで、ユーザーが追加・削除されてもTLを初期化しないように -- URL取得変数を関数に変更 CURRENT_URL -> Mk:url() -- Fix: モバイル表示のときページ下部がナビゲーションバーに隠れる問題を修正 -- Fix: 一部モーダルダイアログでスクロールできない問題を修正 -- Fix: Selecting all emojis in Custom emoji is impossible -- Fix: PhotoSwipeによるメモリリークの修正 - -### Server -- Fix: APIのオフセットが壊れていたせいで「もっと見る」でもっと見れない問題を修正 -- Fix: 外部サーバーの投稿がタイムラインに表示されないことがある問題を修正 - -## 13.14.1 - -### General -- 招待機能を改善しました - * 過去に発行した招待コードを確認できるようになりました - * ロールごとに招待コードの発行数制限と制限対象期間、有効期限を設定できるようになりました - * 招待コードを作成したユーザーと使用したユーザーを確認できるようになりました -- ユーザーにロールが期限付きでアサインされている場合、その期限をユーザーのモデレーションページで確認できるようになりました -- identicon生成を無効にしてパフォーマンスを向上させることができるようになりました -- サーバーのマシン情報の公開を無効にしてパフォーマンスを向上させることができるようになりました - -### Client -- deck UIのカラムのメニューからアンテナとリストの編集画面を開けるように -- ドライブファイルのメニューで画像をクロップできるように -- 画像を動画と同様に簡単に隠せるように -- Enhance: ノートの埋め込みが複数画像と動画を表示されるように -- オリジナル画像を保持せずにアップロードする場合webpでアップロードされるように(Safari以外) -- 見たことのあるRenoteを省略して表示をオンのときに自分のnoteのrenoteを省略するように -- フォルダーやファイルに対しても開発者モード使用時、IDをコピーできるように -- 引用対象を「もっと見る」で展開した場合、「閉じる」で畳めるように -- プロフィールURLをコピーできるボタンを追加 #11190 -- `CURRENT_URL`で現在表示中のURLを取得できるように(AiScript) -- ユーザーのContextMenuに「アンテナに追加」ボタンを追加 -- フォローやお気に入り登録をしていないチャンネルを開く時は概要ページを開くように -- 画面ビューワをタップした場合、マウスクリックと同様に画像ビューワを閉じるように -- オフライン時の画面にリロードボタンを追加 -- Renote時に公開範囲のデフォルト設定が適用されるように -- Deckで非ルートページにアクセスした際に簡易UIで表示しない設定を追加 -- ロール設定画面でロールIDを確認できるように -- コンテキストメニュー表示時のパフォーマンスを改善 -- フォロー/フォロワー非公開時の表示を改善 -- 本文にMFMが含まれている場合に自動でたたまれる機能が、返信先や引用RNにも適用されるように - - position は対象外になりました -- AiScriptを0.15.0に更新 -- Fix: サーバーメトリクスが90度傾いている -- Fix: 非ログイン時にクレデンシャルが必要なページに行くとエラーが出る問題を修正 -- Fix: sparkle内にリンクを入れるとクリック不能になる問題の修正 -- Fix: ZenUIでポップアップの表示位置がおかしい問題を修正 -- Fix: ページ遷移でスクロール位置が保持されない問題を修正 -- Fix: フォルダーのページネーションが機能しない #11180 -- Fix: 長い文章を投稿する際、プレビューが画面からはみ出る問題を修正 -- Fix: システムフォント設定が正しく反映されない問題を修正 -- Fix: アンケート終了時のプッシュ通知が正しく表示されない問題を修正 -- Fix: MasterVolumeが0の時だけでなく各通知音の音量設定が0のときも、HTMLAudioElement.playが実行されないように変更 - -### Server -- JSON.parse の回数を削減することで、ストリーミングのパフォーマンスを向上しました -- nsfwjs のモデルロードを排他することで、重複ロードによってメモリ使用量が増加しないように -- 連合の配送ジョブのパフォーマンスを向上(ロック機構の見直し、Redisキャッシュの活用) -- featuredノートのsignedGet回数を減らしました -- ActivityPubの署名用鍵長を2048bitに変更しパフォーマンスを向上(新規アカウントのみ) -- リモートサーバーのセンシティブなファイルのキャッシュだけを無効化できるオプションを追加 -- MeilisearchにIndexするノートの範囲を設定できるように -- Export notes with file detail -- Add unix socket support -- 設定ファイルでioredisの全てのオプションを指定可能に -- Fix: エクスポートしたカスタム絵文字のzipが大きいと読み込めない問題を修正 -- Fix: リモートサーバーに無意味なActivityPubの配信を行うことがあるのを修正 -- Fix: Remove Meilisearch index when notes are deleted -- Fix: 非英語環境でのPostgreSQLのエラーハンドリングを修正 -- Fix: インスタンスのアイコンがbase64の場合の挙動を修正 -- Fix: ローカルの `Person` を指す `acct` URI を解析するときのバグを修正しました -- Fix: 無効化されたアンテナが再度有効化されないことがある問題を修正 - -## 13.13.2 - -### General -- エラー時や項目が存在しないときなどのアイコン画像をサーバー管理者が設定できるように -- ロールが付与されているユーザーリストを非公開にできるように -- サーバーの負荷が非常に高いため、ユーザー統計表示機能を削除しました - -### Client -- Fix: タブがバックグラウンドでもstreamが切断されないように - -### Server -- Fix: キャッシュが溜まり続けないように - -## 13.13.1 - -### Client -- Fix: タブがアクティブな間はstreamが切断されないように - -### Server -- Fix: api/metaで`TypeError: JSON5.parse is not a function`エラーが発生する問題を修正 - -## 13.13.0 - -### General -- カスタム絵文字ごとにそれをリアクションとして使えるロールを設定できるように -- カスタム絵文字ごとに連合するかどうか設定できるように -- カスタム絵文字ごとにセンシティブフラグを設定できるように -- センシティブなカスタム絵文字のリアクションを受け入れない設定が可能に -- タイムラインにフォロイーの行った他人へのリプライを含めるかどうかの設定をアカウントに保存するのをやめるように - - 今後はAPI呼び出し時およびストリーミング接続時に設定するようになります -- リストを公開できるようになりました - -### Client -- リアクションの取り消し/変更時に確認ダイアログを出すように -- 開発者モードを追加 -- AiScriptを0.13.3に更新 -- Deck UIを使用している場合、`/`以外にアクセスした際にZen UIで表示するように - - メインカラムを設置していない場合の問題を解決 -- ハッシュタグのノート一覧ページから、そのハッシュタグで投稿するボタンを追加 -- アカウント初期設定ウィザードに戻るボタンを追加 -- アカウントの初期設定ウィザードにあとでボタンを追加 -- サーバーにカスタム絵文字の種類が多い場合のパフォーマンスの改善 -- Fix: URLプレビューで情報が取得できなかった際の挙動を修正 -- Fix: Safari、Firefoxでの新規登録時、パスワードマネージャーにメールアドレスが登録されていた挙動を修正 -- Fix: ロールタイムラインが無効でも投稿が流れてしまう問題の修正 -- Fix: ロールタイムラインにて全ての投稿が流れてしまう問題の修正 -- Fix: 「アクセストークンの管理」画面でアプリの情報が表示されない問題の修正 -- Fix: Firefoxにおける絵文字ピッカーのTabキーフォーカス問題の修正 -- Fix: フォローボタンがテーマのカラースキームによって視認性が悪くなる問題を修正 - - 新しいプロパティ `fgOnWhite` が追加されました - -### Server -- bullをbull-mqにアップグレードし、ジョブキューのパフォーマンスを改善 -- ストリーミングのパフォーマンスを改善 -- Fix: 無効化されたアンテナにアクセスがあった際に再度有効化するように -- Fix: お知らせの画像URLを空にできない問題を修正 -- Fix: i/notificationsのsinceIdが機能しない問題を修正 -- Fix: pageのピン留めを解除することができない問題を修正 - -## 13.12.2 - -## NOTE -Meilisearchの設定に`index`が必要になりました。値はMisskeyサーバーのホスト名にすることをお勧めします(アルファベット、ハイフン、アンダーバーのみ使用可能)。例: `misskey-io` -過去に作成された`notes`インデックスは、`---notes`にリネームが必要です。例: `misskey-io---notes` - -### General -- 投稿したコンテンツのAIによる学習を軽減するオプションを追加 - -### Client -- ユーザーを指定してのノート検索が可能に -- アカウント初期設定ウィザードにプライバシー設定を追加 -- リテンション率チャートに折れ線グラフを追加 -- Fix: ブラーエフェクトを有効にしている状態で高負荷になる問題を修正 -- Fix: Pageにおいて画像ブロックに画像を設定できない問題を修正 -- Fix: カラーバーがリプライには表示されないのを修正 -- Fix: チャンネル内の検索ボックスが挙動不審な問題を修正 -- Fix: リテンションチャートのレンダリングを修正 -- Fix: リアクションエフェクトのレンダリングの問題を修正 - -### Server -- センシティブワードの登録にAnd、正規表現が使用できるようになりました。 -- Fix: ひとつのMeilisearchサーバーを複数のMisskeyサーバーで使えない問題を修正 - -## 13.12.1 - -### Client -- プロフィール画面におけるモデレーションノートの表示を調整 -- Fix: 一部ダイアログが表示されない問題を修正 -- Fix: MkUserInfoのフォローボタンが変な位置にある問題を修正 - -### Server -- Fix: リモートサーバーの情報が更新できない問題を修正 -- Fix: 13.11を経験しない状態で13.12にアップデートした場合ユーザープロフィール関連の画像が消失する問題を修正 - -## 13.12.0 - -### NOTE -- Node.js 18.16.0以上が必要になりました - -### General -- アカウントの引っ越し(フォロワー引き継ぎ)に対応 -- Meilisearchを全文検索に使用できるようになりました - * 「フォロワーのみ」の投稿は検索結果に表示されません。 -- 新規登録前に簡潔なルールをユーザーに表示できる、サーバールール機能を追加 -- ユーザーへの自分用メモ機能 - * ユーザーに対して、自分だけが見られるメモを追加できるようになりました。 - (自分自身に対してもメモを追加できます。) - * ユーザーメニューから追加できます。 - (デスクトップ表示ではusernameの右側のボタンからも追加可能) -- チャンネルに色を設定できるようになりました。各ノートに設定した色のインジケーターが表示されます。 -- チャンネルをアーカイブできるようになりました。 - * アーカイブすると、チャンネル一覧や検索結果に表示されなくなり、新たな書き込みもできなくなります。 -- アンテナのエクスポート・インポートができるようになりました -- ロールタイムラインをロールごとに表示するかどうかの選択できるようになりました。 - * デフォルトがオフになるので、ロールタイムラインを表示する場合はオンにしてください。 -- ロールに強制的にNSFWを付与するポリシーを追加 - * アップロード済みのファイルはNSFWにならない為注意してください。 -- モデレーションノートがユーザーのプロフィールページからも閲覧および編集できるようになりました。 -- カスタム絵文字のライセンスを複数でセットできるようになりました。 -- 管理者が予約ユーザー名を設定できるようになりました。 -- Fix: フォローリクエストの通知が残る問題を修正 - -### Client -- アカウント作成時に初期設定ウィザードを表示するように -- チャンネル内検索ができるように -- チャンネル検索ですべてのチャンネルの取得/表示ができるように -- 通知の表示をカスタマイズできるように -- ドライブのファイル一覧から直接ノートを作成できるように -- ノートメニューからRenoteしたユーザーの一覧を見れるように -- コントロールパネルのカスタム絵文字ページおよびaboutのカスタム絵文字の検索インプットで、`:emojiname1::emojiname2:`のように検索して絵文字を検索できるように - * 絵文字ピッカーから入力可能になります -- データセーバーモードを追加 - * 画像が全て隠れた状態で表示されるようになります -- 閲覧注意設定された画像は表示した状態でもそれが閲覧注意だと分かる表示をするように -- モデレーターはノートに添付された画像上から直接NSFW設定できるように -- 1枚だけのメディアリストの画像のアスペクト比を画像に応じて縦長にするように -- プロフィール設定「追加情報」の項目の削除と並び替えができるように -- 新しい実績を追加 -- AiScriptを0.13.2に更新 -- Fix: AiScript APIのMk:dialogで何も返していなかったのをNULLを返すように修正 -- Fix: 1:1ではない画像のリアクション通知バッジが左や上に寄ってしまっていたのを中央に来るように修正 -- Fix: リアクションをホバーした時のユーザーリストで猫耳が切れてしまっていた問題を修正 -- Fix: NSFWメディアの上に表示された「もっと見る」ボタンが押しづらい問題を修正 - -### Server -- channel/searchのqueryが空の場合に全てのチャンネルを返すように変更 -- 環境変数MISSKEY_CONFIG_YMLで設定ファイルをdefault.ymlから変更可能に -- Fix: 他のサーバーの情報が取得できないことがある問題を修正 -- Fix: エクスポートデータの拡張子がunknownになる問題を修正 -- Fix: Content-Dispositionのパースでエラーが発生した場合にダウンロードが完了しない問題を修正 -- Fix: API: i/update avatarIdとbannerIdにnullを渡した時、画像がリセットされない問題を修正 -- Fix: .wav, .flacが再生できない問題を修正(新しくアップロードされたファイルのみ修正が適用されます) -- Fix: 凍結されたユーザーが一部APIのレスポンスに含まれる問題を修正 -- Fix: メモリの使用量を`used - buffers - cached`ではなく`total - available`で求めるように(環境によって正常に計測できていなかったため) - -## 13.11.3 - -### General -- 指定したロールを持つユーザーのノートのみが流れるロールタイムラインを追加 - - Deckのカラムとしても追加可能 -- カスタム絵文字関連の改善 - * ノートなどに含まれるemojis(populateEmojiの結果)は(プロキシされたURLではなく)オリジナルのURLを指すように - * MFMでx3/x4もしくはscale.x/yが2.5以上に指定されていた場合にはオリジナル品質の絵文字を使用するように -- カスタム絵文字でリアクションできないことがある問題を修正 - -### Client -- チャンネルのピン留めされたノートの順番が正しくない問題を修正 - -### Server -- フォローインポートなどでの大量のフォロー等操作をキューイングするように #10544 @nmkj-io -- Misskey Webでのサーバーサイドエラー画面を改善 -- Misskey Webでのサーバーサイドエラーのログが残るように -- ノート作成時のアンテナ追加パフォーマンスを改善 -- アンテナとロールTLのuntil/sinceプロパティが動くように - -## 13.11.2 - -### Note -- 13.11.0または13.11.1から13.11.2以降にアップデートする場合、Redisのカスタム絵文字のキャッシュを削除する必要があります(https://github.com/misskey-dev/misskey/issues/10502#issuecomment-1502790755 参照) - -### General -- チャンネルの検索用ページの追加 - -### Client -- 常に広告を見られるオプションを追加 -- ユーザーページの画像一覧が表示されない問題を修正 -- webhook, 連携アプリ一覧でコンテンツが重複して表示される問題を修正 -- iPhoneで絵文字ピッカーの表示が崩れる問題を修正 -- iPhoneでウィジェットドロワーの「ウィジェットを編集」が押しにくい問題を修正 -- 投稿フォームのデザインを調整 -- ギャラリーの人気の投稿が無限にページングされる問題を修正 - -### Server -- channels/search Endpoint APIの追加 -- APIパラメータサイズ上限を32kbから1mbに緩和 -- プッシュ通知送信時のパフォーマンスを改善 -- ローカルのカスタム絵文字のキャッシュが効いていなかった問題を修正 -- アンテナのノート、チャンネルのノート、通知が正常に作成できないことがある問題を修正 -- ストリーミングのLTLチャンネルでサーバー側にエラーログが出るのを修正 - -### Service Worker -- 「通知が既読になったらプッシュ通知を削除する」を復活 - * 「プッシュ通知が更新されました」の挙動を変えた(ホストとバージョンを表示するようにし、一定時間後の削除は行わないように) -- プッシュ通知が実績を解除 (achievementEarned) に対応 -- プッシュ通知のアクションから既存のクライアントの投稿フォームを開くことになった際の挙動を修正 -- たくさんのプッシュ通知を閉じた際、その通知の数だけnotifications/mark-all-as-readを叩くのをやめるように - -## 13.11.1 - -### General -- チャンネルの投稿を過去までさかのぼれるように - -### Client -- PWA時の絵文字ピッカーの位置をホームバーに重ならないように調整 -- リスト管理の画面でリストが無限に読み込まれる問題を修正 -- 自分のクリップが無限に読み込まれる問題を修正 -- チャンネルのお気に入りが無限に読み込まれる問題を修正 -- さがすのローカルユーザー(ピンどめ)が無限に生成される問題を修正 -- チャンネルを新規作成できない問題を修正 -- ユーザープレビューが表示されない問題を修正 - -### Server -- 通知読み込みでエラーが発生する場合がある問題を修正 -- リアクションできないことがある問題を修正 -- IDをaid以外に設定している場合の問題を修正 -- 連合しているインスタンスについて予期せず配送が全て停止されることがある問題を修正 - -## 13.11.0 - -### NOTE -- このバージョンからRedis 7.xが必要です。 -- アップデートを行うと全ての通知およびアンテナのノートはリセットされます。 - -### General -- チャンネルをお気に入りに登録できるように - - タイムラインのアンテナ選択などでは、フォローしているアンテナの代わりにお気に入りしたアンテナが表示されるようになっています。チャンネルをお気に入りに登録するには、当該チャンネルのページ→概要→⭐️のボタンを押します。 -- チャンネルにノートをピン留めできるように - -### Client -- 投稿フォームのデザインを改善 -- 検索ページでURLを入力した際に照会したときと同等の挙動をするように -- ノートのリアクションを大きく表示するオプションを追加 -- ギャラリー一覧にメディア表示と同じように NSFW 設定を反映するように(ホバーで表示) -- オブジェクトストレージの設定画面を分かりやすく -- 広告・お知らせが新規登録時に増殖しないように -- 「にゃああああああああああああああ!!!!!!!!!!!!」 (`isCat`) 有効時にアバターに表示される猫耳について挙動を変更 - - 「UIにぼかし効果を使用」 (`useBlurEffect`) で次の挙動が有効になります - - 猫耳のアバター内部部分をぼかしでマスク表示してより猫耳っぽく見えるように - - 「UIのアニメーションを減らす」 (`reduceAnimation`) で猫耳を撫でられなくなります -- Add Minimizing ("folding") of windows -- 「データセーバー」モードを追加 -- 非NSFWメディアが隠れている際にも「閲覧注意」が出てしまう問題を修正 - -### Server -- PostgreSQLのレプリケーション対応 - - 設定ファイルの `dbReplications` および `dbSlaves` にて設定できます -- イベント用Redisを別サーバーに分離できるように -- ジョブキュー用Redisを別サーバーに分離できるように -- サーバーの全体的なパフォーマンスを向上 -- ノート作成時のパフォーマンスを向上 -- アンテナのタイムライン取得時のパフォーマンスを向上 -- チャンネルのタイムライン取得時のパフォーマンスを向上 -- 通知に関する全体的なパフォーマンスを向上 -- webhookがcontent-type text/plain;charset=UTF-8 で飛んでくる問題を修正 - -## 13.10.3 - -### Changes -- オブジェクトストレージのリージョン指定が必須になりました - - リージョンの指定の無いサービスは us-east-1 を設定してください - - 値が空の場合は設定ファイルまたは環境変数の使用を試みます - - e.g. ~/aws/config, AWS_REGION - -### General -- コンディショナルロールの条件に「投稿数が~以下」「投稿数が~以上」を追加 -- リアクション非対応AP実装からのLikeアクティビティの解釈を👍から♥に - -### Client -- クリップボタンをノートアクションに追加できるように -- センシティブワードの一覧にピン留めユーザーのIDが表示される問題を修正 - -### Server -- リモートユーザーのチャート生成を無効にするオプションを追加 -- リモートサーバーのチャート生成を無効にするオプションを追加 -- ドライブのチャートはローカルユーザーのみ生成するように -- 空のアンテナが作成できるのを修正 - -## 13.10.2 - -### Server -- 絵文字を編集すると保存できないことがある問題を修正 - -### Client -- ドライブファイルのメニューが正常に動作しない問題を修正 - -## 13.10.1 - -### Client -- Misskey PlayのPlayボタンを押した時にエラーが発生する問題を修正 - -## 13.10.0 - -### General -- ユーザーごとにRenoteをミュートできるように -- ノートごとに絵文字リアクションを受け取るか設定できるように -- クリップをお気に入りに登録できるように -- ノート検索の利用可否をロールで制御可能に(デフォルトでオフ) -- ロールの並び順を設定可能に -- カスタム絵文字にライセンス情報を付与できるように -- 指定した文字列を含む投稿の公開範囲をホームにできるように -- 使われてないアンテナは自動停止されるように - -### Client -- 設定から自分のロールを確認できるように -- 広告一覧ページを追加 -- ドライブクリーナーを追加 -- DM作成時にメンションも含むように -- フォロー申請のボタンのデザインを改善 -- 付箋ウィジェットの高さを設定可能に -- APオブジェクトを入力してフェッチする機能とユーザーやノートの検索機能を分離 -- ナビゲーションバーの項目に「プロフィール」を追加できるように -- ナビゲーションバーのカスタマイズをドラッグ&ドロップで行えるように -- ジョブキューの再試行をワンクリックでできるように -- AiScriptを0.13.1に更新 -- oEmbedをサポートしているウェブサイトのプレビューができるように - - YouTubeをoEmbedでロードし、プレビューで共有ボタンを押すとOSの共有画面がでるように - - ([FirefoxでSpotifyのプレビューを開けるとフルサイズじゃなくプレビューサイズだけ再生できる問題](https://bugzilla.mozilla.org/show_bug.cgi?id=1792395)があります) - - (すでにブラウザーでキャッシュされたリンクに対しては以前のプレビュー行動が行われてます。その場合、ブラウザーのキャッシュをクリアしてまた試してください。) -- プロフィールで設定した情報が削除できない問題を修正 -- ロールで広告を無効にするとadmin/adsでプレビューがでてこない問題を修正 -- /api-consoleページにアクセスすると404が出る問題を修正 -- Safariでプラグインが複数ある場合に正常に読み込まれない問題を修正 -- Bookwyrmのユーザーのプロフィールページで「リモートで表示」をタップしても反応がない問題を修正 -- 非ログイン時の「Misskeyについて」の表示を修正 -- PC版にて「設定」「コントロールパネル」のリンクを2度以上続けてクリックした際に空白のページが表示される問題を修正 - -### Server -- OpenAPIエンドポイントを復旧 -- WebP/AVIF/JPEGのweb公開用画像は、サーバーサイドではJPEGではなくWebPに変換するように -- アニメーション画像のサムネイルを生成するように -- アクティブユーザー数チャートの記録上限値を拡張 -- Playのソースコード上限文字数を2倍に拡張 -- 配送先サーバーが410 Goneで応答してきた場合は自動で配送停止をするように -- avatarBlurHash/bannerBlurHashの型をstringに限定 -- タイムライン取得時のパフォーマンスを改善 -- SMTP Login id length is too short -- API上で`visibility`を`followers`に設定してrenoteすると連合や削除で不具合が発生する問題を修正 -- AWS S3からのファイル削除でNoSuchKeyエラーが出ると進めらない状態になる問題を修正 -- `disableCache: true`を設定している場合に絵文字管理操作でエラーが出る問題を修正 -- リテンション分析が上手く機能しないことがあるのを修正 -- 空のアンテナが作成できないように修正 -- 特定の条件で通報が見れない問題を修正 -- 絵文字の名前に任意の文字が使用できる問題を修正 - -## 13.9.2 (2023/03/06) - -### Improvements -- クリップ、チャンネルページに共有ボタンを追加 -- チャンネルでタイムライン上部に投稿フォームを表示するかどうかのオプションを追加 -- ブラウザでメディアプロキシ(/proxy)からファイルを保存した際に、なるべくオリジナルのファイル名を継承するように -- ドライブの「URLからアップロード」で、content-dispositionのfilenameがあればそれをファイル名に -- Identiconがローカルとリモートで同じになるように - - これまでのIdenticonは異なる画像になります -- サーバーのパフォーマンスを改善 - +## 12.119.2 (2022/12/04) ### Bugfixes -- ロールの権限で「一般ユーザー」のロールがいきなり設定できない問題を修正 -- ユーザーページのバッジ表示を適切に折り返すように @arrow2nd -- fix(client): みつけるのロール一覧でコンディショナルロールが含まれるのを修正 -- macOSでDev Containerが動作しない問題を修正 @RyotaK - -## 13.9.1 (2023/03/03) - -### Bugfixes -- ノートに添付したファイルが表示されない場合があるのを修正 - -## 13.9.0 (2023/03/03) - -### Improvements -- 時限ロール -- アンテナでCWも検索対象にするように -- ノートの操作部をホバー時のみ表示するオプションを追加 -- サウンドを追加 -- enhance(client): MFMのx2, scale, positionが含まれていたらノートをたたむように -- サーバーのパフォーマンスを改善 - -### Bugfixes -- 外部メディアプロキシ使用時にアバタークロップができない問題を修正 -- fix(server): メールアドレス更新時にバリデーションが正しく行われていないのを修正 -- fix(server): チャンネルでミュートが正しく機能していないのを修正 -- プッシュ通知でカスタム絵文字リアクションを表示できなかった問題を修正 - -## 13.8.1 (2023/02/26) - -### Bugfixes -- モバイルでドロワーメニューが表示されない問題を修正 - -## 13.8.0 (2023/02/26) - -### Improvements -- チャンネル内ハイライト -- ホームタイムラインのパフォーマンスを改善 -- renoteした際の表示を改善 -- バックグラウンドで一定時間経過したらページネーションのアイテム更新をしない -- enhance(client): MkUrlPreviewの閉じるボタンを見やすく -- Add dialog to remove follower -- enhance(client): improve clip menu ux -- 検索画面の統合 -- enhance(client): ノートメニューからユーザーメニューを開けるように -- photoswipe 表示時に戻る操作をしても前の画面に戻らないように - -### Bugfixes -- Windows環境でswcを使うと正しくビルドできない問題の修正 -- fix(client): Android ChromeでPWAとしてインストールできない問題を修正 -- 未知のユーザーが deleteActor されたら処理をスキップする -- fix(server): notes/createで、fileIdsと見つかったファイルの数が異なる場合はエラーにする -- fix(server): notes/createのバリデーションが機能していないのを修正 -- fix(server): エラーのスタックトレースは返さないように - -## 13.7.5 (2023/02/24) - -### Note -13.7.0以前から直接このバージョンにアップデートする場合は全ての通知が削除**されません。** - -### Improvements -- 紛らわしいため公開範囲の「ローカルのみ」オプションの名称を「連合なし」に変更 -- Frontend: スマホ・タブレットの場合、チャンネルの投稿フォームに自動でフォーカスしないように - -### Bugfixes -- 全ての通知が削除されてしまうのを修正 - -## 13.7.3 (2023/02/23) - -### Note -~~13.7.0以前から直接このバージョンにアップデートする場合は全ての通知が削除**されません。**~~ - -### Improvements - -### Bugfixes -- Client: 「キャッシュを削除」した後、ローカルのカスタム絵文字が表示されなくなるされなくなる問題を修正 -- Client: 通知設定画面で以前からグループの招待を有効化していた場合、通知の表示に失敗する問題の修正 -- Client: 通知設定画面に古いトグルが残っていた問題を修正 - -## 13.7.2 (2023/02/23) - -### Note -13.7.0以前からアップデートする場合は全ての通知が削除されます。 - -### Improvements -- enhance: make pwa icon maskable -- chore(client): tweak custom emoji size - -### Bugfixes -- マイグレーションが失敗することがあるのを修正 - -## 13.7.1 (2023/02/23) - -### Improvements -- pnpm buildではswcを使うように - -### Bugfixes -- NODE_ENV=productionでビルドできないのを修正 - -## 13.7.0 (2023/02/22) - -### Changes -- チャット機能が削除されました - -### Improvements -- Server: URLプレビュー(summaly)はプロキシを通すように -- Client: 2FA設定のUIをまともにした -- セキュリティキーの名前を変更できるように -- enhance(client): add quiz preset for play -- 広告開始時期を設定できるように -- みつけるで公開ロール一覧とそのメンバーを閲覧できるように -- enhance(client): MFMのx3, x4が含まれていたらノートをたたむように -- enhance(client): make possible to reload page of window - -### Bugfixes -- ユーザー検索ダイアログでローカルユーザーを絞って検索できない問題を修正 -- fix(client): MkHeader及びデッキのカラムでチャンネル一覧を選択したとき、最大5個までしか表示されない -- 管理画面の広告を10個以上見えるように -- Moderation note が保存できない -- ユーザーのハッシュタグ検索が機能していないのを修正 - -## 13.6.1 (2023/02/12) - -### Improvements -- アニメーションを少なくする設定の時、MkPageHeaderのタブアニメーションを無効化 -- Backend: activitypub情報がcorsでブロックされないようヘッダーを追加 -- enhance: レートリミットを0%にできるように -- チャンネル内Renoteを行えるように - -### Bugfixes -- Client: ユーザーページでアクティビティを見ることができない問題を修正 - -## 13.6.0 (2023/02/11) - -### Improvements -- MkPageHeaderをごっそり変えた - * モバイルではヘッダーは上下に分割され、下段にタブが表示されるように - * iconOnlyのタブ項目がアクティブな場合にはタブのタイトルを表示するように - * メインタイムラインではタイトルを表示しない - * メインタイムラインかつモバイルで表示される左上のアバターを選択するとアカウントメニューが開くように -- ユーザーページのノート一覧をタブとして分離 -- コンディショナルロールもバッジとして表示可能に -- enhance(client): ロールをより簡単に付与できるように -- enhance(client): 一度見たノートのRenoteは省略して表示するように -- enhance(client): 迷惑になる可能性のある投稿を行う前に警告を表示 -- リアクションの数が多い場合の表示を改善 -- 一部のMFM構文をopt-outに - -### Bugfixes -- Client: ユーザーページでタブがほとんど見れないことがないように - -## 13.5.6 (2023/02/10) - -### Improvements -- 非ログイン時にMiAuthを踏んだ際にMiAuthであることを表示する -- /auth/のUIをアップデート -- 利用規約同意UIの調整 -- クロップ時の質問を分かりやすく - -### Bugfixes -- fix: prevent clipping audio plyr's tooltip - -## 13.5.4 (2023/02/09) - -### Improvements -- Server: UIのHTML(ノートなどの特別なページを除く)のキャッシュ時間を15秒から30秒に -- i/notificationsのレートリミットを緩和 - -### Bugfixes -- fix(client): validate url to improve security -- fix(client): dateの初期値が正常に入らない時がある - -## 13.5.3 (2023/02/09) - -### Improvements -- Client: デッキにチャンネルカラムを追加 - -## 13.5.2 (2023/02/08) - -### Changes -- Revert: perf(client): do not render custom emojis in user names - -### Bugfixes -- Client: register_note_view_interruptor not working -- Client: ログイントークンの再生成が出来ない - -## 13.5.0 (2023/02/08) - -### Changes -- perf(client): do not render custom emojis in user names - -### Improvements -- Client: disableShowingAnimatedImagesのデフォルト値をprefers-reduced-motionにする -- enhance(client): tweak medialist style - -### Bugfixes -- fix docker health check -- Client: MkEmojiPickerでもChromeで検索ダイアログで変換確定するとそのまま検索されてしまうのを修正 -- fix(mfm): default degree not used in rotate -- fix(server): validate urls from ap to improve security - -## 13.4.0 (2023/02/05) - -### Improvements -- ロールにアイコンを設定してユーザー名の横に表示できるように -- feat: timeline page for non-login users -- 実績の単なるラッキーの獲得確立を調整 -- Add Thai language support - -### Bugfixes -- fix(server): 自分のノートをお気に入りに登録しても実績解除される問題を修正 -- fix(server): clean up file in FileServer -- fix(server): Deny UNIX domain socket -- fix(server): validate filename and emoji name to improve security -- fix(client): validate input response in aiscript -- fix(client): add webhook delete button -- fix(client): tweak notification style -- fix(client): インラインコードを折り返して表示する - -## 13.3.3 (2023/02/04) - -### Bugfixes -- Server: improve security - -## 13.3.2 (2023/02/04) - -### Improvements -- 外部メディアプロキシへの対応を強化しました - 外部メディアプロキシのFastify実装を作りました - https://github.com/misskey-dev/media-proxy -- Server: improve performance - -### Bugfixes -- Client: validate urls to improve security - -## 13.3.1 (2023/02/04) - -### Bugfixes -- Client: カスタム絵文字にアニメーション画像を再生しない設定が適用されていない問題を修正 -- Client: オートコンプリートでUnicode絵文字がカスタム絵文字として表示されてしまうのを修正 -- Client: Fix Vue-plyr CORS issue -- Client: validate urls to improve security - -## 13.3.0 (2023/02/03) -### Changes -- twitter/github/discord連携機能が削除されました -- ハッシュタグごとのチャートが削除されました -- syslogのサポートが削除されました - -### Improvements -- ロールで広告の非表示が有効になっている場合は最初から広告を非表示にするように - -## 13.2.6 (2023/02/01) -### Changes -- docker-compose.ymlをdocker-compose.yml.exampleにしました。docker-compose.ymlとしてコピーしてから使用してください。 - -### Improvements -- 絵文字ピッカーのパフォーマンスを改善 -- AiScriptを0.12.4に更新 - -### Bugfixes -- Server: リレーと通信できない問題を修正 -- Client: classicモード使用時にwindowサイズによってdefaultに変更された後に、windowサイズが元に戻ったらclassicに戻すように修正 #9669 -- Client: Chromeで検索ダイアログで変換確定するとそのまま検索されてしまう問題を修正 - -## 13.2.4 (2023/01/27) -### Improvements -- リモートカスタム絵文字表示時のパフォーマンスを改善 -- Default to `animation: false` when prefers-reduced-motion is set -- リアクション履歴が公開なら、ログインしていなくても表示できるように -- tweak blur setting -- tweak custom emoji cache - -### Bugfixes -- fix aggregation of retention -- ダッシュボードでオンラインユーザー数が表示されない問題を修正 -- フォロー申請・フォローのボタンが、通知から消えている問題を修正 - -## 13.2.3 (2023/01/26) -### Improvements -- カスタム絵文字の更新をリアルタイムで反映するように - -### Bugfixes -- turnstile-failed: missing-input-secret - -## 13.2.2 (2023/01/25) -### Improvements -- サーバーのパフォーマンスを改善 - -### Bugfixes -- サインイン時に誤ったレートリミットがかかることがある問題を修正 -- MFMのposition、rotate、scaleで小数が使えない問題を修正 - -## 13.2.1 (2023/01/24) -### Improvements -- デザインの調整 -- サーバーのパフォーマンスを改善 - -## 13.2.0 (2023/01/23) - -### Improvements -- onlyServer / onlyQueue オプションを復活 -- 他人の実績閲覧時は獲得条件を表示しないように -- アニメーション減らすオプション有効時はリアクションのアニメーションを無効に -- カスタム絵文字一覧のパフォーマンスを改善 - -### Bugfixes -- Aiscript: button is not defined - -## 13.1.7 (2023/01/22) - -### Improvements -- 新たな実績を追加 -- MFMにscaleタグを追加 - -## 13.1.4 (2023/01/22) - -### Improvements -- 新たな実績を追加 - -### Bugfixes -- Client: ローカリゼーション更新時にリロードが繰り返されることがあるのを修正 - -## 13.1.3 (2023/01/22) - -### Bugfixes -- Client: リアクションのカスタム絵文字の表示の問題を修正 - -## 13.1.2 (2023/01/22) - -### Bugfixes -- Client: リアクションのカスタム絵文字の表示の問題を修正 - -## 13.1.1 (2023/01/22) - -### Improvements -- ローカルのカスタム絵文字を表示する際のパフォーマンスを改善 -- Client: 瞬間的に大量の実績を解除した際の挙動を改善 - -### Bugfixes -- Client: アップデート時にローカリゼーションデータが更新されないことがあるのを修正 - -## 13.1.0 (2023/01/21) - -### Improvements -- 実績機能 -- Playのプリセットを追加 -- Playのscriptの文字数制限を緩和 -- AiScript GUIの強化 -- リアクション一覧詳細ダイアログを表示できるように -- 存在しないカスタム絵文字をテキストで表示するように -- Alt text in image viewer -- ジョブキューのプロセスとWebサーバーのプロセスを分離 - -### Bugfixes -- playを削除する手段がなかったのを修正 -- The … button on notes does nothing when not logged in -- twitterと連携するときに autwh is not a function になるのを修正 - -## 13.0.0 (2023/01/16) - -### TL;DR -- New features (Role system, Misskey Play, New widgets, New charts, 🍪👈, etc) -- Rewriten backend -- Better performance (backend and frontend) -- Various usability improvements -- Various UI tweaks - -### Notable features -- ロール機能 - - 従来より柔軟にユーザーのポリシーを管理できます。例えば、「インスタンスのパトロンはアンテナを30個まで作れる」「基本的にLTLは見れないが、許可した人だけ見れる」「招待制インスタンスだけどユーザーなら誰でも他者を招待できる」のような運用はもちろん、「ローカルユーザーかつアカウント作成から1日未満のユーザーはパブリックな投稿を行えない」のように複数条件を組み合わせて、自動でロールを付与する設定も可能です。 -- Misskey Play - - 従来の動的なPagesに代わる、新しいプラットフォームです。動的なコンテンツ(アプリケーション)に特化していて、Pagesに比べてはるかに柔軟なアプリケーションを作成可能です。 - -### Changes -#### For server admins -- Node.js 18.x or later is required -- PostgreSQL 15.x is required - - Misskey not using 15 specific features at 13.0.0, but may do so in the future. - - Docker環境でPostgreSQLのアップデートを行う際のガイドはこちら: https://github.com/misskey-dev/misskey/pull/9641#issue-1536336620 -- Elasticsearchのサポートが削除されました - - 代わりに今後任意の検索プロバイダを設定できる仕組みを構想しています。その仕組みを使えば今まで通りElasticsearchも利用できます -- Yarnからpnpmに移行されました - corepackの有効化を推奨します: `sudo corepack enable` -- インスタンスブロックはサブドメインにも適用されるようになります -- ロールの導入に伴い、いくつかの機能がロールと統合されました - - モデレーターはロールに統合されました。今までのモデレーター情報は失われるため、予めモデレーター一覧を記録しておき、アップデート後にモデレーターロールを作りアサインし直してください。 - - サイレンスはロールに統合されました。今までのユーザーは恩赦されるため、予めサイレンス一覧を記録しておくのをおすすめします。 - - ユーザーごとのドライブ容量設定はロールに統合されました。 - - インスタンスデフォルトのドライブ容量設定はロールに統合されました。アップデート後、ベースロールもしくはコンディショナルロールでドライブ容量を編集してください。 - - LTL/GTLの解放状態はロールに統合されました。 -- Dockerの実行をrootで行わないようにしました。Dockerかつオブジェクトストレージを使用していない場合は`chown -hR 991.991 ./files`を実行してください。 - https://github.com/misskey-dev/misskey/pull/9560 - -#### For users -- ノートのウォッチ機能が削除されました -- アンケートに投票された際に通知が作成されなくなりました -- ノートの数式埋め込みが削除されました -- 新たに動的なPagesを作ることはできなくなりました - - 代わりにAiScriptを用いてより柔軟に動的なコンテンツを作成できるMisskey Play機能が実装されています。 -- AiScriptが0.12.2にアップデートされました - - 0.12.xの変更点についてはこちら https://github.com/syuilo/aiscript/blob/master/CHANGELOG.md#0120 - - 0.12.x未満のプラグインは読み込むことはできません -- iOS15以下のデバイスはサポートされなくなりました -- Firefox110以下はサポートされなくなりました - - 109でもContainerQueriesのフラグを有効にする事で問題なく使用できます - -#### For app developers -- API: metaのレスポンスに`emojis`プロパティが含まれなくなりました - - カスタム絵文字一覧情報を取得するには、`emojis`エンドポイントにリクエストします -- API: カスタム絵文字エンティティに`url`プロパティが含まれなくなりました - - 絵文字画像を表示するには、`/emoji/.webp`にリクエストすると画像が返ります。 - - e.g. `https://p1.a9z.dev/emoji/misskey.webp` - - remote: `https://p1.a9z.dev/emoji/syuilo_birth_present@mk.f72u.net.webp` -- API: `user`および`note`エンティティに`emojis`プロパティが含まれなくなりました -- API: `user`エンティティに`avatarColor`および`bannerColor`プロパティが含まれなくなりました -- API: `instance`エンティティに`latestStatus`、`lastCommunicatedAt`、`latestRequestSentAt`プロパティが含まれなくなりました -- API: `instance`エンティティの`caughtAt`は`firstRetrievedAt`に名前が変わりました - -### Improvements -- Role system @syuilo -- Misskey Play @syuilo -- Introduce retention-rate aggregation @syuilo -- Make possible to export favorited notes @syuilo -- Add per user pv chart @syuilo -- Push notification of Antenna note @tamaina -- AVIF support @tamaina -- Add Cloudflare Turnstile CAPTCHA support @CyberRex0 -- レートリミットをユーザーごとに調整可能に @syuilo -- 非モデレーターでも、権限を持つロールをアサインされたユーザーはインスタンスの招待コードを発行できるように @syuilo -- 非モデレーターでも、権限を持つロールをアサインされたユーザーはカスタム絵文字の追加、編集、削除を行えるように @syuilo -- クリップおよびクリップ内のノートの作成可能数を設定可能に @syuilo -- ユーザーリストおよびユーザーリスト内のユーザーの作成可能数を設定可能に @syuilo -- ハードワードミュートの最大文字数を設定可能に @syuilo -- Webhookの作成可能数を設定可能に @syuilo -- ノートをピン留めできる数を設定可能に @syuilo -- Server: signToActivityPubGet is set to true by default @syuilo -- Server: improve syslog performance @syuilo -- Server: Use undici instead of node-fetch and got @tamaina -- Server: Judge instance block by endsWith @tamaina -- Server: improve note scoring for featured notes @CyberRex0 -- Server: アンケート選択肢の文字数制限を緩和 @syuilo -- Server: プロフィールの文字数制限を緩和 @syuilo -- Server: add rate limits for some endpoints @syuilo -- Server: improve stats api performance @syuilo -- Server: improve nodeinfo performance @syuilo -- Server: delete outdated notifications regularly to improve db performance @syuilo -- Server: delete outdated hard-mutes regularly to improve db performance @syuilo -- Server: delete outdated notes of antenna regularly to improve db performance @syuilo -- Server: improve activitypub deliver performance @syuilo -- Client: use tabler-icons instead of fontawesome to better design @syuilo -- Client: Add new gabber kick sounds (thanks for noizenecio) -- Client: Add link to user RSS feed in profile menu @ssmucny -- Client: Compress non-animated PNG files @saschanaz -- Client: YouTube window player @sim1222 -- Client: show readable error when rate limit exceeded @syuilo -- Client: enhance dashboard of control panel @syuilo -- Client: Vite is upgraded to v4 @syuilo, @tamaina -- Client: HMR is available while yarn dev @tamaina -- Client: Implement the button to subscribe push notification @tamaina -- Client: Implement the toggle to or not to close push notifications when notifications or messages are read @tamaina -- Client: show Unicode emoji tooltip with its name in MkReactionsViewer.reaction @saschanaz -- Client: OpenSearch support @SoniEx2 @chaoticryptidz -- Client: Support remote objects in search @SoniEx2 -- Client: user activity page @syuilo -- Client: Make widgets of universal/classic sync between devices @tamaina -- Client: add user list widget @syuilo -- Client: Add AiScript App widget -- Client: add profile widget @syuilo -- Client: add instance info widget @syuilo -- Client: Improve RSS widget @tamaina -- Client: add heatmap of daily active users to about page @syuilo -- Client: introduce fluent emoji @syuilo -- Client: add new theme @syuilo -- Client: add new mfm function (position, fg, bg) @syuilo -- Client: show fireworks when visit user who today is birthday @syuilo -- Client: show bot warning on screen when logged in as bot account @syuilo -- Client: AiScriptからカスタム絵文字一覧を参照できるように @syuilo -- Client: improve overall performance of client @syuilo -- Client: ui tweaks @syuilo -- Client: clicker game @syuilo - -### Bugfixes -- Server: Fix @tensorflow/tfjs-core's MODULE_NOT_FOUND error @ikuradon -- Server: 引用内の文章がnyaizeされてしまう問題を修正 @kabo2468 -- Server: Bug fix for Pinned Users lookup on instance @squidicuzz -- Server: Fix peers API returning suspended instances @ineffyble -- Server: trim long text of note from ap @syuilo -- Server: Ap inboxの最大ペイロードサイズを64kbに制限 @syuilo -- Server: アンテナの作成数上限を追加 @syuilo -- Server: pages/likeのエラーIDが重複しているのを修正 @syuilo -- Server: pages/updateのパラメータによってはsummaryの値が更新されないのを修正 @syuilo -- Server: Escape SQL LIKE @mei23 -- Server: 特定のPNG画像のアップロードに失敗する問題を修正 @usbharu -- Server: 非公開のクリップのURLでOGPレンダリングされる問題を修正 @syuilo -- Server: アンテナタイムライン(ストリーミング)が、フォローしていないユーザーの鍵投稿も拾ってしまう @syuilo -- Server: follow request list api pagination @sim1222 -- Server: ドライブ容量超過時のエラーが適切にレスポンスされない問題を修正 @syuilo -- Client: パスワードマネージャーなどでユーザー名がオートコンプリートされない問題を修正 @massongit -- Client: 日付形式の文字列などがカスタム絵文字として表示されるのを修正 @syuilo -- Client: case insensitive emoji search @saschanaz -- Client: 画面の幅が狭いとウィジェットドロワーを閉じる手段がなくなるのを修正 @syuilo -- Client: InAppウィンドウが操作できなくなることがあるのを修正 @tamaina -- Client: use proxied image for instance icon @syuilo -- Client: Webhookの編集画面で、内容を保存することができない問題を修正 @m-hayabusa -- Client: Page編集でブロックの移動が行えない問題を修正 @syuilo -- Client: update emoji picker immediately on all input @saschanaz -- Client: チャートのツールチップが画面に残ることがあるのを修正 @syuilo -- Client: fix wrong link in tutorial @syuilo - -### Special thanks -- All contributors -- All who have created instances for the beta test -- All who participated in the beta test +- Server: Backported versions mitigate isn't working @mei23 ## 12.119.1 (2022/12/03) ### Bugfixes @@ -1550,7 +117,7 @@ Meilisearchの設定に`index`が必要になりました。値はMisskeyサー ## 12.112.2 (2022/07/08) ### Bugfixes -- Fix Docker doesn't work @mei23 +- Fix Docker doesn't work @mei23 Still not working on arm64 environment. (See 12.112.0) ## 12.112.1 (2022/07/07) @@ -1592,7 +159,7 @@ same as 12.112.0 - Improve player detection in URL preview @mei23 - Add Badge Image to Push Notification #8012 @tamaina - Server: Improve performance -- Server: Supports IPv6 on Redis transport. @mei23 +- Server: Supports IPv6 on Redis transport. @mei23 IPv4/IPv6 is used by default. You can tune this behavior via `redis.family`. - Server: Add possibility to log IP addresses of users @syuilo - Add additional drive capacity change support @CyberRex0 @@ -1805,7 +372,7 @@ same as 12.112.0 ## 12.104.0 (2022/02/09) ### Note -ビルドする前に`yarn clean`を実行してください。 +ビルドする前に`npm run clean`を実行してください。 このリリースはマイグレーションの規模が大きいため、インスタンスによってはマイグレーションに時間がかかる可能性があります。 マイグレーションが終わらない場合は、チャートの情報はリセットされてしまいますが`__chart__`で始まるテーブルの**レコード**を全て削除(テーブル自体は消さないでください)してから再度試す方法もあります。 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 1bbfb082af..cd9cf8302a 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,131 +2,45 @@ ## Our Pledge -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, caste, color, religion, or sexual -identity and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards -Examples of behavior that contributes to a positive environment for our -community include: +Examples of behavior that contributes to creating a positive environment include: -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the overall - community +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members -Examples of unacceptable behavior include: +Examples of unacceptable behavior by participants include: -* The use of sexualized language or imagery, and sexual attention or advances of - any kind -* Trolling, insulting or derogatory comments, and personal or political attacks +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment -* Publishing others' private information, such as a physical or email address, - without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting -## Enforcement Responsibilities +## Our Responsibilities -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -. -All complaints will be reviewed and investigated promptly and fairly. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at syuilotan@yahoo.co.jp. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series of -actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or permanent -ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within the -community. +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.1, available at -[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] -Community Impact Guidelines were inspired by -[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. - -For answers to common questions about this code of conduct, see the FAQ at -[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at -[https://www.contributor-covenant.org/translations][translations]. - -[homepage]: https://www.contributor-covenant.org -[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html -[Mozilla CoC]: https://github.com/mozilla/diversity -[FAQ]: https://www.contributor-covenant.org/faq -[translations]: https://www.contributor-covenant.org/translations +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f6c1f4f82..4547138eb4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ Before creating an issue, please check the following: - To avoid duplication, please search for similar issues before creating a new issue. - Do not use Issues to ask questions or troubleshooting. - Issues should only be used to feature requests, suggestions, and bug tracking. - - Please ask questions or troubleshooting in [GitHub Discussions](https://github.com/misskey-dev/misskey/discussions) or [Discord](https://discord.gg/Wp8gVStHW3). + - Please ask questions or troubleshooting in the [Misskey Forum](https://forum.misskey.io/) or [Discord](https://discord.gg/Wp8gVStHW3). > **Warning** > Do not close issues that are about to be resolved. It should remain open until a commit that actually resolves it is merged. @@ -44,7 +44,7 @@ Thank you for your PR! Before creating a PR, please check the following: - Check if there are any documents that need to be created or updated due to this change. - If you have added a feature or fixed a bug, please add a test case if possible. - Please make sure that tests and Lint are passed in advance. - - You can run it with `pnpm test` and `pnpm lint`. [See more info](#testing) + - You can run it with `npm run test` and `npm run lint`. [See more info](#testing) - If this PR includes UI changes, please attach a screenshot in the text. Thanks for your cooperation 🤗 @@ -83,18 +83,11 @@ An actual domain will be assigned so you can test the federation. - The title must be in the format `Release: x.y.z`. - `x.y.z` is the new version you are trying to release. 3. Deploy and perform a simple QA check. Also verify that the tests passed. -4. Merge it. (Do not squash commit) +4. Merge it. 5. Create a [release of GitHub](https://github.com/misskey-dev/misskey/releases) - The target branch must be `master` - The tag name must be the version -> **Note** -> Why this instruction is necessary: -> - To perform final QA checks -> - To distribute responsibility -> - To check direct commits to develop -> - To celebrate the release together 🎉 - ## Localization (l10n) Misskey uses [Crowdin](https://crowdin.com/project/misskey) for localization management. You can improve our translations with your Crowdin account. @@ -106,74 +99,37 @@ If your language is not listed in Crowdin, please open an issue. ![Crowdin](https://d322cqt584bo4o.cloudfront.net/misskey/localized.svg) ## Development -During development, it is useful to use the - -``` -pnpm dev -``` - -command. - -- Server-side source files and automatically builds them if they are modified. Automatically start the server process(es). -- Vite HMR (just the `vite` command) is available. The behavior may be different from production. -- Service Worker is watched by esbuild. -- The front end can be viewed by accessing `http://localhost:5173`. -- The backend listens on the port configured with `port` in .config/default.yml. -If you have not changed it from the default, it will be "http://localhost:3000". -If "port" in .config/default.yml is set to something other than 3000, you need to change the proxy settings in packages/frontend/vite.config.local-dev.ts. - -### Dev Container -Instead of running `pnpm` locally, you can use Dev Container to set up your development environment. -To use Dev Container, open the project directory on VSCode with Dev Containers installed. -**Note:** If you are using Windows, please clone the repository with WSL. Using Git for Windows will result in broken files due to the difference in how newlines are handled. - -It will run the following command automatically inside the container. -``` bash -git submodule update --init -pnpm install --frozen-lockfile -cp .devcontainer/devcontainer.yml .config/default.yml -pnpm build -pnpm migrate -``` - -After finishing the migration, run the `pnpm dev` command to start the development server. - -``` bash -pnpm dev -``` +During development, it is useful to use the `npm run dev` command. +This command monitors the server-side and client-side source files and automatically builds them if they are modified. +In addition, it will also automatically start the Misskey server process. ## Testing -- Test codes are located in [`/packages/backend/test`](/packages/backend/test). +- Test codes are located in [`/test`](/test). ### Run test Create a config file. ``` -cp .github/misskey/test.yml .config/ +cp test/test.yml .config/ ``` Prepare DB/Redis for testing. ``` -docker compose -f packages/backend/test/docker-compose.yml up +docker-compose -f test/docker-compose.yml up ``` -Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.yml`. +Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.yml`. Run all test. ``` -pnpm test +npm run test ``` #### Run specify test ``` -pnpm jest -- foo.ts +npx cross-env TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT="./test/tsconfig.json" npx mocha test/foo.ts --require ts-node/register ``` ### e2e tests TODO -## Environment Variable - -- `MISSKEY_CONFIG_YML`: Specify the file path of config.yml instead of default.yml (e.g. `2nd.yml`). -- `MISSKEY_WEBFINGER_USE_HTTP`: If it's set true, WebFinger requests will be http instead of https, useful for testing federation between servers in localhost. NEVER USE IN PRODUCTION. - ## Continuous integration Misskey uses GitHub Actions for executing automated tests. Configuration files are located in [`/.github/workflows`](/.github/workflows). @@ -212,108 +168,10 @@ niraxは、Misskeyで使用しているオリジナルのフロントエンド vue-routerとの最大の違いは、niraxは複数のルーターが存在することを許可している点です。 これにより、アプリ内ウィンドウでブラウザとは個別にルーティングすることなどが可能になります。 -## Storybook - -Misskey uses [Storybook](https://storybook.js.org/) for UI development. - -### Setup & Run - -#### Setup - -```bash -pnpm --filter misskey-js build -``` - -#### Run - -```bash -pnpm --filter frontend storybook-dev -``` - -### Usage - -When you create a new component (in this example, `MyComponent.vue`), the story file (`MyComponent.stories.ts`) will be automatically generated by the `.storybook/generate.js` script. -You can override the default story by creating a impl story file (`MyComponent.stories.impl.ts`). - -```ts -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; -import MyComponent from './MyComponent.vue'; -export const Default = { - render(args) { - return { - components: { - MyComponent, - }, - setup() { - return { - args, - }; - }, - computed: { - props() { - return { - ...this.args, - }; - }, - }, - template: '', - }; - }, - args: { - foo: 'bar', - }, - parameters: { - layout: 'centered', - }, -} satisfies StoryObj; -``` - -If you want to opt-out from the automatic generation, create a `MyComponent.stories.impl.ts` file and add the following line to the file. - -```ts -import MyComponent from './MyComponent.vue'; -void MyComponent; -``` - -You can override the component meta by creating a meta story file (`MyComponent.stories.meta.ts`). - -```ts -export const argTypes = { - scale: { - control: { - type: 'range', - min: 1, - max: 4, - }, -}; -``` - -Also, you can use msw to mock API requests in the storybook. Creating a `MyComponent.stories.msw.ts` file to define the mock handlers. - -```ts -import { rest } from 'msw'; -export const handlers = [ - rest.post('/api/notes/timeline', (req, res, ctx) => { - return res( - ctx.json([]), - ); - }), -]; -``` - -Don't forget to re-run the `.storybook/generate.js` script after adding, editing, or removing the above files. - ## Notes +### How to resolve conflictions occurred at yarn.lock? -### Misskeyのドメイン固有の概念は`Mi`をprefixする -例えばGoogleが自社サービスをMap、Earth、DriveではなくGoogle Map、Google Earth、Google Driveのように命名するのと同じ -コード上でMisskeyのドメイン固有の概念には`Mi`をprefixすることで、他のドメインの同様の概念と区別できるほか、名前の衝突を防ぐ。 -ただし、文脈上Misskeyのものを指すことが明らかであり、名前の衝突の恐れがない場合は、一時的なローカル変数に限って`Mi`を省略してもよい。 - -### How to resolve conflictions occurred at pnpm-lock.yaml? - -Just execute `pnpm` to fix it. +Just execute `yarn` to fix it. ### INSERTするときにはsaveではなくinsertを使用する #6441 @@ -392,41 +250,19 @@ SQLでは配列のインデックスは**1始まり**。 ### null IN nullが含まれる可能性のあるカラムにINするときは、そのままだとおかしくなるのでORなどでnullのハンドリングをしよう。 -### enumの削除は気をつける -enumの列挙の内容の削除は、その値をもつレコードを全て削除しないといけない - -削除が重たかったり不可能だったりする場合は、削除しないでおく +### `undefined`にご用心 +MongoDBの時とは違い、findOneでレコードを取得する時に対象レコードが存在しない場合 **`undefined`** が返ってくるので注意。 +MongoDBは`null`で返してきてたので、その感覚で`if (x === null)`とか書くとバグる。代わりに`if (x == null)`と書いてください ### Migration作成方法 packages/backendで: ```sh -pnpm dlx typeorm migration:generate -d ormconfig.js -o +npx typeorm migration:generate -d ormconfig.js -o ``` - 生成後、ファイルをmigration下に移してください - 作成されたスクリプトは不必要な変更を含むため除去してください -### JSON SchemaのobjectでanyOfを使うとき -JSON Schemaで、objectに対してanyOfを使う場合、anyOfの中でpropertiesを定義しないこと。 -バリデーションが効かないため。(SchemaTypeもそのように作られており、objectのanyOf内のpropertiesは捨てられます) -https://github.com/misskey-dev/misskey/pull/10082 - -テキストhogeおよびfugaについて、片方を必須としつつ両方の指定もありうる場合: - -``` -export const paramDef = { - type: 'object', - properties: { - hoge: { type: 'string', minLength: 1 }, - fuga: { type: 'string', minLength: 1 }, - }, - anyOf: [ - { required: ['hoge'] }, - { required: ['fuga'] }, - ], -} as const; -``` - ### コネクションには`markRaw`せよ **Vueのコンポーネントのdataオプションとして**misskey.jsのコネクションを設定するとき、必ず`markRaw`でラップしてください。インスタンスが不必要にリアクティブ化されることで、misskey.js内の処理で不具合が発生するとともに、パフォーマンス上の問題にも繋がる。なお、Composition APIを使う場合はこの限りではない(リアクティブ化はマニュアルなため)。 @@ -440,6 +276,3 @@ marginはそのコンポーネントを使う側が設定する ## その他 ### HTMLのクラス名で follow という単語は使わない 広告ブロッカーで誤ってブロックされる - -### indexというファイル名を使うな -ESMではディレクトリインポートは廃止されているのと、ディレクトリインポートせずともファイル名が index だと何故か一部のライブラリ?でディレクトリインポートだと見做されてエラーになる diff --git a/COPYING b/COPYING index c218443d42..afa1794598 100644 --- a/COPYING +++ b/COPYING @@ -1,5 +1,5 @@ Unless otherwise stated this repository is -Copyright © 2014-2023 syuilo and contributers +Copyright © 2014-2022 syuilo and contributers And is distributed under The GNU Affero General Public License Version 3, you should have received a copy of the license file as LICENSE. diff --git a/Dockerfile b/Dockerfile index 440c04e2df..81dc726375 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,62 +1,32 @@ -# syntax = docker/dockerfile:1.4 +FROM node:16.15.1-bullseye AS builder -ARG NODE_VERSION=21.4.0-alpine3.18 +ARG NODE_ENV=production -FROM node:${NODE_VERSION} as build - -RUN corepack enable - -WORKDIR /sharkey - -RUN apk add git linux-headers build-base - -ENV PYTHONUNBUFFERED=1 -RUN apk add --update python3 && ln -sf python3 /usr/bin/python -RUN python3 -m ensurepip -RUN pip3 install --no-cache --upgrade pip setuptools +WORKDIR /misskey COPY . ./ -RUN git submodule update --init --recursive -RUN pnpm config set fetch-retries 5 -RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ - pnpm i -RUN pnpm build -RUN node scripts/trim-deps.mjs -RUN mv packages/frontend/assets sharkey-assets -RUN rm -r node_modules packages/frontend packages/sw -RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ - pnpm i --prod +RUN apt-get update +RUN apt-get install -y build-essential +RUN git submodule update --init +RUN yarn install +RUN yarn build RUN rm -rf .git -FROM node:${NODE_VERSION} +FROM node:16.15.1-bullseye-slim AS runner -WORKDIR /sharkey +WORKDIR /misskey -RUN apk add ffmpeg tini +RUN apt-get update +RUN apt-get install -y ffmpeg tini -COPY --from=build /sharkey/built ./built -COPY --from=build /sharkey/node_modules ./node_modules -COPY --from=build /sharkey/packages/backend/built ./packages/backend/built -COPY --from=build /sharkey/packages/backend/node_modules ./packages/backend/node_modules -COPY --from=build /sharkey/packages/megalodon/lib ./packages/megalodon/lib -COPY --from=build /sharkey/packages/megalodon/node_modules ./packages/megalodon/node_modules -COPY --from=build /sharkey/packages/misskey-js/built ./packages/misskey-js/built -COPY --from=build /sharkey/packages/misskey-js/node_modules ./packages/misskey-js/node_modules -COPY --from=build /sharkey/fluent-emojis ./fluent-emojis -COPY --from=build /sharkey/sharkey-assets ./packages/frontend/assets - -COPY package.json ./package.json -COPY pnpm-workspace.yaml ./pnpm-workspace.yaml -COPY packages/backend/package.json ./packages/backend/package.json -COPY packages/backend/check_connect.js ./packages/backend/check_connect.js -COPY packages/backend/ormconfig.js ./packages/backend/ormconfig.js -COPY packages/backend/migration ./packages/backend/migration -COPY packages/backend/assets ./packages/backend/assets -COPY packages/megalodon/package.json ./packages/megalodon/package.json -COPY packages/misskey-js/package.json ./packages/misskey-js/package.json +COPY --from=builder /misskey/node_modules ./node_modules +COPY --from=builder /misskey/built ./built +COPY --from=builder /misskey/packages/backend/node_modules ./packages/backend/node_modules +COPY --from=builder /misskey/packages/backend/built ./packages/backend/built +COPY --from=builder /misskey/packages/client/node_modules ./packages/client/node_modules +COPY . ./ ENV NODE_ENV=production -RUN corepack enable -ENTRYPOINT ["/sbin/tini", "--"] -CMD ["pnpm", "run", "migrateandstart"] +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["npm", "run", "migrateandstart"] diff --git a/README.md b/README.md index d3ba23c4e1..c273270644 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,44 @@
- - Sharkey logo + + Misskey logo - -**🌎 **[Sharkey](https://joinsharkey.org/)** is an open source, decentralized social media platform that's free forever! 🚀** - + +**🌎 **[Misskey](https://misskey-hub.net/)** is an open source, decentralized social media platform that's free forever! 🚀** + --- - - find an instance + + find an instance - + create an instance become a contributor - + join the community - - donate - - Translate Sharkey - + + become a patron + ---
- + ## ✨ Features - **ActivityPub support**\ -Not on Sharkey? No problem! Not only can Sharkey instances talk to each other, but you can make friends with people on other networks like Mastodon and Pixelfed! -- **Federated Backgrounds and Music status**\ -You can add a background to your profile as well as a music status via ListenBrainz, show everyone what music you are currently listening too -- **Mastodon API**\ -Sharkey implements the Mastodon API unlike normal Misskey -- **UI/UX Improvements**\ -Sharkey makes some UI/UX improvements to make it easier to navigate -- **Sign-Up Approval**\ -With Sharkey, you can enable sign-ups, subject to manual moderator approval and mandatory user-provided reasons for joining. +Not on Misskey? No problem! Not only can Misskey instances talk to each other, but you can make friends with people on other networks like Mastodon and Pixelfed! +- **Reactions**\ +You can add emoji reactions to any post! No longer are you bound by a like button, show everyone exactly how you feel with the tap of a button. +- **Drive**\ +With Misskey's built in drive, you get cloud storage right in your social media, where you can upload any files, make folders, and find media from posts you've made! - **Rich Web UI**\ - Sharkey has a rich and easy to use Web UI! + Misskey has a rich and easy to use Web UI! It is highly customizable, from changing the layout and adding widgets to making custom themes. Furthermore, plugins can be created using AiScript, an original programming language. - And much more... @@ -55,4 +49,9 @@ With Sharkey, you can enable sign-ups, subject to manual moderator approval and ## Documentation -Sharkey Documentation can be found at [Sharkey Documentation](https://docs.joinsharkey.org/docs/install/fresh/) +Misskey Documentation can be found at [Misskey Hub](https://misskey-hub.net/), some of the links and graphics above also lead to specific portions of it. + +## Sponsors +
+ RSS3 +
diff --git a/ROADMAP.md b/ROADMAP.md index 3077c41e73..9b3016cad3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,24 +5,25 @@ Also, the later tasks are more indefinite and are subject to change as developme ## (1) Improve maintainability \ This is the phase we are at now. We need to make a high-maintenance environment that can withstand future development. -- ~~Make the number of type errors zero (backend)~~ → Done ✔️ +- Make the number of type errors zero (backend) + - Probably need to switch some libraries to others that make it difficult to reduce type errors + - e.g. koa to fastify https://github.com/misskey-dev/misskey/issues/7537 - Improve CI - - ~~Fix tests~~ → Done ✔️ + - Fix tests + - mocha, jest, etc. do not support the combination of `TypeScript + ESM + Path alias`, and the tests currently do not work. - Fix random test failures - https://github.com/misskey-dev/misskey/issues/7985 and https://github.com/misskey-dev/misskey/issues/7986 - Add more tests - - ~~May need to implement a mechanism that allows for DI~~ → Done ✔️ + - May need to implement a mechanism that allows for DI - https://github.com/misskey-dev/misskey/pull/9085 - - ~~Measure coverage~~ → Done ✔️ + - Measure coverage - https://github.com/misskey-dev/misskey/pull/9081 - Improve documentation -- Refactoring - - Extract the logic of each endpoint definition into a service and just call it ## (2) Improve functionality Once Phase 1 is complete and an environment conducive to the development of a stable system is in place, the implementation of new functions can begin gradually. - Improve features for moderation -- ~~OAuth2 support https://github.com/misskey-dev/misskey/issues/8262~~ → Done ✔️ +- OAuth2 support https://github.com/misskey-dev/misskey/issues/8262 - GraphQL support? ## (3) Improve scalability diff --git a/SECURITY.md b/SECURITY.md index fa00b700e9..2c026a5f33 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,9 +1,9 @@ # Reporting Security Issues -If you discover a security issue in Sharkey, please report it by sending an -email to [admin@transfem.org](mailto:admin@transfem.org). +If you discover a security issue in Misskey, please report it by sending an +email to [syuilotan@yahoo.co.jp](mailto:syuilotan@yahoo.co.jp). This will allow us to assess the risk, and make a fix available before we add a bug report to the GitHub repository. -Thanks for helping make Sharkey safe for everyone. +Thanks for helping make Misskey safe for everyone. diff --git a/assets/backend.png b/assets/backend.png deleted file mode 100644 index 74cf2e64e0..0000000000 Binary files a/assets/backend.png and /dev/null differ diff --git a/assets/title_float.svg b/assets/title_float.svg index ed1749e321..43205ac1c4 100644 --- a/assets/title_float.svg +++ b/assets/title_float.svg @@ -23,13 +23,13 @@ - - -
-
- -
-
-

${ subject }

-
${ html }
-
- -
- - -`, - }); - - this.logger.info(`Message sent: ${info.messageId}`); - } catch (err) { - this.logger.error(err as Error); - throw err; - } - } - - @bindThis - public async validateEmailForAccount(emailAddress: string): Promise<{ - available: boolean; - reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp' | 'banned'; - }> { - const meta = await this.metaService.fetch(); - - const exist = await this.userProfilesRepository.countBy({ - emailVerified: true, - email: emailAddress, - }); - - let validated: { - valid: boolean, - reason?: string | null, - }; - - if (meta.enableActiveEmailValidation) { - if (meta.enableVerifymailApi && meta.verifymailAuthKey != null) { - validated = await this.verifyMail(emailAddress, meta.verifymailAuthKey); - } else { - validated = await validateEmail({ - email: emailAddress, - validateRegex: true, - validateMx: true, - validateTypo: false, // TLDを見ているみたいだけどclubとか弾かれるので - validateDisposable: true, // 捨てアドかどうかチェック - validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので - }); - } - } else { - validated = { valid: true, reason: null }; - } - - const emailDomain: string = emailAddress.split('@')[1]; - const isBanned = this.utilityService.isBlockedHost(meta.bannedEmailDomains, emailDomain); - - const available = exist === 0 && validated.valid && !isBanned; - - return { - available, - reason: available ? null : - exist !== 0 ? 'used' : - isBanned ? 'banned' : - validated.reason === 'regex' ? 'format' : - validated.reason === 'disposable' ? 'disposable' : - validated.reason === 'mx' ? 'mx' : - validated.reason === 'smtp' ? 'smtp' : - null, - }; - } - - private async verifyMail(emailAddress: string, verifymailAuthKey: string): Promise<{ - valid: boolean; - reason: 'used' | 'format' | 'disposable' | 'mx' | 'smtp' | null; - }> { - const endpoint = 'https://verifymail.io/api/' + emailAddress + '?key=' + verifymailAuthKey; - const res = await this.httpRequestService.send(endpoint, { - method: 'GET', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json, */*', - }, - }); - - const json = (await res.json()) as { - block: boolean; - catch_all: boolean; - deliverable_email: boolean; - disposable: boolean; - domain: string; - email_address: string; - email_provider: string; - mx: boolean; - mx_fallback: boolean; - mx_host: string[]; - mx_ip: string[]; - mx_priority: { [key: string]: number }; - privacy: boolean; - related_domains: string[]; - }; - - if (json.email_address === undefined) { - return { - valid: false, - reason: 'format', - }; - } - if (json.deliverable_email !== undefined && !json.deliverable_email) { - return { - valid: false, - reason: 'smtp', - }; - } - if (json.disposable) { - return { - valid: false, - reason: 'disposable', - }; - } - if (json.mx !== undefined && !json.mx) { - return { - valid: false, - reason: 'mx', - }; - } - - return { - valid: true, - reason: null, - }; - } -} diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts deleted file mode 100644 index 6d857d189e..0000000000 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ /dev/null @@ -1,192 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiNote } from '@/models/Note.js'; -import { Packed } from '@/misc/json-schema.js'; -import type { NotesRepository } from '@/models/_.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { FanoutTimelineName, FanoutTimelineService } from '@/core/FanoutTimelineService.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; -import { CacheService } from '@/core/CacheService.js'; -import { isReply } from '@/misc/is-reply.js'; -import { isInstanceMuted } from '@/misc/is-instance-muted.js'; - -type TimelineOptions = { - untilId: string | null, - sinceId: string | null, - limit: number, - allowPartial: boolean, - me?: { id: MiUser['id'] } | undefined | null, - useDbFallback: boolean, - redisTimelines: FanoutTimelineName[], - noteFilter?: (note: MiNote) => boolean, - alwaysIncludeMyNotes?: boolean; - ignoreAuthorFromBlock?: boolean; - ignoreAuthorFromMute?: boolean; - excludeNoFiles?: boolean; - excludeReplies?: boolean; - excludeBots?: boolean; - excludePureRenotes: boolean; - dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise, -}; - -@Injectable() -export class FanoutTimelineEndpointService { - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - private noteEntityService: NoteEntityService, - private cacheService: CacheService, - private fanoutTimelineService: FanoutTimelineService, - ) { - } - - @bindThis - async timeline(ps: TimelineOptions): Promise[]> { - return await this.noteEntityService.packMany(await this.getMiNotes(ps), ps.me); - } - - @bindThis - private async getMiNotes(ps: TimelineOptions): Promise { - let noteIds: string[]; - let shouldFallbackToDb = false; - - // 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える - if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]); - - const shouldPrepend = ps.sinceId && !ps.untilId; - const idCompare: (a: string, b: string) => number = shouldPrepend ? (a, b) => a < b ? -1 : 1 : (a, b) => a > b ? -1 : 1; - - const redisResult = await this.fanoutTimelineService.getMulti(ps.redisTimelines, ps.untilId, ps.sinceId); - - // TODO: いい感じにgetMulti内でソート済だからuniqするときにredisResultが全てソート済なのを利用して再ソートを避けたい - const redisResultIds = Array.from(new Set(redisResult.flat(1))); - - redisResultIds.sort(idCompare); - noteIds = redisResultIds.slice(0, ps.limit); - - shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0); - - if (!shouldFallbackToDb) { - let filter = ps.noteFilter ?? (_note => true); - - if (ps.alwaysIncludeMyNotes && ps.me) { - const me = ps.me; - const parentFilter = filter; - filter = (note) => note.userId === me.id || parentFilter(note); - } - - if (ps.excludeNoFiles) { - const parentFilter = filter; - filter = (note) => note.fileIds.length !== 0 && parentFilter(note); - } - - if (ps.excludeReplies) { - const parentFilter = filter; - filter = (note) => !isReply(note, ps.me?.id) && parentFilter(note); - } - - if (ps.excludeBots) { - const parentFilter = filter; - filter = (note) => !note.user?.isBot && parentFilter(note); - } - - if (ps.excludePureRenotes) { - const parentFilter = filter; - filter = (note) => !isPureRenote(note) && parentFilter(note); - } - - if (ps.me) { - const me = ps.me; - const [ - userIdsWhoMeMuting, - userIdsWhoMeMutingRenotes, - userIdsWhoBlockingMe, - userMutedInstances, - ] = await Promise.all([ - this.cacheService.userMutingsCache.fetch(ps.me.id), - this.cacheService.renoteMutingsCache.fetch(ps.me.id), - this.cacheService.userBlockedCache.fetch(ps.me.id), - this.cacheService.userProfileCache.fetch(me.id).then(p => new Set(p.mutedInstances)), - ]); - - const parentFilter = filter; - filter = (note) => { - if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false; - if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false; - if (isPureRenote(note) && isUserRelated(note, userIdsWhoMeMutingRenotes, ps.ignoreAuthorFromMute)) return false; - if (isInstanceMuted(note, userMutedInstances)) return false; - - return parentFilter(note); - }; - } - - const redisTimeline: MiNote[] = []; - let readFromRedis = 0; - let lastSuccessfulRate = 1; // rateをキャッシュする? - - while ((redisResultIds.length - readFromRedis) !== 0) { - const remainingToRead = ps.limit - redisTimeline.length; - - // DBからの取り直しを減らす初回と同じ割合以上で成功すると仮定するが、クエリの長さを考えて三倍まで - const countToGet = Math.ceil(remainingToRead * Math.min(1.1 / lastSuccessfulRate, 3)); - noteIds = redisResultIds.slice(readFromRedis, readFromRedis + countToGet); - - readFromRedis += noteIds.length; - - const gotFromDb = await this.getAndFilterFromDb(noteIds, filter, idCompare); - redisTimeline.push(...gotFromDb); - lastSuccessfulRate = gotFromDb.length / noteIds.length; - - if (ps.allowPartial ? redisTimeline.length !== 0 : redisTimeline.length >= ps.limit) { - // 十分Redisからとれた - const result = redisTimeline.slice(0, ps.limit); - if (shouldPrepend) result.reverse(); - return result; - } - } - - // まだ足りない分はDBにフォールバック - const remainingToRead = ps.limit - redisTimeline.length; - let dbUntil: string | null; - let dbSince: string | null; - if (shouldPrepend) { - redisTimeline.reverse(); - dbUntil = ps.untilId; - dbSince = noteIds[noteIds.length - 1]; - } else { - dbUntil = noteIds[noteIds.length - 1]; - dbSince = ps.sinceId; - } - const gotFromDb = await ps.dbFallback(dbUntil, dbSince, remainingToRead); - return shouldPrepend ? [...gotFromDb, ...redisTimeline] : [...redisTimeline, ...gotFromDb]; - } - - return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit); - } - - private async getAndFilterFromDb(noteIds: string[], noteFilter: (note: MiNote) => boolean, idCompare: (a: string, b: string) => number): Promise { - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); - - const notes = (await query.getMany()).filter(noteFilter); - - notes.sort((a, b) => idCompare(a.id, b.id)); - - return notes; - } -} diff --git a/packages/backend/src/core/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts deleted file mode 100644 index 9b2678fbcd..0000000000 --- a/packages/backend/src/core/FanoutTimelineService.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * 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 { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; - -export type FanoutTimelineName = - // home timeline - | `homeTimeline:${string}` - | `homeTimelineWithFiles:${string}` // only notes with files are included - // local timeline - | `localTimeline` // replies are not included - | `localTimelineWithFiles` // only non-reply notes with files are included - | `localTimelineWithReplies` // only replies are included - | `localTimelineWithReplyTo:${string}` // Only replies to specific local user are included. Parameter is reply user id. - - // antenna - | `antennaTimeline:${string}` - - // user timeline - | `userTimeline:${string}` // replies are not included - | `userTimelineWithFiles:${string}` // only non-reply notes with files are included - | `userTimelineWithReplies:${string}` // only replies are included - | `userTimelineWithChannel:${string}` // only channel notes are included, replies are included - - // user list timelines - | `userListTimeline:${string}` - | `userListTimelineWithFiles:${string}` // only notes with files are included - - // channel timelines - | `channelTimeline:${string}` // replies are included - - // role timelines - | `roleTimeline:${string}` // any notes are included - -@Injectable() -export class FanoutTimelineService { - constructor( - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, - - private idService: IdService, - ) { - } - - @bindThis - public push(tl: FanoutTimelineName, id: string, maxlen: number, pipeline: Redis.ChainableCommander) { - // リモートから遅れて届いた(もしくは後から追加された)投稿日時が古い投稿が追加されるとページネーション時に問題を引き起こすため、 - // 3分以内に投稿されたものでない場合、Redisにある最古のIDより新しい場合のみ追加する - if (this.idService.parse(id).date.getTime() > Date.now() - 1000 * 60 * 3) { - pipeline.lpush('list:' + tl, id); - if (Math.random() < 0.1) { // 10%の確率でトリム - pipeline.ltrim('list:' + tl, 0, maxlen - 1); - } - } else { - // 末尾のIDを取得 - this.redisForTimelines.lindex('list:' + tl, -1).then(lastId => { - if (lastId == null || (this.idService.parse(id).date.getTime() > this.idService.parse(lastId).date.getTime())) { - this.redisForTimelines.lpush('list:' + tl, id); - } else { - Promise.resolve(); - } - }); - } - } - - @bindThis - public get(name: FanoutTimelineName, untilId?: string | null, sinceId?: string | null) { - if (untilId && sinceId) { - return this.redisForTimelines.lrange('list:' + name, 0, -1) - .then(ids => ids.filter(id => id < untilId && id > sinceId).sort((a, b) => a > b ? -1 : 1)); - } else if (untilId) { - return this.redisForTimelines.lrange('list:' + name, 0, -1) - .then(ids => ids.filter(id => id < untilId).sort((a, b) => a > b ? -1 : 1)); - } else if (sinceId) { - return this.redisForTimelines.lrange('list:' + name, 0, -1) - .then(ids => ids.filter(id => id > sinceId).sort((a, b) => a < b ? -1 : 1)); - } else { - return this.redisForTimelines.lrange('list:' + name, 0, -1) - .then(ids => ids.sort((a, b) => a > b ? -1 : 1)); - } - } - - @bindThis - public getMulti(name: FanoutTimelineName[], untilId?: string | null, sinceId?: string | null): Promise { - const pipeline = this.redisForTimelines.pipeline(); - for (const n of name) { - pipeline.lrange('list:' + n, 0, -1); - } - return pipeline.exec().then(res => { - if (res == null) return []; - const tls = res.map(r => r[1] as string[]); - return tls.map(ids => - (untilId && sinceId) - ? ids.filter(id => id < untilId && id > sinceId).sort((a, b) => a > b ? -1 : 1) - : untilId - ? ids.filter(id => id < untilId).sort((a, b) => a > b ? -1 : 1) - : sinceId - ? ids.filter(id => id > sinceId).sort((a, b) => a < b ? -1 : 1) - : ids.sort((a, b) => a > b ? -1 : 1), - ); - }); - } - - @bindThis - public purge(name: FanoutTimelineName) { - return this.redisForTimelines.del('list:' + name); - } -} diff --git a/packages/backend/src/core/FeaturedService.ts b/packages/backend/src/core/FeaturedService.ts deleted file mode 100644 index 595383c82c..0000000000 --- a/packages/backend/src/core/FeaturedService.ts +++ /dev/null @@ -1,145 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import type { MiGalleryPost, MiNote, MiUser } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; - -const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと -export const GALLERY_POSTS_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと -const PER_USER_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 7; // 1週間ごと -const HASHTAG_RANKING_WINDOW = 1000 * 60 * 60; // 1時間ごと - -const featuredEpoc = new Date('2023-01-01T00:00:00Z').getTime(); - -@Injectable() -export class FeaturedService { - constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする - ) { - } - - @bindThis - private getCurrentWindow(windowRange: number): number { - const passed = new Date().getTime() - featuredEpoc; - return Math.floor(passed / windowRange); - } - - @bindThis - private async updateRankingOf(name: string, windowRange: number, element: string, score = 1): Promise { - const currentWindow = this.getCurrentWindow(windowRange); - const redisTransaction = this.redisClient.multi(); - redisTransaction.zincrby( - `${name}:${currentWindow}`, - score, - element); - redisTransaction.expire( - `${name}:${currentWindow}`, - (windowRange * 3) / 1000, - 'NX'); // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定 - await redisTransaction.exec(); - } - - @bindThis - private async getRankingOf(name: string, windowRange: number, threshold: number): Promise { - const currentWindow = this.getCurrentWindow(windowRange); - const previousWindow = currentWindow - 1; - - const redisPipeline = this.redisClient.pipeline(); - redisPipeline.zrange( - `${name}:${currentWindow}`, 0, threshold, 'REV', 'WITHSCORES'); - redisPipeline.zrange( - `${name}:${previousWindow}`, 0, threshold, 'REV', 'WITHSCORES'); - const [currentRankingResult, previousRankingResult] = await redisPipeline.exec().then(result => result ? result.map(r => (r[1] ?? []) as string[]) : [[], []]); - - const ranking = new Map(); - for (let i = 0; i < currentRankingResult.length; i += 2) { - const noteId = currentRankingResult[i]; - const score = parseInt(currentRankingResult[i + 1], 10); - ranking.set(noteId, score); - } - for (let i = 0; i < previousRankingResult.length; i += 2) { - const noteId = previousRankingResult[i]; - const score = parseInt(previousRankingResult[i + 1], 10); - const exist = ranking.get(noteId); - if (exist != null) { - ranking.set(noteId, (exist + score) / 2); - } else { - ranking.set(noteId, score); - } - } - - return Array.from(ranking.keys()); - } - - @bindThis - private async removeFromRanking(name: string, windowRange: number, element: string): Promise { - const currentWindow = this.getCurrentWindow(windowRange); - const previousWindow = currentWindow - 1; - - const redisPipeline = this.redisClient.pipeline(); - redisPipeline.zrem(`${name}:${currentWindow}`, element); - redisPipeline.zrem(`${name}:${previousWindow}`, element); - await redisPipeline.exec(); - } - - @bindThis - public updateGlobalNotesRanking(noteId: MiNote['id'], score = 1): Promise { - return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score); - } - - @bindThis - public updateGalleryPostsRanking(galleryPostId: MiGalleryPost['id'], score = 1): Promise { - return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPostId, score); - } - - @bindThis - public updateInChannelNotesRanking(channelId: MiNote['channelId'], noteId: MiNote['id'], score = 1): Promise { - return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score); - } - - @bindThis - public updatePerUserNotesRanking(userId: MiUser['id'], noteId: MiNote['id'], score = 1): Promise { - return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, noteId, score); - } - - @bindThis - public updateHashtagsRanking(hashtag: string, score = 1): Promise { - return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score); - } - - @bindThis - public getGlobalNotesRanking(threshold: number): Promise { - return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, threshold); - } - - @bindThis - public getGalleryPostsRanking(threshold: number): Promise { - return this.getRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, threshold); - } - - @bindThis - public getInChannelNotesRanking(channelId: MiNote['channelId'], threshold: number): Promise { - return this.getRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, threshold); - } - - @bindThis - public getPerUserNotesRanking(userId: MiUser['id'], threshold: number): Promise { - return this.getRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, threshold); - } - - @bindThis - public getHashtagsRanking(threshold: number): Promise { - return this.getRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, threshold); - } - - @bindThis - public removeHashtagsFromRanking(hashtag: string): Promise { - return this.removeFromRanking('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag); - } -} diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts deleted file mode 100644 index e41f010e48..0000000000 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import type { InstancesRepository } from '@/models/_.js'; -import type { MiInstance } from '@/models/Instance.js'; -import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; -import { IdService } from '@/core/IdService.js'; -import { DI } from '@/di-symbols.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class FederatedInstanceService implements OnApplicationShutdown { - public federatedInstanceCache: RedisKVCache; - - constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, - - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, - - private utilityService: UtilityService, - private idService: IdService, - ) { - this.federatedInstanceCache = new RedisKVCache(this.redisClient, 'federatedInstance', { - lifetime: 1000 * 60 * 30, // 30m - memoryCacheLifetime: 1000 * 60 * 3, // 3m - fetcher: (key) => this.instancesRepository.findOneBy({ host: key }), - toRedisConverter: (value) => JSON.stringify(value), - fromRedisConverter: (value) => { - const parsed = JSON.parse(value); - if (parsed == null) return null; - return { - ...parsed, - firstRetrievedAt: new Date(parsed.firstRetrievedAt), - latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null, - infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null, - }; - }, - }); - } - - @bindThis - public async fetch(host: string): Promise { - host = this.utilityService.toPuny(host); - - const cached = await this.federatedInstanceCache.get(host); - if (cached) return cached; - - const index = await this.instancesRepository.findOneBy({ host }); - - if (index == null) { - const i = await this.instancesRepository.insert({ - id: this.idService.gen(), - host, - firstRetrievedAt: new Date(), - }).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0])); - - this.federatedInstanceCache.set(host, i); - return i; - } else { - this.federatedInstanceCache.set(host, index); - return index; - } - } - - @bindThis - public async update(id: MiInstance['id'], data: Partial): Promise { - const result = await this.instancesRepository.createQueryBuilder().update() - .set(data) - .where('id = :id', { id }) - .returning('*') - .execute() - .then((response) => { - return response.raw[0]; - }); - - this.federatedInstanceCache.set(result.host, result); - } - - @bindThis - public dispose(): void { - this.federatedInstanceCache.dispose(); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts deleted file mode 100644 index 682acef15b..0000000000 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ /dev/null @@ -1,314 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { URL } from 'node:url'; -import { Inject, Injectable } from '@nestjs/common'; -import { JSDOM } from 'jsdom'; -import tinycolor from 'tinycolor2'; -import * as Redis from 'ioredis'; -import type { MiInstance } from '@/models/Instance.js'; -import type Logger from '@/logger.js'; -import { DI } from '@/di-symbols.js'; -import { LoggerService } from '@/core/LoggerService.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { bindThis } from '@/decorators.js'; -import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import type { DOMWindow } from 'jsdom'; - -type NodeInfo = { - openRegistrations?: unknown; - software?: { - name?: unknown; - version?: unknown; - }; - metadata?: { - name?: unknown; - nodeName?: unknown; - nodeDescription?: unknown; - description?: unknown; - maintainer?: { - name?: unknown; - email?: unknown; - }; - themeColor?: unknown; - }; -}; - -@Injectable() -export class FetchInstanceMetadataService { - private logger: Logger; - - constructor( - private httpRequestService: HttpRequestService, - private loggerService: LoggerService, - private federatedInstanceService: FederatedInstanceService, - @Inject(DI.redis) - private redisClient: Redis.Redis, - ) { - this.logger = this.loggerService.getLogger('metadata', 'cyan'); - } - - @bindThis - public async tryLock(host: string): Promise { - const mutex = await this.redisClient.set(`fetchInstanceMetadata:mutex:${host}`, '1', 'GET'); - return mutex !== '1'; - } - - @bindThis - public unlock(host: string): Promise<'OK'> { - return this.redisClient.set(`fetchInstanceMetadata:mutex:${host}`, '0'); - } - - @bindThis - public async fetchInstanceMetadata(instance: MiInstance, force = false): Promise { - const host = instance.host; - // Acquire mutex to ensure no parallel runs - if (!await this.tryLock(host)) return; - try { - if (!force) { - const _instance = await this.federatedInstanceService.fetch(host); - const now = Date.now(); - if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) { - // unlock at the finally caluse - return; - } - } - - this.logger.info(`Fetching metadata of ${instance.host} ...`); - - const [info, dom, manifest] = await Promise.all([ - this.fetchNodeinfo(instance).catch(() => null), - this.fetchDom(instance).catch(() => null), - this.fetchManifest(instance).catch(() => null), - ]); - - const [favicon, icon, themeColor, name, description] = await Promise.all([ - this.fetchFaviconUrl(instance, dom).catch(() => null), - this.fetchIconUrl(instance, dom, manifest).catch(() => null), - this.getThemeColor(info, dom, manifest).catch(() => null), - this.getSiteName(info, dom, manifest).catch(() => null), - this.getDescription(info, dom, manifest).catch(() => null), - ]); - - this.logger.succ(`Successfuly fetched metadata of ${instance.host}`); - - const updates = { - infoUpdatedAt: new Date(), - } as Record; - - if (info) { - updates.softwareName = typeof info.software?.name === 'string' ? info.software.name.toLowerCase() : '?'; - updates.softwareVersion = info.software?.version; - updates.openRegistrations = info.openRegistrations; - updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null; - updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email ?? null) : null : null; - } - - if (name) updates.name = name; - if (description) updates.description = description; - if (icon ?? favicon) updates.iconUrl = (icon && !icon.includes('data:image/png;base64')) ? icon : favicon; - if (favicon) updates.faviconUrl = favicon; - if (themeColor) updates.themeColor = themeColor; - - await this.federatedInstanceService.update(instance.id, updates); - - this.logger.succ(`Successfuly updated metadata of ${instance.host}`); - } catch (e) { - this.logger.error(`Failed to update metadata of ${instance.host}: ${e}`); - } finally { - await this.unlock(host); - } - } - - @bindThis - private async fetchNodeinfo(instance: MiInstance): Promise { - this.logger.info(`Fetching nodeinfo of ${instance.host} ...`); - - try { - const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo') - .catch(err => { - if (err.statusCode === 404) { - throw new Error('No nodeinfo provided'); - } else { - throw err.statusCode ?? err.message; - } - }) as Record; - - if (wellknown.links == null || !Array.isArray(wellknown.links)) { - throw new Error('No wellknown links'); - } - - const links = wellknown.links as any[]; - - const link1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0'); - const link2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0'); - const link2_1 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.1'); - const link = link2_1 ?? link2_0 ?? link1_0; - - if (link == null) { - throw new Error('No nodeinfo link provided'); - } - - const info = await this.httpRequestService.getJson(link.href) - .catch(err => { - throw err.statusCode ?? err.message; - }); - - this.logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`); - - return info as NodeInfo; - } catch (err) { - this.logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${err}`); - - throw err; - } - } - - @bindThis - private async fetchDom(instance: MiInstance): Promise { - this.logger.info(`Fetching HTML of ${instance.host} ...`); - - const url = 'https://' + instance.host; - - const html = await this.httpRequestService.getHtml(url); - - const { window } = new JSDOM(html); - const doc = window.document; - - return doc; - } - - @bindThis - private async fetchManifest(instance: MiInstance): Promise | null> { - const url = 'https://' + instance.host; - - const manifestUrl = url + '/manifest.json'; - - const manifest = await this.httpRequestService.getJson(manifestUrl) as Record; - - return manifest; - } - - @bindThis - private async fetchFaviconUrl(instance: MiInstance, doc: DOMWindow['document'] | null): Promise { - const url = 'https://' + instance.host; - - if (doc) { - // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 - const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.relList.contains('icon'))?.href; - - if (href) { - return (new URL(href, url)).href; - } - } - - const faviconUrl = url + '/favicon.ico'; - - const favicon = await this.httpRequestService.send(faviconUrl, { - method: 'HEAD', - }, { throwErrorWhenResponseNotOk: false }); - - if (favicon.ok) { - return faviconUrl; - } - - return null; - } - - @bindThis - private async fetchIconUrl(instance: MiInstance, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { - if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) { - const url = 'https://' + instance.host; - return (new URL(manifest.icons[0].src, url)).href; - } - - if (doc) { - const url = 'https://' + instance.host; - - // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 - const links = Array.from(doc.getElementsByTagName('link')).reverse(); - // https://github.com/misskey-dev/misskey/pull/8220/files/0ec4eba22a914e31b86874f12448f88b3e58dd5a#r796487559 - const href = - [ - links.find(link => link.relList.contains('apple-touch-icon-precomposed'))?.href, - links.find(link => link.relList.contains('apple-touch-icon'))?.href, - links.find(link => link.relList.contains('icon'))?.href, - ] - .find(href => href); - - if (href) { - return (new URL(href, url)).href; - } - } - - return null; - } - - @bindThis - private async getThemeColor(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { - const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color; - - if (themeColor) { - const color = new tinycolor(themeColor); - if (color.isValid()) return color.toHexString(); - } - - return null; - } - - @bindThis - private async getSiteName(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { - if (info && info.metadata) { - if (typeof info.metadata.nodeName === 'string') { - return info.metadata.nodeName; - } else if (typeof info.metadata.name === 'string') { - return info.metadata.name; - } - } - - if (doc) { - const og = doc.querySelector('meta[property="og:title"]')?.getAttribute('content'); - - if (og) { - return og; - } - } - - if (manifest) { - return manifest.name ?? manifest.short_name; - } - - return null; - } - - @bindThis - private async getDescription(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { - if (info && info.metadata) { - if (typeof info.metadata.nodeDescription === 'string') { - return info.metadata.nodeDescription; - } else if (typeof info.metadata.description === 'string') { - return info.metadata.description; - } - } - - if (doc) { - const meta = doc.querySelector('meta[name="description"]')?.getAttribute('content'); - if (meta) { - return meta; - } - - const og = doc.querySelector('meta[property="og:description"]')?.getAttribute('content'); - if (og) { - return og; - } - } - - if (manifest) { - return manifest.name ?? manifest.short_name; - } - - return null; - } -} diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts deleted file mode 100644 index 803f6908de..0000000000 --- a/packages/backend/src/core/FileInfoService.ts +++ /dev/null @@ -1,261 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as fs from 'node:fs'; -import * as crypto from 'node:crypto'; -import * as stream from 'node:stream/promises'; -import { Injectable } from '@nestjs/common'; -import * as fileType from 'file-type'; -import isSvg from 'is-svg'; -import probeImageSize from 'probe-image-size'; -import sharp from 'sharp'; -import { encode } from 'blurhash'; -import { bindThis } from '@/decorators.js'; - -export type FileInfo = { - size: number; - md5: string; - type: { - mime: string; - ext: string | null; - }; - width?: number; - height?: number; - orientation?: number; - blurhash?: string; - sensitive: boolean; - porn: boolean; - warnings: string[]; -}; - -const TYPE_OCTET_STREAM = { - mime: 'application/octet-stream', - ext: null, -}; - -const TYPE_SVG = { - mime: 'image/svg+xml', - ext: 'svg', -}; - -@Injectable() -export class FileInfoService { - constructor( - ) { - } - - /** - * Get file information - */ - @bindThis - public async getFileInfo(path: string): Promise { - const warnings = [] as string[]; - - const size = await this.getFileSize(path); - const md5 = await this.calcHash(path); - - let type = await this.detectType(path); - - // image dimensions - let width: number | undefined; - let height: number | undefined; - let orientation: number | undefined; - - if ([ - 'image/png', - 'image/gif', - 'image/jpeg', - 'image/webp', - 'image/avif', - 'image/apng', - 'image/bmp', - 'image/tiff', - 'image/svg+xml', - 'image/vnd.adobe.photoshop', - ].includes(type.mime)) { - const imageSize = await this.detectImageSize(path).catch(e => { - warnings.push(`detectImageSize failed: ${e}`); - return undefined; - }); - - // うまく判定できない画像は octet-stream にする - if (!imageSize) { - warnings.push('cannot detect image dimensions'); - type = TYPE_OCTET_STREAM; - } else if (imageSize.wUnits === 'px') { - width = imageSize.width; - height = imageSize.height; - orientation = imageSize.orientation; - - // 制限を超えている画像は octet-stream にする - if (imageSize.width > 16383 || imageSize.height > 16383) { - warnings.push('image dimensions exceeds limits'); - type = TYPE_OCTET_STREAM; - } - } else { - warnings.push(`unsupported unit type: ${imageSize.wUnits}`); - } - } - - let blurhash: string | undefined; - - if ([ - 'image/jpeg', - 'image/gif', - 'image/png', - 'image/apng', - 'image/webp', - 'image/avif', - 'image/svg+xml', - ].includes(type.mime)) { - blurhash = await this.getBlurhash(path).catch(e => { - warnings.push(`getBlurhash failed: ${e}`); - return undefined; - }); - } - - const sensitive = false; - const porn = false; - - return { - size, - md5, - type, - width, - height, - orientation, - blurhash, - sensitive, - porn, - warnings, - }; - } - - @bindThis - public fixMime(mime: string | fileType.MimeType): string { - // see https://github.com/misskey-dev/misskey/pull/10686 - if (mime === 'audio/x-flac') { - return 'audio/flac'; - } - if (mime === 'audio/vnd.wave') { - return 'audio/wav'; - } - - return mime; - } - - /** - * Detect MIME Type and extension - */ - @bindThis - public async detectType(path: string): Promise<{ - mime: string; - ext: string | null; - }> { - // Check 0 byte - const fileSize = await this.getFileSize(path); - if (fileSize === 0) { - return TYPE_OCTET_STREAM; - } - - const type = await fileType.fileTypeFromFile(path); - - if (type) { - // XMLはSVGかもしれない - if (type.mime === 'application/xml' && await this.checkSvg(path)) { - return TYPE_SVG; - } - - return { - mime: this.fixMime(type.mime), - ext: type.ext, - }; - } - - // 種類が不明でもSVGかもしれない - if (await this.checkSvg(path)) { - return TYPE_SVG; - } - - // それでも種類が不明なら application/octet-stream にする - return TYPE_OCTET_STREAM; - } - - /** - * Check the file is SVG or not - */ - @bindThis - public async checkSvg(path: string): Promise { - try { - const size = await this.getFileSize(path); - if (size > 1 * 1024 * 1024) return false; - const buffer = await fs.promises.readFile(path); - return isSvg(buffer.toString()); - } catch { - return false; - } - } - - /** - * Get file size - */ - @bindThis - public async getFileSize(path: string): Promise { - return (await fs.promises.stat(path)).size; - } - - /** - * Calculate MD5 hash - */ - @bindThis - private async calcHash(path: string): Promise { - const hash = crypto.createHash('md5').setEncoding('hex'); - await stream.pipeline(fs.createReadStream(path), hash); - return hash.read(); - } - - /** - * Detect dimensions of image - */ - @bindThis - private async detectImageSize(path: string): Promise<{ - width: number; - height: number; - wUnits: string; - hUnits: string; - orientation?: number; -}> { - const readable = fs.createReadStream(path); - const imageSize = await probeImageSize(readable); - readable.destroy(); - return imageSize; - } - - /** - * Calculate average color of image - */ - @bindThis - private getBlurhash(path: string): Promise { - return new Promise((resolve, reject) => { - sharp(path) - .raw() - .ensureAlpha() - .resize(64, 64, { fit: 'inside' }) - .toBuffer((err, buffer, info) => { - if (err) return reject(err); - - let hash; - - try { - hash = encode(new Uint8ClampedArray(buffer), info.width, info.height, 5, 5); - } catch (e) { - return reject(e); - } - - resolve(hash); - }); - }); - } -} diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts deleted file mode 100644 index 95a4eba742..0000000000 --- a/packages/backend/src/core/GlobalEventService.ts +++ /dev/null @@ -1,344 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import type { MiChannel } from '@/models/Channel.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiUserProfile } from '@/models/UserProfile.js'; -import type { MiNote } from '@/models/Note.js'; -import type { MiAntenna } from '@/models/Antenna.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiDriveFolder } from '@/models/DriveFolder.js'; -import type { MiUserList } from '@/models/UserList.js'; -import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; -import type { MiSignin } from '@/models/Signin.js'; -import type { MiPage } from '@/models/Page.js'; -import type { MiWebhook } from '@/models/Webhook.js'; -import type { MiMeta } from '@/models/Meta.js'; -import { MiAvatarDecoration, MiRole, MiRoleAssignment } from '@/models/_.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { bindThis } from '@/decorators.js'; -import { Serialized } from '@/types.js'; -import type Emitter from 'strict-event-emitter-types'; -import type { EventEmitter } from 'events'; - -//#region Stream type-body definitions -export interface BroadcastTypes { - emojiAdded: { - emoji: Packed<'EmojiDetailed'>; - }; - emojiUpdated: { - emojis: Packed<'EmojiDetailed'>[]; - }; - emojiDeleted: { - emojis: { - id?: string; - name: string; - [other: string]: any; - }[]; - }; - announcementCreated: { - announcement: Packed<'Announcement'>; - }; -} - -export interface MainEventTypes { - notification: Packed<'Notification'>; - mention: Packed<'Note'>; - reply: Packed<'Note'>; - renote: Packed<'Note'>; - follow: Packed<'UserDetailedNotMe'>; - followed: Packed<'User'>; - unfollow: Packed<'User'>; - meUpdated: Packed<'User'>; - pageEvent: { - pageId: MiPage['id']; - event: string; - var: any; - userId: MiUser['id']; - user: Packed<'User'>; - }; - urlUploadFinished: { - marker?: string | null; - file: Packed<'DriveFile'>; - }; - readAllNotifications: undefined; - unreadNotification: Packed<'Notification'>; - unreadMention: MiNote['id']; - readAllUnreadMentions: undefined; - unreadSpecifiedNote: MiNote['id']; - readAllUnreadSpecifiedNotes: undefined; - readAllAntennas: undefined; - unreadAntenna: MiAntenna; - readAllAnnouncements: undefined; - myTokenRegenerated: undefined; - signin: { - id: MiSignin['id']; - createdAt: string; - ip: string; - headers: Record; - success: boolean; - }; - registryUpdated: { - scope?: string[]; - key: string; - value: any | null; - }; - driveFileCreated: Packed<'DriveFile'>; - readAntenna: MiAntenna; - receiveFollowRequest: Packed<'User'>; - announcementCreated: { - announcement: Packed<'Announcement'>; - }; -} - -export interface DriveEventTypes { - fileCreated: Packed<'DriveFile'>; - fileDeleted: MiDriveFile['id']; - fileUpdated: Packed<'DriveFile'>; - folderCreated: Packed<'DriveFolder'>; - folderDeleted: MiDriveFolder['id']; - folderUpdated: Packed<'DriveFolder'>; -} - -export interface NoteEventTypes { - pollVoted: { - choice: number; - userId: MiUser['id']; - }; - deleted: { - deletedAt: Date; - }; - updated: { - cw: string | null; - text: string; - }; - reacted: { - reaction: string; - emoji?: { - name: string; - url: string; - } | null; - userId: MiUser['id']; - }; - unreacted: { - reaction: string; - userId: MiUser['id']; - }; - replied: { - id: MiNote['id']; - }; -} -type NoteStreamEventTypes = { - [key in keyof NoteEventTypes]: { - id: MiNote['id']; - body: NoteEventTypes[key]; - }; -}; - -export interface UserListEventTypes { - userAdded: Packed<'User'>; - userRemoved: Packed<'User'>; -} - -export interface AntennaEventTypes { - note: MiNote; -} - -export interface RoleTimelineEventTypes { - note: Packed<'Note'>; -} - -export interface AdminEventTypes { - newAbuseUserReport: { - id: MiAbuseUserReport['id']; - targetUserId: MiUser['id'], - reporterId: MiUser['id'], - comment: string; - }; -} -//#endregion - -// 辞書(interface or type)から{ type, body }ユニオンを定義 -// https://stackoverflow.com/questions/49311989/can-i-infer-the-type-of-a-value-using-extends-keyof-type -// VS Codeの展開を防止するためにEvents型を定義 -type Events = { [K in keyof T]: { type: K; body: T[K]; } }; -type EventUnionFromDictionary< - T extends object, - U = Events -> = U[keyof U]; - -type SerializedAll = { - [K in keyof T]: Serialized; -}; - -export interface InternalEventTypes { - userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; }; - userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; }; - remoteUserUpdated: { id: MiUser['id']; }; - follow: { followerId: MiUser['id']; followeeId: MiUser['id']; }; - unfollow: { followerId: MiUser['id']; followeeId: MiUser['id']; }; - blockingCreated: { blockerId: MiUser['id']; blockeeId: MiUser['id']; }; - blockingDeleted: { blockerId: MiUser['id']; blockeeId: MiUser['id']; }; - policiesUpdated: MiRole['policies']; - roleCreated: MiRole; - roleDeleted: MiRole; - roleUpdated: MiRole; - userRoleAssigned: MiRoleAssignment; - userRoleUnassigned: MiRoleAssignment; - webhookCreated: MiWebhook; - webhookDeleted: MiWebhook; - webhookUpdated: MiWebhook; - antennaCreated: MiAntenna; - antennaDeleted: MiAntenna; - antennaUpdated: MiAntenna; - avatarDecorationCreated: MiAvatarDecoration; - avatarDecorationDeleted: MiAvatarDecoration; - avatarDecorationUpdated: MiAvatarDecoration; - metaUpdated: MiMeta; - followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; - unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; - updateUserProfile: MiUserProfile; - mute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; - unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; - userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; }; - userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; }; -} - -// name/messages(spec) pairs dictionary -export type GlobalEvents = { - internal: { - name: 'internal'; - payload: EventUnionFromDictionary>; - }; - broadcast: { - name: 'broadcast'; - payload: EventUnionFromDictionary>; - }; - main: { - name: `mainStream:${MiUser['id']}`; - payload: EventUnionFromDictionary>; - }; - drive: { - name: `driveStream:${MiUser['id']}`; - payload: EventUnionFromDictionary>; - }; - note: { - name: `noteStream:${MiNote['id']}`; - payload: EventUnionFromDictionary>; - }; - userList: { - name: `userListStream:${MiUserList['id']}`; - payload: EventUnionFromDictionary>; - }; - roleTimeline: { - name: `roleTimelineStream:${MiRole['id']}`; - payload: EventUnionFromDictionary>; - }; - antenna: { - name: `antennaStream:${MiAntenna['id']}`; - payload: EventUnionFromDictionary>; - }; - admin: { - name: `adminStream:${MiUser['id']}`; - payload: EventUnionFromDictionary>; - }; - notes: { - name: 'notesStream'; - payload: Serialized>; - }; -}; - -// API event definitions -// ストリームごとのEmitterの辞書を用意 -type EventEmitterDictionary = { [x in keyof GlobalEvents]: Emitter.default void }> }; -// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection -type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; -// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする -export type StreamEventEmitter = UnionToIntersection; -// { [y in name]: (e: spec) => void }をまとめてその交差型をEmitterにかけるとts(2590)にひっかかる - -// provide stream channels union -export type StreamChannels = GlobalEvents[keyof GlobalEvents]['name']; - -@Injectable() -export class GlobalEventService { - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.redisForPub) - private redisForPub: Redis.Redis, - ) { - } - - @bindThis - private publish(channel: StreamChannels, type: string | null, value?: any): void { - const message = type == null ? value : value == null ? - { type: type, body: null } : - { type: type, body: value }; - - this.redisForPub.publish(this.config.host, JSON.stringify({ - channel: channel, - message: message, - })); - } - - @bindThis - public publishInternalEvent(type: K, value?: InternalEventTypes[K]): void { - this.publish('internal', type, typeof value === 'undefined' ? null : value); - } - - @bindThis - public publishBroadcastStream(type: K, value?: BroadcastTypes[K]): void { - this.publish('broadcast', type, typeof value === 'undefined' ? null : value); - } - - @bindThis - public publishMainStream(userId: MiUser['id'], type: K, value?: MainEventTypes[K]): void { - this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value); - } - - @bindThis - public publishDriveStream(userId: MiUser['id'], type: K, value?: DriveEventTypes[K]): void { - this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value); - } - - @bindThis - public publishNoteStream(noteId: MiNote['id'], type: K, value?: NoteEventTypes[K]): void { - this.publish(`noteStream:${noteId}`, type, { - id: noteId, - body: value, - }); - } - - @bindThis - public publishUserListStream(listId: MiUserList['id'], type: K, value?: UserListEventTypes[K]): void { - this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value); - } - - @bindThis - public publishAntennaStream(antennaId: MiAntenna['id'], type: K, value?: AntennaEventTypes[K]): void { - this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); - } - - @bindThis - public publishRoleTimelineStream(roleId: MiRole['id'], type: K, value?: RoleTimelineEventTypes[K]): void { - this.publish(`roleTimelineStream:${roleId}`, type, typeof value === 'undefined' ? null : value); - } - - @bindThis - public publishNotesStream(note: Packed<'Note'>): void { - this.publish('notesStream', null, note); - } - - @bindThis - public publishAdminStream(userId: MiUser['id'], type: K, value?: AdminEventTypes[K]): void { - this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value); - } -} diff --git a/packages/backend/src/core/HashtagService.ts b/packages/backend/src/core/HashtagService.ts deleted file mode 100644 index 5a2417c9cd..0000000000 --- a/packages/backend/src/core/HashtagService.ts +++ /dev/null @@ -1,251 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * 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 { MiUser } from '@/models/User.js'; -import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { IdService } from '@/core/IdService.js'; -import type { MiHashtag } from '@/models/Hashtag.js'; -import type { HashtagsRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { bindThis } from '@/decorators.js'; -import { FeaturedService } from '@/core/FeaturedService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { UtilityService } from '@/core/UtilityService.js'; - -@Injectable() -export class HashtagService { - constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする - - @Inject(DI.hashtagsRepository) - private hashtagsRepository: HashtagsRepository, - - private userEntityService: UserEntityService, - private featuredService: FeaturedService, - private idService: IdService, - private metaService: MetaService, - private utilityService: UtilityService, - ) { - } - - @bindThis - public async updateHashtags(user: { id: MiUser['id']; host: MiUser['host']; }, tags: string[]) { - for (const tag of tags) { - await this.updateHashtag(user, tag); - } - } - - @bindThis - public async updateUsertags(user: MiUser, tags: string[]) { - for (const tag of tags) { - await this.updateHashtag(user, tag, true, true); - } - - for (const tag of user.tags.filter(x => !tags.includes(x))) { - await this.updateHashtag(user, tag, true, false); - } - } - - @bindThis - public async updateHashtag(user: { id: MiUser['id']; host: MiUser['host']; }, tag: string, isUserAttached = false, inc = true) { - tag = normalizeForSearch(tag); - - // TODO: サンプリング - this.updateHashtagsRanking(tag, user.id); - - const index = await this.hashtagsRepository.findOneBy({ name: tag }); - - if (index == null && !inc) return; - - if (index != null) { - const q = this.hashtagsRepository.createQueryBuilder('tag').update() - .where('name = :name', { name: tag }); - - const set = {} as any; - - if (isUserAttached) { - if (inc) { - // 自分が初めてこのタグを使ったなら - if (!index.attachedUserIds.some(id => id === user.id)) { - set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`; - set.attachedUsersCount = () => '"attachedUsersCount" + 1'; - } - // 自分が(ローカル内で)初めてこのタグを使ったなら - if (this.userEntityService.isLocalUser(user) && !index.attachedLocalUserIds.some(id => id === user.id)) { - set.attachedLocalUserIds = () => `array_append("attachedLocalUserIds", '${user.id}')`; - set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" + 1'; - } - // 自分が(リモートで)初めてこのタグを使ったなら - if (this.userEntityService.isRemoteUser(user) && !index.attachedRemoteUserIds.some(id => id === user.id)) { - set.attachedRemoteUserIds = () => `array_append("attachedRemoteUserIds", '${user.id}')`; - set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" + 1'; - } - } else { - set.attachedUserIds = () => `array_remove("attachedUserIds", '${user.id}')`; - set.attachedUsersCount = () => '"attachedUsersCount" - 1'; - if (this.userEntityService.isLocalUser(user)) { - set.attachedLocalUserIds = () => `array_remove("attachedLocalUserIds", '${user.id}')`; - set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" - 1'; - } else { - set.attachedRemoteUserIds = () => `array_remove("attachedRemoteUserIds", '${user.id}')`; - set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" - 1'; - } - } - } else { - // 自分が初めてこのタグを使ったなら - if (!index.mentionedUserIds.some(id => id === user.id)) { - set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`; - set.mentionedUsersCount = () => '"mentionedUsersCount" + 1'; - } - // 自分が(ローカル内で)初めてこのタグを使ったなら - if (this.userEntityService.isLocalUser(user) && !index.mentionedLocalUserIds.some(id => id === user.id)) { - set.mentionedLocalUserIds = () => `array_append("mentionedLocalUserIds", '${user.id}')`; - set.mentionedLocalUsersCount = () => '"mentionedLocalUsersCount" + 1'; - } - // 自分が(リモートで)初めてこのタグを使ったなら - if (this.userEntityService.isRemoteUser(user) && !index.mentionedRemoteUserIds.some(id => id === user.id)) { - set.mentionedRemoteUserIds = () => `array_append("mentionedRemoteUserIds", '${user.id}')`; - set.mentionedRemoteUsersCount = () => '"mentionedRemoteUsersCount" + 1'; - } - } - - if (Object.keys(set).length > 0) { - q.set(set); - q.execute(); - } - } else { - if (isUserAttached) { - this.hashtagsRepository.insert({ - id: this.idService.gen(), - name: tag, - mentionedUserIds: [], - mentionedUsersCount: 0, - mentionedLocalUserIds: [], - mentionedLocalUsersCount: 0, - mentionedRemoteUserIds: [], - mentionedRemoteUsersCount: 0, - attachedUserIds: [user.id], - attachedUsersCount: 1, - attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [], - attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0, - attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [], - attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0, - } as MiHashtag); - } else { - this.hashtagsRepository.insert({ - id: this.idService.gen(), - name: tag, - mentionedUserIds: [user.id], - mentionedUsersCount: 1, - mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [], - mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0, - mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [], - mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0, - attachedUserIds: [], - attachedUsersCount: 0, - attachedLocalUserIds: [], - attachedLocalUsersCount: 0, - attachedRemoteUserIds: [], - attachedRemoteUsersCount: 0, - } as MiHashtag); - } - } - } - - @bindThis - public async updateHashtagsRanking(hashtag: string, userId: MiUser['id']): Promise { - const instance = await this.metaService.fetch(); - const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t)); - if (hiddenTags.includes(hashtag)) return; - if (this.utilityService.isSensitiveWordIncluded(hashtag, instance.sensitiveWords)) return; - - // YYYYMMDDHHmm (10分間隔) - const now = new Date(); - now.setMinutes(Math.floor(now.getMinutes() / 10) * 10, 0, 0); - const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`; - - const exist = await this.redisClient.sismember(`hashtagUsers:${hashtag}`, userId); - if (exist === 1) return; - - this.featuredService.updateHashtagsRanking(hashtag, 1); - - const redisPipeline = this.redisClient.pipeline(); - - // チャート用 - redisPipeline.pfadd(`hashtagUsers:${hashtag}:${window}`, userId); - redisPipeline.expire(`hashtagUsers:${hashtag}:${window}`, - 60 * 60 * 24 * 3, // 3日間 - 'NX', // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定 - ); - - // ユニークカウント用 - // TODO: Bloom Filter を使うようにしても良さそう - redisPipeline.sadd(`hashtagUsers:${hashtag}`, userId); - redisPipeline.expire(`hashtagUsers:${hashtag}`, - 60 * 60, // 1時間 - 'NX', // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定 - ); - - redisPipeline.exec(); - } - - @bindThis - public async getChart(hashtag: string, range: number): Promise { - const now = new Date(); - now.setMinutes(Math.floor(now.getMinutes() / 10) * 10, 0, 0); - - const redisPipeline = this.redisClient.pipeline(); - - for (let i = 0; i < range; i++) { - const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`; - redisPipeline.pfcount(`hashtagUsers:${hashtag}:${window}`); - now.setMinutes(now.getMinutes() - (i * 10), 0, 0); - } - - const result = await redisPipeline.exec(); - - if (result == null) return []; - - return result.map(x => x[1]) as number[]; - } - - @bindThis - public async getCharts(hashtags: string[], range: number): Promise> { - const now = new Date(); - now.setMinutes(Math.floor(now.getMinutes() / 10) * 10, 0, 0); - - const redisPipeline = this.redisClient.pipeline(); - - for (let i = 0; i < range; i++) { - const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`; - for (const hashtag of hashtags) { - redisPipeline.pfcount(`hashtagUsers:${hashtag}:${window}`); - } - now.setMinutes(now.getMinutes() - (i * 10), 0, 0); - } - - const result = await redisPipeline.exec(); - - if (result == null) return {}; - - // key is hashtag - const charts = {} as Record; - for (const hashtag of hashtags) { - charts[hashtag] = []; - } - - for (let i = 0; i < range; i++) { - for (let j = 0; j < hashtags.length; j++) { - charts[hashtags[j]].push(result[(i * hashtags.length) + j][1] as number); - } - } - - return charts; - } -} diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts deleted file mode 100644 index 1352e137ce..0000000000 --- a/packages/backend/src/core/HttpRequestService.ts +++ /dev/null @@ -1,204 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as http from 'node:http'; -import * as https from 'node:https'; -import * as net from 'node:net'; -import CacheableLookup from 'cacheable-lookup'; -import fetch from 'node-fetch'; -import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { StatusError } from '@/misc/status-error.js'; -import { bindThis } from '@/decorators.js'; -import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; -import type { IObject } from '@/core/activitypub/type.js'; -import type { Response } from 'node-fetch'; -import type { URL } from 'node:url'; - -export type HttpRequestSendOptions = { - throwErrorWhenResponseNotOk: boolean; - validators?: ((res: Response) => void)[]; -}; - -@Injectable() -export class HttpRequestService { - /** - * Get http non-proxy agent - */ - private http: http.Agent; - - /** - * Get https non-proxy agent - */ - private https: https.Agent; - - /** - * Get http proxy or non-proxy agent - */ - public httpAgent: http.Agent; - - /** - * Get https proxy or non-proxy agent - */ - public httpsAgent: https.Agent; - - constructor( - @Inject(DI.config) - private config: Config, - ) { - const cache = new CacheableLookup({ - maxTtl: 3600, // 1hours - errorTtl: 30, // 30secs - lookup: false, // nativeのdns.lookupにfallbackしない - }); - - this.http = new http.Agent({ - keepAlive: true, - keepAliveMsecs: 30 * 1000, - lookup: cache.lookup as unknown as net.LookupFunction, - localAddress: config.outgoingAddress, - }); - - this.https = new https.Agent({ - keepAlive: true, - keepAliveMsecs: 30 * 1000, - lookup: cache.lookup as unknown as net.LookupFunction, - localAddress: config.outgoingAddress, - }); - - const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128); - - this.httpAgent = config.proxy - ? new HttpProxyAgent({ - keepAlive: true, - keepAliveMsecs: 30 * 1000, - maxSockets, - maxFreeSockets: 256, - scheduling: 'lifo', - proxy: config.proxy, - localAddress: config.outgoingAddress, - }) - : this.http; - - this.httpsAgent = config.proxy - ? new HttpsProxyAgent({ - keepAlive: true, - keepAliveMsecs: 30 * 1000, - maxSockets, - maxFreeSockets: 256, - scheduling: 'lifo', - proxy: config.proxy, - localAddress: config.outgoingAddress, - }) - : this.https; - } - - /** - * Get agent by URL - * @param url URL - * @param bypassProxy Allways bypass proxy - */ - @bindThis - public getAgentByUrl(url: URL, bypassProxy = false): http.Agent | https.Agent { - if (bypassProxy || (this.config.proxyBypassHosts ?? []).includes(url.hostname)) { - return url.protocol === 'http:' ? this.http : this.https; - } else { - return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent; - } - } - - @bindThis - public async getActivityJson(url: string): Promise { - const res = await this.send(url, { - method: 'GET', - headers: { - Accept: 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"', - }, - timeout: 5000, - size: 1024 * 256, - }, { - throwErrorWhenResponseNotOk: true, - validators: [validateContentTypeSetAsActivityPub], - }); - - return await res.json() as IObject; - } - - @bindThis - public async getJson(url: string, accept = 'application/json, */*', headers?: Record): Promise { - const res = await this.send(url, { - method: 'GET', - headers: Object.assign({ - Accept: accept, - }, headers ?? {}), - timeout: 5000, - size: 1024 * 256, - }); - - return await res.json() as T; - } - - @bindThis - public async getHtml(url: string, accept = 'text/html, */*', headers?: Record): Promise { - const res = await this.send(url, { - method: 'GET', - headers: Object.assign({ - Accept: accept, - }, headers ?? {}), - timeout: 5000, - }); - - return await res.text(); - } - - @bindThis - public async send( - url: string, - args: { - method?: string, - body?: string, - headers?: Record, - timeout?: number, - size?: number, - } = {}, - extra: HttpRequestSendOptions = { - throwErrorWhenResponseNotOk: true, - validators: [], - }, - ): Promise { - const timeout = args.timeout ?? 5000; - - const controller = new AbortController(); - setTimeout(() => { - controller.abort(); - }, timeout); - - const res = await fetch(url, { - method: args.method ?? 'GET', - headers: { - 'User-Agent': this.config.userAgent, - ...(args.headers ?? {}), - }, - body: args.body, - size: args.size ?? 10 * 1024 * 1024, - agent: (url) => this.getAgentByUrl(url), - signal: controller.signal, - }); - - if (!res.ok && extra.throwErrorWhenResponseNotOk) { - throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText); - } - - if (res.ok) { - for (const validator of (extra.validators ?? [])) { - validator(res); - } - } - - return res; - } -} diff --git a/packages/backend/src/core/IdService.ts b/packages/backend/src/core/IdService.ts deleted file mode 100644 index 43e72d2d7b..0000000000 --- a/packages/backend/src/core/IdService.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { ulid } from 'ulid'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { genAid, isSafeAidT, parseAid } from '@/misc/id/aid.js'; -import { genAidx, isSafeAidxT, parseAidx } from '@/misc/id/aidx.js'; -import { genMeid, isSafeMeidT, parseMeid } from '@/misc/id/meid.js'; -import { genMeidg, isSafeMeidgT, parseMeidg } from '@/misc/id/meidg.js'; -import { genObjectId, isSafeObjectIdT, parseObjectId } from '@/misc/id/object-id.js'; -import { bindThis } from '@/decorators.js'; -import { parseUlid } from '@/misc/id/ulid.js'; - -@Injectable() -export class IdService { - private method: string; - - constructor( - @Inject(DI.config) - private config: Config, - ) { - this.method = config.id.toLowerCase(); - } - - @bindThis - public isSafeT(t: number): boolean { - switch (this.method) { - case 'aid': return isSafeAidT(t); - case 'aidx': return isSafeAidxT(t); - case 'meid': return isSafeMeidT(t); - case 'meidg': return isSafeMeidgT(t); - case 'ulid': return t > 0; - case 'objectid': return isSafeObjectIdT(t); - default: throw new Error('unrecognized id generation method'); - } - } - - /** - * 時間を元にIDを生成します(省略時は現在日時) - * @param time 日時 - */ - @bindThis - public gen(time?: number): string { - const t = (!time || (time > Date.now())) ? Date.now() : time; - - switch (this.method) { - case 'aid': return genAid(t); - case 'aidx': return genAidx(t); - case 'meid': return genMeid(t); - case 'meidg': return genMeidg(t); - case 'ulid': return ulid(t); - case 'objectid': return genObjectId(t); - default: throw new Error('unrecognized id generation method'); - } - } - - @bindThis - public parse(id: string): { date: Date; } { - switch (this.method) { - case 'aid': return parseAid(id); - case 'aidx': return parseAidx(id); - case 'objectid': return parseObjectId(id); - case 'meid': return parseMeid(id); - case 'meidg': return parseMeidg(id); - case 'ulid': return parseUlid(id); - default: throw new Error('unrecognized id generation method'); - } - } -} diff --git a/packages/backend/src/core/ImageProcessingService.ts b/packages/backend/src/core/ImageProcessingService.ts deleted file mode 100644 index 8e800eb8f5..0000000000 --- a/packages/backend/src/core/ImageProcessingService.ts +++ /dev/null @@ -1,164 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import sharp from 'sharp'; - -export type IImage = { - data: Buffer; - ext: string | null; - type: string; -}; - -export type IImageStream = { - data: Readable; - ext: string | null; - type: string; -}; - -export type IImageSharp = { - data: sharp.Sharp; - ext: string | null; - type: string; -}; - -export type IImageStreamable = IImage | IImageStream | IImageSharp; - -export const webpDefault: sharp.WebpOptions = { - quality: 77, - alphaQuality: 95, - lossless: false, - nearLossless: false, - smartSubsample: true, - mixed: true, - effort: 2, -}; - -export const avifDefault: sharp.AvifOptions = { - quality: 60, - lossless: false, - effort: 2, -}; - -import { bindThis } from '@/decorators.js'; -import { Readable } from 'node:stream'; - -@Injectable() -export class ImageProcessingService { - constructor( - ) { - } - - /** - * Convert to WebP - * with resize, remove metadata, resolve orientation, stop animation - */ - @bindThis - public async convertToWebp(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise { - return this.convertSharpToWebp(sharp(path), width, height, options); - } - - @bindThis - public async convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise { - const result = this.convertSharpToWebpStream(sharp, width, height, options); - - return { - data: await result.data.toBuffer(), - ext: result.ext, - type: result.type, - }; - } - - @bindThis - public convertToWebpStream(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageSharp { - return this.convertSharpToWebpStream(sharp(path), width, height, options); - } - - @bindThis - public convertSharpToWebpStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageSharp { - const data = sharp - .resize(width, height, { - fit: 'inside', - withoutEnlargement: true, - }) - .rotate() - .webp(options); - - return { - data, - ext: 'webp', - type: 'image/webp', - }; - } - - /** - * Convert to Avif - * with resize, remove metadata, resolve orientation, stop animation - */ - @bindThis - public async convertToAvif(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise { - return this.convertSharpToAvif(sharp(path), width, height, options); - } - - @bindThis - public async convertSharpToAvif(sharp: sharp.Sharp, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise { - const result = this.convertSharpToAvifStream(sharp, width, height, options); - - return { - data: await result.data.toBuffer(), - ext: result.ext, - type: result.type, - }; - } - - @bindThis - public convertToAvifStream(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): IImageSharp { - return this.convertSharpToAvifStream(sharp(path), width, height, options); - } - - @bindThis - public convertSharpToAvifStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.AvifOptions = avifDefault): IImageSharp { - const data = sharp - .resize(width, height, { - fit: 'inside', - withoutEnlargement: true, - }) - .rotate() - .avif(options); - - return { - data, - ext: 'avif', - type: 'image/avif', - }; - } - - /** - * Convert to PNG - * with resize, remove metadata, resolve orientation, stop animation - */ - @bindThis - public async convertToPng(path: string, width: number, height: number): Promise { - return this.convertSharpToPng(sharp(path), width, height); - } - - @bindThis - public async convertSharpToPng(sharp: sharp.Sharp, width: number, height: number): Promise { - const data = await sharp - .resize(width, height, { - fit: 'inside', - withoutEnlargement: true, - }) - .rotate() - .png() - .toBuffer(); - - return { - data, - ext: 'png', - type: 'image/png', - }; - } -} diff --git a/packages/backend/src/core/InstanceActorService.ts b/packages/backend/src/core/InstanceActorService.ts deleted file mode 100644 index b40fd46291..0000000000 --- a/packages/backend/src/core/InstanceActorService.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull } from 'typeorm'; -import type { MiLocalUser } from '@/models/User.js'; -import type { UsersRepository } from '@/models/_.js'; -import { MemorySingleCache } from '@/misc/cache.js'; -import { DI } from '@/di-symbols.js'; -import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; -import { bindThis } from '@/decorators.js'; - -const ACTOR_USERNAME = 'instance.actor' as const; - -@Injectable() -export class InstanceActorService { - private cache: MemorySingleCache; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private createSystemUserService: CreateSystemUserService, - ) { - this.cache = new MemorySingleCache(Infinity); - } - - @bindThis - public async getInstanceActor(): Promise { - const cached = this.cache.get(); - if (cached) return cached; - - const user = await this.usersRepository.findOneBy({ - host: IsNull(), - username: ACTOR_USERNAME, - }) as MiLocalUser | undefined; - - if (user) { - this.cache.set(user); - return user; - } else { - const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as MiLocalUser; - this.cache.set(created); - return created; - } - } -} diff --git a/packages/backend/src/core/InternalStorageService.ts b/packages/backend/src/core/InternalStorageService.ts deleted file mode 100644 index 22129bb348..0000000000 --- a/packages/backend/src/core/InternalStorageService.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as fs from 'node:fs'; -import * as Path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { bindThis } from '@/decorators.js'; - -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); - -const path = Path.resolve(_dirname, '../../../../files'); - -@Injectable() -export class InternalStorageService { - constructor( - @Inject(DI.config) - private config: Config, - ) { - } - - @bindThis - public resolvePath(key: string) { - return Path.resolve(path, key); - } - - @bindThis - public read(key: string) { - return fs.createReadStream(this.resolvePath(key)); - } - - @bindThis - public saveFromPath(key: string, srcPath: string) { - fs.mkdirSync(path, { recursive: true }); - fs.copyFileSync(srcPath, this.resolvePath(key)); - return `${this.config.url}/files/${key}`; - } - - @bindThis - public saveFromBuffer(key: string, data: Buffer) { - fs.mkdirSync(path, { recursive: true }); - fs.writeFileSync(this.resolvePath(key), data); - return `${this.config.url}/files/${key}`; - } - - @bindThis - public del(key: string) { - fs.unlink(this.resolvePath(key), () => {}); - } -} diff --git a/packages/backend/src/core/LoggerService.ts b/packages/backend/src/core/LoggerService.ts deleted file mode 100644 index 46b000ee63..0000000000 --- a/packages/backend/src/core/LoggerService.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import Logger from '@/logger.js'; -import { bindThis } from '@/decorators.js'; -import type { KEYWORD } from 'color-convert/conversions.js'; - -@Injectable() -export class LoggerService { - constructor( - ) { - } - - @bindThis - public getLogger(domain: string, color?: KEYWORD | undefined, store?: boolean) { - return new Logger(domain, color, store); - } -} diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts deleted file mode 100644 index 80e8020961..0000000000 --- a/packages/backend/src/core/MetaService.ts +++ /dev/null @@ -1,156 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import * as Redis from 'ioredis'; -import { DI } from '@/di-symbols.js'; -import { MiMeta } from '@/models/Meta.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { bindThis } from '@/decorators.js'; -import type { GlobalEvents } from '@/core/GlobalEventService.js'; -import { FeaturedService } from '@/core/FeaturedService.js'; -import type { OnApplicationShutdown } from '@nestjs/common'; - -@Injectable() -export class MetaService implements OnApplicationShutdown { - private cache: MiMeta | undefined; - private intervalId: NodeJS.Timeout; - - constructor( - @Inject(DI.redisForSub) - private redisForSub: Redis.Redis, - - @Inject(DI.db) - private db: DataSource, - - private featuredService: FeaturedService, - private globalEventService: GlobalEventService, - ) { - //this.onMessage = this.onMessage.bind(this); - - if (process.env.NODE_ENV !== 'test') { - this.intervalId = setInterval(() => { - this.fetch(true).then(meta => { - // fetch内でもセットしてるけど仕様変更の可能性もあるため一応 - this.cache = meta; - }); - }, 1000 * 60 * 5); - } - - this.redisForSub.on('message', this.onMessage); - } - - @bindThis - private async onMessage(_: string, data: string): Promise { - const obj = JSON.parse(data); - - if (obj.channel === 'internal') { - const { type, body } = obj.message as GlobalEvents['internal']['payload']; - switch (type) { - case 'metaUpdated': { - this.cache = body; - break; - } - default: - break; - } - } - } - - @bindThis - public async fetch(noCache = false): Promise { - if (!noCache && this.cache) return this.cache; - - return await this.db.transaction(async transactionalEntityManager => { - // 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する - const metas = await transactionalEntityManager.find(MiMeta, { - order: { - id: 'DESC', - }, - }); - - const meta = metas[0]; - - if (meta) { - this.cache = meta; - return meta; - } else { - // metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う - const saved = await transactionalEntityManager - .upsert( - MiMeta, - { - id: 'x', - }, - ['id'], - ) - .then((x) => transactionalEntityManager.findOneByOrFail(MiMeta, x.identifiers[0])); - - this.cache = saved; - return saved; - } - }); - } - - @bindThis - public async update(data: Partial): Promise { - let before: MiMeta | undefined; - - const updated = await this.db.transaction(async transactionalEntityManager => { - const metas = await transactionalEntityManager.find(MiMeta, { - order: { - id: 'DESC', - }, - }); - - before = metas[0]; - - if (before) { - await transactionalEntityManager.update(MiMeta, before.id, data); - - const metas = await transactionalEntityManager.find(MiMeta, { - order: { - id: 'DESC', - }, - }); - - return metas[0]; - } else { - return await transactionalEntityManager.save(MiMeta, data); - } - }); - - if (data.hiddenTags) { - process.nextTick(() => { - const hiddenTags = new Set(data.hiddenTags); - if (before) { - for (const previousHiddenTag of before.hiddenTags) { - hiddenTags.delete(previousHiddenTag); - } - } - - for (const hiddenTag of hiddenTags) { - this.featuredService.removeHashtagsFromRanking(hiddenTag); - } - }); - } - - this.globalEventService.publishInternalEvent('metaUpdated', updated); - - return updated; - } - - @bindThis - public dispose(): void { - clearInterval(this.intervalId); - this.redisForSub.off('message', this.onMessage); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts deleted file mode 100644 index 60bf8b3c2a..0000000000 --- a/packages/backend/src/core/MfmService.ts +++ /dev/null @@ -1,667 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { URL } from 'node:url'; -import { Inject, Injectable } from '@nestjs/common'; -import * as parse5 from 'parse5'; -import { Window } from 'happy-dom'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { intersperse } from '@/misc/prelude/array.js'; -import type { IMentionedRemoteUsers } from '@/models/Note.js'; -import { bindThis } from '@/decorators.js'; -import * as TreeAdapter from '../../node_modules/parse5/dist/tree-adapters/default.js'; -import type * as mfm from '@sharkey/sfm-js'; - -const treeAdapter = TreeAdapter.defaultTreeAdapter; - -const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; -const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; - -@Injectable() -export class MfmService { - constructor( - @Inject(DI.config) - private config: Config, - ) { - } - - @bindThis - public fromHtml(html: string, hashtagNames?: string[]): string { - // some AP servers like Pixelfed use br tags as well as newlines - html = html.replace(/\r?\n/gi, '\n'); - - const dom = parse5.parseFragment(html); - - let text = ''; - - for (const n of dom.childNodes) { - analyze(n); - } - - return text.trim(); - - function getText(node: TreeAdapter.Node): string { - if (treeAdapter.isTextNode(node)) return node.value; - if (!treeAdapter.isElementNode(node)) return ''; - if (node.nodeName === 'br') return '\n'; - - if (node.childNodes) { - return node.childNodes.map(n => getText(n)).join(''); - } - - return ''; - } - - function appendChildren(childNodes: TreeAdapter.ChildNode[]): void { - if (childNodes) { - for (const n of childNodes) { - analyze(n); - } - } - } - - function analyze(node: TreeAdapter.Node) { - if (treeAdapter.isTextNode(node)) { - text += node.value; - return; - } - - // Skip comment or document type node - if (!treeAdapter.isElementNode(node)) return; - - switch (node.nodeName) { - case 'br': { - text += '\n'; - break; - } - - case 'a': - { - const txt = getText(node); - const rel = node.attrs.find(x => x.name === 'rel'); - const href = node.attrs.find(x => x.name === 'href'); - - // ハッシュタグ - if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) { - text += txt; - // メンション - } else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) { - const part = txt.split('@'); - - if (part.length === 2 && href) { - //#region ホスト名部分が省略されているので復元する - const acct = `${txt}@${(new URL(href.value)).hostname}`; - text += acct; - //#endregion - } else if (part.length === 3) { - text += txt; - } - // その他 - } else { - const generateLink = () => { - if (!href && !txt) { - return ''; - } - if (!href) { - return txt; - } - if (!txt || txt === href.value) { // #6383: Missing text node - if (href.value.match(urlRegexFull)) { - return href.value; - } else { - return `<${href.value}>`; - } - } - if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) { - return `[${txt}](<${href.value}>)`; // #6846 - } else { - return `[${txt}](${href.value})`; - } - }; - - text += generateLink(); - } - break; - } - - case 'h1': - { - text += '【'; - appendChildren(node.childNodes); - text += '】\n'; - break; - } - - case 'b': - case 'strong': - { - text += '**'; - appendChildren(node.childNodes); - text += '**'; - break; - } - - case 'small': - { - text += ''; - appendChildren(node.childNodes); - text += ''; - break; - } - - case 's': - case 'del': - { - text += '~~'; - appendChildren(node.childNodes); - text += '~~'; - break; - } - - case 'i': - case 'em': - { - text += ''; - appendChildren(node.childNodes); - text += ''; - break; - } - - // block code (
)
-				case 'pre': {
-					if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
-						text += '\n```\n';
-						text += getText(node.childNodes[0]);
-						text += '\n```\n';
-					} else {
-						appendChildren(node.childNodes);
-					}
-					break;
-				}
-
-				// inline code ()
-				case 'code': {
-					text += '`';
-					appendChildren(node.childNodes);
-					text += '`';
-					break;
-				}
-
-				case 'blockquote': {
-					const t = getText(node);
-					if (t) {
-						text += '\n> ';
-						text += t.split('\n').join('\n> ');
-					}
-					break;
-				}
-
-				case 'p':
-				case 'h2':
-				case 'h3':
-				case 'h4':
-				case 'h5':
-				case 'h6':
-				{
-					text += '\n\n';
-					appendChildren(node.childNodes);
-					break;
-				}
-
-				// other block elements
-				case 'div':
-				case 'header':
-				case 'footer':
-				case 'article':
-				case 'li':
-				case 'dt':
-				case 'dd':
-				{
-					text += '\n';
-					appendChildren(node.childNodes);
-					break;
-				}
-
-				default:	// includes inline elements
-				{
-					appendChildren(node.childNodes);
-					break;
-				}
-			}
-		}
-	}
-
-	@bindThis
-	public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) {
-		if (nodes == null) {
-			return null;
-		}
-
-		const { window } = new Window();
-
-		const doc = window.document;
-
-		function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
-			if (children) {
-				for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child);
-			}
-		}
-
-		function fnDefault(node: mfm.MfmFn) {
-			const el = doc.createElement('i');
-			appendChildren(node.children, el);
-			return el;
-		}
-
-		const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => any } = {
-			bold: (node) => {
-				const el = doc.createElement('b');
-				appendChildren(node.children, el);
-				return el;
-			},
-
-			small: (node) => {
-				const el = doc.createElement('small');
-				appendChildren(node.children, el);
-				return el;
-			},
-
-			strike: (node) => {
-				const el = doc.createElement('del');
-				appendChildren(node.children, el);
-				return el;
-			},
-
-			italic: (node) => {
-				const el = doc.createElement('i');
-				appendChildren(node.children, el);
-				return el;
-			},
-
-			fn: (node) => {
-				switch (node.props.name) {
-					case 'unixtime': {
-						const text = node.children[0].type === 'text' ? node.children[0].props.text : '';
-						try {
-							const date = new Date(parseInt(text, 10) * 1000);
-							const el = doc.createElement('time');
-							el.setAttribute('datetime', date.toISOString());
-							el.textContent = date.toISOString();
-							return el;
-						} catch (err) {
-							return fnDefault(node);
-						}
-					}
-
-					case 'ruby': {
-						if (node.children.length === 1) {
-							const child = node.children[0];
-							const text = child.type === 'text' ? child.props.text : '';
-							const rubyEl = doc.createElement('ruby');
-							const rtEl = doc.createElement('rt');
-
-							// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
-							const rpStartEl = doc.createElement('rp');
-							rpStartEl.appendChild(doc.createTextNode('('));
-							const rpEndEl = doc.createElement('rp');
-							rpEndEl.appendChild(doc.createTextNode(')'));
-
-							rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
-							rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
-							rubyEl.appendChild(rpStartEl);
-							rubyEl.appendChild(rtEl);
-							rubyEl.appendChild(rpEndEl);
-							return rubyEl;
-						} else {
-							const rt = node.children.at(-1);
-
-							if (!rt) {
-								return fnDefault(node);
-							}
-
-							const text = rt.type === 'text' ? rt.props.text : '';
-							const rubyEl = doc.createElement('ruby');
-							const rtEl = doc.createElement('rt');
-
-							// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
-							const rpStartEl = doc.createElement('rp');
-							rpStartEl.appendChild(doc.createTextNode('('));
-							const rpEndEl = doc.createElement('rp');
-							rpEndEl.appendChild(doc.createTextNode(')'));
-
-							appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
-							rtEl.appendChild(doc.createTextNode(text.trim()));
-							rubyEl.appendChild(rpStartEl);
-							rubyEl.appendChild(rtEl);
-							rubyEl.appendChild(rpEndEl);
-							return rubyEl;
-						}
-					}
-
-					default: {
-						return fnDefault(node);
-					}
-				}
-			},
-
-			blockCode: (node) => {
-				const pre = doc.createElement('pre');
-				const inner = doc.createElement('code');
-				inner.textContent = node.props.code;
-				pre.appendChild(inner);
-				return pre;
-			},
-
-			center: (node) => {
-				const el = doc.createElement('div');
-				appendChildren(node.children, el);
-				return el;
-			},
-
-			emojiCode: (node) => {
-				return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
-			},
-
-			unicodeEmoji: (node) => {
-				return doc.createTextNode(node.props.emoji);
-			},
-
-			hashtag: (node) => {
-				const a = doc.createElement('a');
-				a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
-				a.textContent = `#${node.props.hashtag}`;
-				a.setAttribute('rel', 'tag');
-				return a;
-			},
-
-			inlineCode: (node) => {
-				const el = doc.createElement('code');
-				el.textContent = node.props.code;
-				return el;
-			},
-
-			mathInline: (node) => {
-				const el = doc.createElement('code');
-				el.textContent = node.props.formula;
-				return el;
-			},
-
-			mathBlock: (node) => {
-				const el = doc.createElement('code');
-				el.textContent = node.props.formula;
-				return el;
-			},
-
-			link: (node) => {
-				const a = doc.createElement('a');
-				a.setAttribute('href', node.props.url);
-				appendChildren(node.children, a);
-				return a;
-			},
-
-			mention: (node) => {
-				const a = doc.createElement('a');
-				const { username, host, acct } = node.props;
-				const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
-				a.setAttribute('href', remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`);
-				a.className = 'u-url mention';
-				a.textContent = acct;
-				return a;
-			},
-
-			quote: (node) => {
-				const el = doc.createElement('blockquote');
-				appendChildren(node.children, el);
-				return el;
-			},
-
-			text: (node) => {
-				const el = doc.createElement('span');
-				const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
-
-				for (const x of intersperse('br', nodes)) {
-					el.appendChild(x === 'br' ? doc.createElement('br') : x);
-				}
-
-				return el;
-			},
-
-			url: (node) => {
-				const a = doc.createElement('a');
-				a.setAttribute('href', node.props.url);
-				a.textContent = node.props.url;
-				return a;
-			},
-
-			search: (node) => {
-				const a = doc.createElement('a');
-				a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
-				a.textContent = node.props.content;
-				return a;
-			},
-
-			plain: (node) => {
-				const el = doc.createElement('span');
-				appendChildren(node.children, el);
-				return el;
-			},
-		};
-
-		appendChildren(nodes, doc.body);
-
-		return `

${doc.body.innerHTML}

`; - } - - // the toMastoHtml function was taken from Iceshrimp and written by zotan and modified by marie to work with the current MK version - - @bindThis - public async toMastoHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], inline = false, quoteUri: string | null = null) { - if (nodes == null) { - return null; - } - - const { window } = new Window(); - - const doc = window.document; - - async function appendChildren(children: mfm.MfmNode[], targetElement: any): Promise { - if (children) { - for (const child of await Promise.all(children.map(async (x) => await (handlers as any)[x.type](x)))) targetElement.appendChild(child); - } - } - - const handlers: { - [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => any; - } = { - async bold(node) { - const el = doc.createElement('span'); - el.textContent = '**'; - await appendChildren(node.children, el); - el.textContent += '**'; - return el; - }, - - async small(node) { - const el = doc.createElement('small'); - await appendChildren(node.children, el); - return el; - }, - - async strike(node) { - const el = doc.createElement('span'); - el.textContent = '~~'; - await appendChildren(node.children, el); - el.textContent += '~~'; - return el; - }, - - async italic(node) { - const el = doc.createElement('span'); - el.textContent = '*'; - await appendChildren(node.children, el); - el.textContent += '*'; - return el; - }, - - async fn(node) { - const el = doc.createElement('span'); - el.textContent = '*'; - await appendChildren(node.children, el); - el.textContent += '*'; - return el; - }, - - blockCode(node) { - const pre = doc.createElement('pre'); - const inner = doc.createElement('code'); - - const nodes = node.props.code - .split(/\r\n|\r|\n/) - .map((x) => doc.createTextNode(x)); - - for (const x of intersperse('br', nodes)) { - inner.appendChild(x === 'br' ? doc.createElement('br') : x); - } - - pre.appendChild(inner); - return pre; - }, - - async center(node) { - const el = doc.createElement('div'); - await appendChildren(node.children, el); - return el; - }, - - emojiCode(node) { - return doc.createTextNode(`\u200B:${node.props.name}:\u200B`); - }, - - unicodeEmoji(node) { - return doc.createTextNode(node.props.emoji); - }, - - hashtag: (node) => { - const a = doc.createElement('a'); - a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`); - a.textContent = `#${node.props.hashtag}`; - a.setAttribute('rel', 'tag'); - a.setAttribute('class', 'hashtag'); - return a; - }, - - inlineCode(node) { - const el = doc.createElement('code'); - el.textContent = node.props.code; - return el; - }, - - mathInline(node) { - const el = doc.createElement('code'); - el.textContent = node.props.formula; - return el; - }, - - mathBlock(node) { - const el = doc.createElement('code'); - el.textContent = node.props.formula; - return el; - }, - - async link(node) { - const a = doc.createElement('a'); - a.setAttribute('rel', 'nofollow noopener noreferrer'); - a.setAttribute('target', '_blank'); - a.setAttribute('href', node.props.url); - await appendChildren(node.children, a); - return a; - }, - - async mention(node) { - const { username, host, acct } = node.props; - const resolved = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host); - - const el = doc.createElement('span'); - if (!resolved) { - el.textContent = acct; - } else { - el.setAttribute('class', 'h-card'); - el.setAttribute('translate', 'no'); - const a = doc.createElement('a'); - a.setAttribute('href', resolved.url ? resolved.url : resolved.uri); - a.className = 'u-url mention'; - const span = doc.createElement('span'); - span.textContent = resolved.username || username; - a.textContent = '@'; - a.appendChild(span); - el.appendChild(a); - } - - return el; - }, - - async quote(node) { - const el = doc.createElement('blockquote'); - await appendChildren(node.children, el); - return el; - }, - - text(node) { - const el = doc.createElement('span'); - const nodes = node.props.text - .split(/\r\n|\r|\n/) - .map((x) => doc.createTextNode(x)); - - for (const x of intersperse('br', nodes)) { - el.appendChild(x === 'br' ? doc.createElement('br') : x); - } - - return el; - }, - - url(node) { - const a = doc.createElement('a'); - a.setAttribute('rel', 'nofollow noopener noreferrer'); - a.setAttribute('target', '_blank'); - a.setAttribute('href', node.props.url); - a.textContent = node.props.url.replace(/^https?:\/\//, ''); - return a; - }, - - search: (node) => { - const a = doc.createElement('a'); - a.setAttribute('href', `https"google.com/${node.props.query}`); - a.textContent = node.props.content; - return a; - }, - - async plain(node) { - const el = doc.createElement('span'); - await appendChildren(node.children, el); - return el; - }, - }; - - await appendChildren(nodes, doc.body); - - if (quoteUri !== null) { - const a = doc.createElement('a'); - a.setAttribute('href', quoteUri); - a.textContent = quoteUri.replace(/^https?:\/\//, ''); - - const quote = doc.createElement('span'); - quote.setAttribute('class', 'quote-inline'); - quote.appendChild(doc.createElement('br')); - quote.appendChild(doc.createElement('br')); - quote.innerHTML += 'RE: '; - quote.appendChild(a); - - doc.body.appendChild(quote); - } - - return inline ? doc.body.innerHTML : `

${doc.body.innerHTML}

`; - } -} diff --git a/packages/backend/src/core/ModerationLogService.ts b/packages/backend/src/core/ModerationLogService.ts deleted file mode 100644 index 8b78d02047..0000000000 --- a/packages/backend/src/core/ModerationLogService.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { ModerationLogsRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; -import { IdService } from '@/core/IdService.js'; -import { bindThis } from '@/decorators.js'; -import { ModerationLogPayloads, moderationLogTypes } from '@/types.js'; - -@Injectable() -export class ModerationLogService { - constructor( - @Inject(DI.moderationLogsRepository) - private moderationLogsRepository: ModerationLogsRepository, - - private idService: IdService, - ) { - } - - @bindThis - public async log(moderator: { id: MiUser['id'] }, type: T, info?: ModerationLogPayloads[T]) { - await this.moderationLogsRepository.insert({ - id: this.idService.gen(), - userId: moderator.id, - type: type, - info: (info as any) ?? {}, - }); - } -} diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts deleted file mode 100644 index 3bc4a29b99..0000000000 --- a/packages/backend/src/core/NoteCreateService.ts +++ /dev/null @@ -1,1278 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { setImmediate } from 'node:timers/promises'; -import * as mfm from '@sharkey/sfm-js'; -import { In, DataSource, IsNull, LessThan } from 'typeorm'; -import * as Redis from 'ioredis'; -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import RE2 from 're2'; -import { extractMentions } from '@/misc/extract-mentions.js'; -import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; -import { extractHashtags } from '@/misc/extract-hashtags.js'; -import type { IMentionedRemoteUsers } from '@/models/Note.js'; -import { MiNote } from '@/models/Note.js'; -import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiApp } from '@/models/App.js'; -import { concat } from '@/misc/prelude/array.js'; -import { IdService } from '@/core/IdService.js'; -import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; -import type { IPoll } from '@/models/Poll.js'; -import { MiPoll } from '@/models/Poll.js'; -import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import { checkWordMute } from '@/misc/check-word-mute.js'; -import type { MiChannel } from '@/models/Channel.js'; -import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { MemorySingleCache } from '@/misc/cache.js'; -import type { MiUserProfile } from '@/models/UserProfile.js'; -import { RelayService } from '@/core/RelayService.js'; -import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import NotesChart from '@/core/chart/charts/notes.js'; -import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; -import InstanceChart from '@/core/chart/charts/instance.js'; -import ActiveUsersChart from '@/core/chart/charts/active-users.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { NotificationService } from '@/core/NotificationService.js'; -import { WebhookService } from '@/core/WebhookService.js'; -import { HashtagService } from '@/core/HashtagService.js'; -import { AntennaService } from '@/core/AntennaService.js'; -import { QueueService } from '@/core/QueueService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; -import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; -import { bindThis } from '@/decorators.js'; -import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; -import { RoleService } from '@/core/RoleService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { SearchService } from '@/core/SearchService.js'; -import { FeaturedService } from '@/core/FeaturedService.js'; -import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { UserBlockingService } from '@/core/UserBlockingService.js'; -import { isReply } from '@/misc/is-reply.js'; - -type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; - -class NotificationManager { - private notifier: { id: MiUser['id']; }; - private note: MiNote; - private queue: { - target: MiLocalUser['id']; - reason: NotificationType; - }[]; - - constructor( - private mutingsRepository: MutingsRepository, - private notificationService: NotificationService, - notifier: { id: MiUser['id']; }, - note: MiNote, - ) { - this.notifier = notifier; - this.note = note; - this.queue = []; - } - - @bindThis - public push(notifiee: MiLocalUser['id'], reason: NotificationType) { - // 自分自身へは通知しない - if (this.notifier.id === notifiee) return; - - const exist = this.queue.find(x => x.target === notifiee); - - if (exist) { - // 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする - if (reason !== 'mention') { - exist.reason = reason; - } - } else { - this.queue.push({ - reason: reason, - target: notifiee, - }); - } - } - - @bindThis - public async notify() { - for (const x of this.queue) { - if (x.reason === 'renote') { - this.notificationService.createNotification(x.target, 'renote', { - noteId: this.note.id, - targetNoteId: this.note.renoteId!, - }, this.notifier.id); - } else { - this.notificationService.createNotification(x.target, x.reason, { - noteId: this.note.id, - }, this.notifier.id); - } - } - } -} - -type MinimumUser = { - id: MiUser['id']; - host: MiUser['host']; - username: MiUser['username']; - uri: MiUser['uri']; -}; - -type Option = { - createdAt?: Date | null; - name?: string | null; - text?: string | null; - reply?: MiNote | null; - renote?: MiNote | null; - files?: MiDriveFile[] | null; - poll?: IPoll | null; - localOnly?: boolean | null; - reactionAcceptance?: MiNote['reactionAcceptance']; - cw?: string | null; - visibility?: string; - visibleUsers?: MinimumUser[] | null; - channel?: MiChannel | null; - apMentions?: MinimumUser[] | null; - apHashtags?: string[] | null; - apEmojis?: string[] | null; - uri?: string | null; - url?: string | null; - app?: MiApp | null; -}; - -@Injectable() -export class NoteCreateService implements OnApplicationShutdown { - #shutdownController = new AbortController(); - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - - @Inject(DI.noteThreadMutingsRepository) - private noteThreadMutingsRepository: NoteThreadMutingsRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - - private userEntityService: UserEntityService, - private noteEntityService: NoteEntityService, - private idService: IdService, - private globalEventService: GlobalEventService, - private queueService: QueueService, - private fanoutTimelineService: FanoutTimelineService, - private noteReadService: NoteReadService, - private notificationService: NotificationService, - private relayService: RelayService, - private federatedInstanceService: FederatedInstanceService, - private hashtagService: HashtagService, - private antennaService: AntennaService, - private webhookService: WebhookService, - private featuredService: FeaturedService, - private remoteUserResolveService: RemoteUserResolveService, - private apDeliverManagerService: ApDeliverManagerService, - private apRendererService: ApRendererService, - private roleService: RoleService, - private metaService: MetaService, - private searchService: SearchService, - private notesChart: NotesChart, - private perUserNotesChart: PerUserNotesChart, - private activeUsersChart: ActiveUsersChart, - private instanceChart: InstanceChart, - private utilityService: UtilityService, - private userBlockingService: UserBlockingService, - ) { } - - @bindThis - public async create(user: { - id: MiUser['id']; - username: MiUser['username']; - host: MiUser['host']; - isBot: MiUser['isBot']; - noindex: MiUser['noindex']; - }, data: Option, silent = false): Promise { - // チャンネル外にリプライしたら対象のスコープに合わせる - // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) - if (data.reply && data.channel && data.reply.channelId !== data.channel.id) { - if (data.reply.channelId) { - data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId }); - } else { - data.channel = null; - } - } - - // チャンネル内にリプライしたら対象のスコープに合わせる - // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) - if (data.reply && (data.channel == null) && data.reply.channelId) { - data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId }); - } - - if (data.createdAt == null) data.createdAt = new Date(); - if (data.visibility == null) data.visibility = 'public'; - if (data.localOnly == null) data.localOnly = false; - if (data.channel != null) data.visibility = 'public'; - if (data.channel != null) data.visibleUsers = []; - if (data.channel != null) data.localOnly = true; - - const meta = await this.metaService.fetch(); - - if (data.visibility === 'public' && data.channel == null) { - const sensitiveWords = meta.sensitiveWords; - if (this.utilityService.isSensitiveWordIncluded(data.cw ?? data.text ?? '', sensitiveWords)) { - data.visibility = 'home'; - } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { - data.visibility = 'home'; - } - } - - const inSilencedInstance = this.utilityService.isSilencedHost(meta.silencedHosts, user.host); - - if (data.visibility === 'public' && inSilencedInstance && user.host !== null) { - data.visibility = 'home'; - } - - if (data.renote) { - switch (data.renote.visibility) { - case 'public': - // public noteは無条件にrenote可能 - break; - case 'home': - // home noteはhome以下にrenote可能 - if (data.visibility === 'public') { - data.visibility = 'home'; - } - break; - case 'followers': - // 他人のfollowers noteはreject - if (data.renote.userId !== user.id) { - throw new Error('Renote target is not public or home'); - } - - // Renote対象がfollowersならfollowersにする - data.visibility = 'followers'; - break; - case 'specified': - // specified / direct noteはreject - throw new Error('Renote target is not public or home'); - } - } - - // Check blocking - if (data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length === 0)) { - if (data.renote.userHost === null) { - if (data.renote.userId !== user.id) { - const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id); - if (blocked) { - throw new Error('blocked'); - } - } - } - } - - // 返信対象がpublicではないならhomeにする - if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') { - data.visibility = 'home'; - } - - // ローカルのみをRenoteしたらローカルのみにする - if (data.renote && data.renote.localOnly && data.channel == null) { - data.localOnly = true; - } - - // ローカルのみにリプライしたらローカルのみにする - if (data.reply && data.reply.localOnly && data.channel == null) { - data.localOnly = true; - } - - if (data.text) { - if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) { - data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH); - } - data.text = data.text.trim(); - } else { - data.text = null; - } - - let tags = data.apHashtags; - let emojis = data.apEmojis; - let mentionedUsers = data.apMentions; - - // Parse MFM if needed - if (!tags || !emojis || !mentionedUsers) { - const tokens = (data.text ? mfm.parse(data.text)! : []); - const cwTokens = data.cw ? mfm.parse(data.cw)! : []; - const choiceTokens = data.poll && data.poll.choices - ? concat(data.poll.choices.map(choice => mfm.parse(choice)!)) - : []; - - const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens); - - tags = data.apHashtags ?? extractHashtags(combinedTokens); - - emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens); - - mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens); - } - - tags = tags.filter(tag => Array.from(tag).length <= 128).splice(0, 32); - - if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) { - mentionedUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId })); - } - - if (data.visibility === 'specified') { - if (data.visibleUsers == null) throw new Error('invalid param'); - - for (const u of data.visibleUsers) { - if (!mentionedUsers.some(x => x.id === u.id)) { - mentionedUsers.push(u); - } - } - - if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) { - data.visibleUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId })); - } - } - - if (user.host && !data.cw) { - await this.federatedInstanceService.fetch(user.host).then(async i => { - if (i.isNSFW) { - data.cw = 'Instance is marked as NSFW'; - } - }); - } - - const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); - - setImmediate('post created', { signal: this.#shutdownController.signal }).then( - () => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!), - () => { /* aborted, ignore this */ }, - ); - - return note; - } - - @bindThis - public async import(user: { - id: MiUser['id']; - username: MiUser['username']; - host: MiUser['host']; - isBot: MiUser['isBot']; - noindex: MiUser['noindex']; - }, data: Option, silent = false): Promise { - // チャンネル外にリプライしたら対象のスコープに合わせる - // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) - if (data.reply && data.channel && data.reply.channelId !== data.channel.id) { - if (data.reply.channelId) { - data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId }); - } else { - data.channel = null; - } - } - - // チャンネル内にリプライしたら対象のスコープに合わせる - // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) - if (data.reply && (data.channel == null) && data.reply.channelId) { - data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId }); - } - - if (data.createdAt == null) data.createdAt = new Date(); - if (data.visibility == null) data.visibility = 'public'; - if (data.localOnly == null) data.localOnly = false; - if (data.channel != null) data.visibility = 'public'; - if (data.channel != null) data.visibleUsers = []; - if (data.channel != null) data.localOnly = true; - - const meta = await this.metaService.fetch(); - - if (data.visibility === 'public' && data.channel == null) { - const sensitiveWords = meta.sensitiveWords; - if (this.utilityService.isSensitiveWordIncluded(data.cw ?? data.text ?? '', sensitiveWords)) { - data.visibility = 'home'; - } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { - data.visibility = 'home'; - } - } - - const inSilencedInstance = this.utilityService.isSilencedHost(meta.silencedHosts, user.host); - - if (data.visibility === 'public' && inSilencedInstance && user.host !== null) { - data.visibility = 'home'; - } - - if (data.renote) { - switch (data.renote.visibility) { - case 'public': - // public noteは無条件にrenote可能 - break; - case 'home': - // home noteはhome以下にrenote可能 - if (data.visibility === 'public') { - data.visibility = 'home'; - } - break; - case 'followers': - // 他人のfollowers noteはreject - if (data.renote.userId !== user.id) { - throw new Error('Renote target is not public or home'); - } - - // Renote対象がfollowersならfollowersにする - data.visibility = 'followers'; - break; - case 'specified': - // specified / direct noteはreject - throw new Error('Renote target is not public or home'); - } - } - - // Check blocking - if (data.renote && !this.isQuote(data)) { - if (data.renote.userHost === null) { - if (data.renote.userId !== user.id) { - const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id); - if (blocked) { - throw new Error('blocked'); - } - } - } - } - - // 返信対象がpublicではないならhomeにする - if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') { - data.visibility = 'home'; - } - - // ローカルのみをRenoteしたらローカルのみにする - if (data.renote && data.renote.localOnly && data.channel == null) { - data.localOnly = true; - } - - // ローカルのみにリプライしたらローカルのみにする - if (data.reply && data.reply.localOnly && data.channel == null) { - data.localOnly = true; - } - - if (data.text) { - if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) { - data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH); - } - data.text = data.text.trim(); - } else { - data.text = null; - } - - let tags = data.apHashtags; - let emojis = data.apEmojis; - let mentionedUsers = data.apMentions; - - // Parse MFM if needed - if (!tags || !emojis || !mentionedUsers) { - const tokens = (data.text ? mfm.parse(data.text)! : []); - const cwTokens = data.cw ? mfm.parse(data.cw)! : []; - const choiceTokens = data.poll && data.poll.choices - ? concat(data.poll.choices.map(choice => mfm.parse(choice)!)) - : []; - - const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens); - - tags = data.apHashtags ?? extractHashtags(combinedTokens); - - emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens); - - mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens); - } - - tags = tags.filter(tag => Array.from(tag).length <= 128).splice(0, 32); - - if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) { - mentionedUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId })); - } - - if (data.visibility === 'specified') { - if (data.visibleUsers == null) throw new Error('invalid param'); - - for (const u of data.visibleUsers) { - if (!mentionedUsers.some(x => x.id === u.id)) { - mentionedUsers.push(u); - } - } - - if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) { - data.visibleUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId })); - } - } - - const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); - - setImmediate('post created', { signal: this.#shutdownController.signal }).then( - () => this.postNoteImported(note, user, data, silent, tags!, mentionedUsers!), - () => { /* aborted, ignore this */ }, - ); - - return note; - } - - @bindThis - private async insertNote(user: { id: MiUser['id']; host: MiUser['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) { - const insert = new MiNote({ - id: this.idService.gen(data.createdAt?.getTime()), - fileIds: data.files ? data.files.map(file => file.id) : [], - replyId: data.reply ? data.reply.id : null, - renoteId: data.renote ? data.renote.id : null, - channelId: data.channel ? data.channel.id : null, - threadId: data.reply - ? data.reply.threadId - ? data.reply.threadId - : data.reply.id - : null, - name: data.name, - text: data.text, - hasPoll: data.poll != null, - cw: data.cw ?? null, - tags: tags.map(tag => normalizeForSearch(tag)), - emojis, - userId: user.id, - localOnly: data.localOnly!, - reactionAcceptance: data.reactionAcceptance, - visibility: data.visibility as any, - visibleUserIds: data.visibility === 'specified' - ? data.visibleUsers - ? data.visibleUsers.map(u => u.id) - : [] - : [], - - attachedFileTypes: data.files ? data.files.map(file => file.type) : [], - - // 以下非正規化データ - replyUserId: data.reply ? data.reply.userId : null, - replyUserHost: data.reply ? data.reply.userHost : null, - renoteUserId: data.renote ? data.renote.userId : null, - renoteUserHost: data.renote ? data.renote.userHost : null, - userHost: user.host, - }); - - if (data.uri != null) insert.uri = data.uri; - if (data.url != null) insert.url = data.url; - - // Append mentions data - if (mentionedUsers.length > 0) { - insert.mentions = mentionedUsers.map(u => u.id); - const profiles = await this.userProfilesRepository.findBy({ userId: In(insert.mentions) }); - insert.mentionedRemoteUsers = JSON.stringify(mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u)).map(u => { - const profile = profiles.find(p => p.userId === u.id); - const url = profile != null ? profile.url : null; - return { - uri: u.uri, - url: url ?? undefined, - username: u.username, - host: u.host, - } as IMentionedRemoteUsers[0]; - })); - } - - // 投稿を作成 - try { - if (insert.hasPoll) { - // Start transaction - await this.db.transaction(async transactionalEntityManager => { - await transactionalEntityManager.insert(MiNote, insert); - - const poll = new MiPoll({ - noteId: insert.id, - choices: data.poll!.choices, - expiresAt: data.poll!.expiresAt, - multiple: data.poll!.multiple, - votes: new Array(data.poll!.choices.length).fill(0), - noteVisibility: insert.visibility, - userId: user.id, - userHost: user.host, - }); - - await transactionalEntityManager.insert(MiPoll, poll); - }); - } else { - await this.notesRepository.insert(insert); - } - - return insert; - } catch (e) { - // duplicate key error - if (isDuplicateKeyValueError(e)) { - const err = new Error('Duplicated note'); - err.name = 'duplicated'; - throw err; - } - - console.error(e); - - throw e; - } - } - - @bindThis - private async postNoteCreated(note: MiNote, user: { - id: MiUser['id']; - username: MiUser['username']; - host: MiUser['host']; - isBot: MiUser['isBot']; - noindex: MiUser['noindex']; - }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { - const meta = await this.metaService.fetch(); - - this.notesChart.update(note, true); - if (meta.enableChartsForRemoteUser || (user.host == null)) { - this.perUserNotesChart.update(user, note, true); - } - - // Register host - if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetch(user.host).then(async i => { - if (note.renote && note.text) { - this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); - } else if (!note.renote) { - this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); - } - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateNote(i.host, note, true); - } - }); - } - - // ハッシュタグ更新 - if (data.visibility === 'public' || data.visibility === 'home') { - if (user.isBot && meta.enableBotTrending) { - this.hashtagService.updateHashtags(user, tags); - } else if (!user.isBot) { - this.hashtagService.updateHashtags(user, tags); - } - } - - if (data.renote && data.text) { - // Increment notes count (user) - this.incNotesCountOfUser(user); - } else if (!data.renote) { - // Increment notes count (user) - this.incNotesCountOfUser(user); - } - - this.pushToTl(note, user); - - this.antennaService.addNoteToAntennas(note, user); - - if (data.reply) { - this.saveReply(data.reply, note); - } - - if (data.reply == null) { - // TODO: キャッシュ - this.followingsRepository.findBy({ - followeeId: user.id, - notify: 'normal', - }).then(followings => { - if (note.visibility !== 'specified') { - for (const following of followings) { - // TODO: ワードミュート考慮 - this.notificationService.createNotification(following.followerId, 'note', { - noteId: note.id, - }, user.id); - } - } - }); - } - - if (data.renote && data.text == null && data.renote.userId !== user.id && !user.isBot) { - this.incRenoteCount(data.renote); - } - - if (data.poll && data.poll.expiresAt) { - const delay = data.poll.expiresAt.getTime() - Date.now(); - this.queueService.endedPollNotificationQueue.add(note.id, { - noteId: note.id, - }, { - delay, - removeOnComplete: true, - }); - } - - if (!silent) { - if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); - - // 未読通知を作成 - if (data.visibility === 'specified') { - if (data.visibleUsers == null) throw new Error('invalid param'); - - for (const u of data.visibleUsers) { - // ローカルユーザーのみ - if (!this.userEntityService.isLocalUser(u)) continue; - - this.noteReadService.insertNoteUnread(u.id, note, { - isSpecified: true, - isMentioned: false, - }); - } - } else { - for (const u of mentionedUsers) { - // ローカルユーザーのみ - if (!this.userEntityService.isLocalUser(u)) continue; - - this.noteReadService.insertNoteUnread(u.id, note, { - isSpecified: false, - isMentioned: true, - }); - } - } - - // Pack the note - const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true }); - - this.globalEventService.publishNotesStream(noteObj); - - this.roleService.addNoteToRoleTimeline(noteObj); - - this.webhookService.getActiveWebhooks().then(webhooks => { - webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); - for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'note', { - note: noteObj, - }); - } - }); - - const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note); - - await this.createMentionedEvents(mentionedUsers, note, nm); - - // If has in reply to note - if (data.reply) { - this.globalEventService.publishNoteStream(data.reply.id, 'replied', { - id: note.id, - }); - // 通知 - if (data.reply.userHost === null) { - const isThreadMuted = await this.noteThreadMutingsRepository.exist({ - where: { - userId: data.reply.userId, - threadId: data.reply.threadId ?? data.reply.id, - }, - }); - - if (!isThreadMuted) { - nm.push(data.reply.userId, 'reply'); - this.globalEventService.publishMainStream(data.reply.userId, 'reply', noteObj); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply')); - for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'reply', { - note: noteObj, - }); - } - } - } - } - - // If it is renote - if (data.renote) { - const type = this.isQuote(data) ? 'quote' : 'renote'; - - // Notify - if (data.renote.userHost === null) { - nm.push(data.renote.userId, type); - } - - // Publish event - if ((user.id !== data.renote.userId) && data.renote.userHost === null) { - this.globalEventService.publishMainStream(data.renote.userId, 'renote', noteObj); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote')); - for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'renote', { - note: noteObj, - }); - } - } - } - - nm.notify(); - - //#region AP deliver - if (this.userEntityService.isLocalUser(user)) { - (async () => { - const noteActivity = await this.renderNoteOrRenoteActivity(data, note); - const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); - - // メンションされたリモートユーザーに配送 - for (const u of mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u))) { - dm.addDirectRecipe(u as MiRemoteUser); - } - - // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送 - if (data.reply && data.reply.userHost !== null) { - const u = await this.usersRepository.findOneBy({ id: data.reply.userId }); - if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u); - } - - // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送 - if (data.renote && data.renote.userHost !== null) { - const u = await this.usersRepository.findOneBy({ id: data.renote.userId }); - if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u); - } - - // フォロワーに配送 - if (['public', 'home', 'followers'].includes(note.visibility)) { - dm.addFollowersRecipe(); - } - - if (['public'].includes(note.visibility)) { - this.relayService.deliverToRelays(user, noteActivity); - } - - dm.execute(); - })(); - } - //#endregion - } - - if (data.channel) { - this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1); - this.channelsRepository.update(data.channel.id, { - lastNotedAt: new Date(), - }); - - this.notesRepository.countBy({ - userId: user.id, - channelId: data.channel.id, - }).then(count => { - // この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる - // TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい - if (count === 1) { - this.channelsRepository.increment({ id: data.channel!.id }, 'usersCount', 1); - } - }); - } - - // Register to search database - if (!user.noindex) this.index(note); - } - - @bindThis - private async postNoteImported(note: MiNote, user: { - id: MiUser['id']; - username: MiUser['username']; - host: MiUser['host']; - isBot: MiUser['isBot']; - noindex: MiUser['noindex']; - }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { - const meta = await this.metaService.fetch(); - - this.notesChart.update(note, true); - if (meta.enableChartsForRemoteUser || (user.host == null)) { - this.perUserNotesChart.update(user, note, true); - } - - // Register host - if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetch(user.host).then(async i => { - if (note.renote && note.text) { - this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); - } else if (!note.renote) { - this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); - } - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateNote(i.host, note, true); - } - }); - } - - if (data.renote && data.text) { - // Increment notes count (user) - this.incNotesCountOfUser(user); - } else if (!data.renote) { - // Increment notes count (user) - this.incNotesCountOfUser(user); - } - - this.pushToTl(note, user); - - this.antennaService.addNoteToAntennas(note, user); - - if (data.reply) { - this.saveReply(data.reply, note); - } - - if (data.reply == null) { - // TODO: キャッシュ - this.followingsRepository.findBy({ - followeeId: user.id, - notify: 'normal', - }).then(followings => { - for (const following of followings) { - // TODO: ワードミュート考慮 - this.notificationService.createNotification(following.followerId, 'note', { - noteId: note.id, - }, user.id); - } - }); - } - - if (data.renote && data.text == null && data.renote.userId !== user.id && !user.isBot) { - this.incRenoteCount(data.renote); - } - - if (data.poll && data.poll.expiresAt) { - const delay = data.poll.expiresAt.getTime() - Date.now(); - this.queueService.endedPollNotificationQueue.add(note.id, { - noteId: note.id, - }, { - delay, - removeOnComplete: true, - }); - } - - // Pack the note - const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true }); - - if (data.channel) { - this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1); - this.channelsRepository.update(data.channel.id, { - lastNotedAt: new Date(), - }); - - this.notesRepository.countBy({ - userId: user.id, - channelId: data.channel.id, - }).then(count => { - // この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる - // TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい - if (count === 1) { - this.channelsRepository.increment({ id: data.channel!.id }, 'usersCount', 1); - } - }); - } - - // Register to search database - if (!user.noindex) this.index(note); - } - - @bindThis - private isQuote(note: Option): note is Option & { renote: MiNote } { - // sync with misc/is-quote.ts - return !!note.renote && (!!note.text || !!note.cw || (!!note.files && !!note.files.length) || !!note.poll); - } - - @bindThis - private incRenoteCount(renote: MiNote) { - this.notesRepository.createQueryBuilder().update() - .set({ - renoteCount: () => '"renoteCount" + 1', - }) - .where('id = :id', { id: renote.id }) - .execute(); - - // 30%の確率、3日以内に投稿されたノートの場合ハイライト用ランキング更新 - if (Math.random() < 0.3 && (Date.now() - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) { - if (renote.channelId != null) { - if (renote.replyId == null) { - this.featuredService.updateInChannelNotesRanking(renote.channelId, renote.id, 5); - } - } else { - if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) { - this.featuredService.updateGlobalNotesRanking(renote.id, 5); - this.featuredService.updatePerUserNotesRanking(renote.userId, renote.id, 5); - } - } - } - } - - @bindThis - private async createMentionedEvents(mentionedUsers: MinimumUser[], note: MiNote, nm: NotificationManager) { - for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) { - const isThreadMuted = await this.noteThreadMutingsRepository.exist({ - where: { - userId: u.id, - threadId: note.threadId ?? note.id, - }, - }); - - if (isThreadMuted) { - continue; - } - - const detailPackedNote = await this.noteEntityService.pack(note, u, { - detail: true, - }); - - this.globalEventService.publishMainStream(u.id, 'mention', detailPackedNote); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention')); - for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'mention', { - note: detailPackedNote, - }); - } - - // Create notification - nm.push(u.id, 'mention'); - } - } - - @bindThis - private saveReply(reply: MiNote, note: MiNote) { - this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1); - } - - @bindThis - private async renderNoteOrRenoteActivity(data: Option, note: MiNote) { - if (data.localOnly) return null; - - const content = data.renote && !this.isQuote(data) - ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note) - : this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note); - - return this.apRendererService.addContext(content); - } - - @bindThis - private index(note: MiNote) { - if (note.text == null && note.cw == null) return; - - this.searchService.indexNote(note); - } - - @bindThis - private incNotesCountOfUser(user: { id: MiUser['id']; }) { - this.usersRepository.createQueryBuilder().update() - .set({ - updatedAt: new Date(), - notesCount: () => '"notesCount" + 1', - }) - .where('id = :id', { id: user.id }) - .execute(); - } - - @bindThis - private async extractMentionedUsers(user: { host: MiUser['host']; }, tokens: mfm.MfmNode[]): Promise { - if (tokens == null) return []; - - const mentions = extractMentions(tokens); - let mentionedUsers = (await Promise.all(mentions.map(m => - this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null), - ))).filter(x => x != null) as MiUser[]; - - // Drop duplicate users - mentionedUsers = mentionedUsers.filter((u, i, self) => - i === self.findIndex(u2 => u.id === u2.id), - ); - - return mentionedUsers; - } - - @bindThis - private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { - const meta = await this.metaService.fetch(); - if (!meta.enableFanoutTimeline) return; - - const r = this.redisForTimelines.pipeline(); - - if (note.channelId) { - this.fanoutTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r); - - this.fanoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); - - const channelFollowings = await this.channelFollowingsRepository.find({ - where: { - followeeId: note.channelId, - }, - select: ['followerId'], - }); - - for (const channelFollowing of channelFollowings) { - this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); - } - } - } else { - // TODO: キャッシュ? - // eslint-disable-next-line prefer-const - let [followings, userListMemberships] = await Promise.all([ - this.followingsRepository.find({ - where: { - followeeId: user.id, - followerHost: IsNull(), - isFollowerHibernated: false, - }, - select: ['followerId', 'withReplies'], - }), - this.userListMembershipsRepository.find({ - where: { - userId: user.id, - }, - select: ['userListId', 'userListUserId', 'withReplies'], - }), - ]); - - if (note.visibility === 'followers') { - // TODO: 重そうだから何とかしたい Set 使う? - userListMemberships = userListMemberships.filter(x => x.userListUserId === user.id || followings.some(f => f.followerId === x.userListUserId)); - } - - // TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする - for (const following of followings) { - // 基本的にvisibleUserIdsには自身のidが含まれている前提であること - if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue; - - // 「自分自身への返信 or そのフォロワーへの返信」のどちらでもない場合 - if (isReply(note, following.followerId)) { - if (!following.withReplies) continue; - } - - this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); - } - } - - for (const userListMembership of userListMemberships) { - // ダイレクトのとき、そのリストが対象外のユーザーの場合 - if ( - note.visibility === 'specified' && - note.userId !== userListMembership.userListUserId && - !note.visibleUserIds.some(v => v === userListMembership.userListUserId) - ) continue; - - // 「自分自身への返信 or そのリストの作成者への返信」のどちらでもない場合 - if (isReply(note, userListMembership.userListUserId)) { - if (!userListMembership.withReplies) continue; - } - - this.fanoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r); - } - } - - if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { // 自分自身のHTL - this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); - } - } - - // 自分自身以外への返信 - if (isReply(note)) { - this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); - - if (note.visibility === 'public' && note.userHost == null) { - this.fanoutTimelineService.push('localTimelineWithReplies', note.id, 300, r); - if (note.replyUserHost == null) { - this.fanoutTimelineService.push(`localTimelineWithReplyTo:${note.replyUserId}`, note.id, 300 / 10, r); - } - } - } else { - this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r); - } - - if (note.visibility === 'public' && note.userHost == null) { - this.fanoutTimelineService.push('localTimeline', note.id, 1000, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r); - } - } - } - - if (Math.random() < 0.1) { - process.nextTick(() => { - this.checkHibernation(followings); - }); - } - } - - r.exec(); - } - - @bindThis - public async checkHibernation(followings: MiFollowing[]) { - if (followings.length === 0) return; - - const shuffle = (array: MiFollowing[]) => { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } - return array; - }; - - // ランダムに最大1000件サンプリング - const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000)); - - const hibernatedUsers = await this.usersRepository.find({ - where: { - id: In(samples.map(x => x.followerId)), - lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))), - }, - select: ['id'], - }); - - if (hibernatedUsers.length > 0) { - this.usersRepository.update({ - id: In(hibernatedUsers.map(x => x.id)), - }, { - isHibernated: true, - }); - - this.followingsRepository.update({ - followerId: In(hibernatedUsers.map(x => x.id)), - }, { - isFollowerHibernated: true, - }); - } - } - - @bindThis - public dispose(): void { - this.#shutdownController.abort(); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts deleted file mode 100644 index 99d9d9db7e..0000000000 --- a/packages/backend/src/core/NoteDeleteService.ts +++ /dev/null @@ -1,232 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Brackets, In } from 'typeorm'; -import { Injectable, Inject } from '@nestjs/common'; -import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; -import type { MiNote, IMentionedRemoteUsers } from '@/models/Note.js'; -import type { InstancesRepository, NotesRepository, UsersRepository } from '@/models/_.js'; -import { RelayService } from '@/core/RelayService.js'; -import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import NotesChart from '@/core/chart/charts/notes.js'; -import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; -import InstanceChart from '@/core/chart/charts/instance.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { bindThis } from '@/decorators.js'; -import { MetaService } from '@/core/MetaService.js'; -import { SearchService } from '@/core/SearchService.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; - -@Injectable() -export class NoteDeleteService { - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, - - private userEntityService: UserEntityService, - private noteEntityService: NoteEntityService, - private globalEventService: GlobalEventService, - private relayService: RelayService, - private federatedInstanceService: FederatedInstanceService, - private apRendererService: ApRendererService, - private apDeliverManagerService: ApDeliverManagerService, - private metaService: MetaService, - private searchService: SearchService, - private moderationLogService: ModerationLogService, - private notesChart: NotesChart, - private perUserNotesChart: PerUserNotesChart, - private instanceChart: InstanceChart, - ) {} - - /** - * 投稿を削除します。 - * @param user 投稿者 - * @param note 投稿 - */ - async delete(user: { id: MiUser['id']; uri: MiUser['uri']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, quiet = false, deleter?: MiUser) { - const deletedAt = new Date(); - const cascadingNotes = await this.findCascadingNotes(note); - - if (note.replyId) { - await this.notesRepository.decrement({ id: note.replyId }, 'repliesCount', 1); - } - - if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { - await this.notesRepository.findOneBy({ id: note.renoteId }).then(async (renote) => { - if (!renote) return; - if (renote.userId !== user.id) await this.notesRepository.decrement({ id: renote.id }, 'renoteCount', 1); - }); - } - - if (!quiet) { - this.globalEventService.publishNoteStream(note.id, 'deleted', { - deletedAt: deletedAt, - }); - - //#region ローカルの投稿なら削除アクティビティを配送 - if (this.userEntityService.isLocalUser(user) && !note.localOnly) { - let renote: MiNote | null = null; - - // if deleted note is renote - if (isPureRenote(note)) { - renote = await this.notesRepository.findOneBy({ - id: note.renoteId, - }); - } - - const content = this.apRendererService.addContext(renote - ? this.apRendererService.renderUndo(this.apRendererService.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note), user) - : this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${note.id}`), user)); - - this.deliverToConcerned(user, note, content); - } - - // also deliever delete activity to cascaded notes - const federatedLocalCascadingNotes = (cascadingNotes).filter(note => !note.localOnly && note.userHost == null); // filter out local-only notes - for (const cascadingNote of federatedLocalCascadingNotes) { - if (!cascadingNote.user) continue; - if (!this.userEntityService.isLocalUser(cascadingNote.user)) continue; - const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${cascadingNote.id}`), cascadingNote.user)); - this.deliverToConcerned(cascadingNote.user, cascadingNote, content); - } - //#endregion - - const meta = await this.metaService.fetch(); - - this.notesChart.update(note, false); - if (meta.enableChartsForRemoteUser || (user.host == null)) { - this.perUserNotesChart.update(user, note, false); - } - - if (note.renoteId && note.text) { - // Decrement notes count (user) - this.decNotesCountOfUser(user); - } else if (!note.renoteId) { - // Decrement notes count (user) - this.decNotesCountOfUser(user); - } - - if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetch(user.host).then(async i => { - if (note.renoteId && note.text) { - this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); - } else if (!note.renoteId) { - this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); - } - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateNote(i.host, note, false); - } - }); - } - } - - for (const cascadingNote of cascadingNotes) { - this.searchService.unindexNote(cascadingNote); - } - this.searchService.unindexNote(note); - - await this.notesRepository.delete({ - id: note.id, - userId: user.id, - }); - - if (deleter && (note.userId !== deleter.id)) { - const user = await this.usersRepository.findOneByOrFail({ id: note.userId }); - this.moderationLogService.log(deleter, 'deleteNote', { - noteId: note.id, - noteUserId: note.userId, - noteUserUsername: user.username, - noteUserHost: user.host, - note: note, - }); - } - } - - @bindThis - private decNotesCountOfUser(user: { id: MiUser['id']; }) { - this.usersRepository.createQueryBuilder().update() - .set({ - updatedAt: new Date(), - notesCount: () => '"notesCount" - 1', - }) - .where('id = :id', { id: user.id }) - .execute(); - } - - @bindThis - private async findCascadingNotes(note: MiNote): Promise { - const recursive = async (noteId: string): Promise => { - const query = this.notesRepository.createQueryBuilder('note') - .where('note.replyId = :noteId', { noteId }) - .orWhere(new Brackets(q => { - q.where('note.renoteId = :noteId', { noteId }) - .andWhere('note.text IS NOT NULL'); - })) - .leftJoinAndSelect('note.user', 'user'); - const replies = await query.getMany(); - - return [ - replies, - ...await Promise.all(replies.map(reply => recursive(reply.id))), - ].flat(); - }; - - const cascadingNotes: MiNote[] = await recursive(note.id); - - return cascadingNotes; - } - - @bindThis - private async getMentionedRemoteUsers(note: MiNote) { - const where = [] as any[]; - - // mention / reply / dm - const uris = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); - if (uris.length > 0) { - where.push( - { uri: In(uris) }, - ); - } - - // renote / quote - if (note.renoteUserId) { - where.push({ - id: note.renoteUserId, - }); - } - - if (where.length === 0) return []; - - return await this.usersRepository.find({ - where, - }) as MiRemoteUser[]; - } - - @bindThis - private async deliverToConcerned(user: { id: MiLocalUser['id']; host: null; }, note: MiNote, content: any) { - this.apDeliverManagerService.deliverToFollowers(user, content); - this.relayService.deliverToRelays(user, content); - const remoteUsers = await this.getMentionedRemoteUsers(note); - for (const remoteUser of remoteUsers) { - this.apDeliverManagerService.deliverToUser(user, content, remoteUser); - } - } -} diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts deleted file mode 100644 index dae3f485ff..0000000000 --- a/packages/backend/src/core/NoteEditService.ts +++ /dev/null @@ -1,950 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { setImmediate } from 'node:timers/promises'; -import * as mfm from '@sharkey/sfm-js'; -import { DataSource, In, IsNull, LessThan } from 'typeorm'; -import * as Redis from 'ioredis'; -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import RE2 from 're2'; -import { extractMentions } from '@/misc/extract-mentions.js'; -import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; -import { extractHashtags } from '@/misc/extract-hashtags.js'; -import type { IMentionedRemoteUsers } from '@/models/Note.js'; -import { MiNote } from '@/models/Note.js'; -import type { NoteEditRepository, ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository, PollsRepository } from '@/models/_.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiApp } from '@/models/App.js'; -import { concat } from '@/misc/prelude/array.js'; -import { IdService } from '@/core/IdService.js'; -import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; -import { MiPoll, type IPoll } from '@/models/Poll.js'; -import type { MiChannel } from '@/models/Channel.js'; -import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { RelayService } from '@/core/RelayService.js'; -import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import InstanceChart from '@/core/chart/charts/instance.js'; -import ActiveUsersChart from '@/core/chart/charts/active-users.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { NotificationService } from '@/core/NotificationService.js'; -import { WebhookService } from '@/core/WebhookService.js'; -import { QueueService } from '@/core/QueueService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; -import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; -import { bindThis } from '@/decorators.js'; -import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; -import { RoleService } from '@/core/RoleService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { SearchService } from '@/core/SearchService.js'; -import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; -import { UtilityService } from '@/core/UtilityService.js'; - -type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; - -class NotificationManager { - private notifier: { id: MiUser['id']; }; - private note: MiNote; - private queue: { - target: MiLocalUser['id']; - reason: NotificationType; - }[]; - - constructor( - private mutingsRepository: MutingsRepository, - private notificationService: NotificationService, - notifier: { id: MiUser['id']; }, - note: MiNote, - ) { - this.notifier = notifier; - this.note = note; - this.queue = []; - } - - @bindThis - public push(notifiee: MiLocalUser['id'], reason: NotificationType) { - // 自分自身へは通知しない - if (this.notifier.id === notifiee) return; - - const exist = this.queue.find(x => x.target === notifiee); - - if (exist) { - // 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする - if (reason !== 'mention') { - exist.reason = reason; - } - } else { - this.queue.push({ - reason: reason, - target: notifiee, - }); - } - } - - @bindThis - public async notify() { - for (const x of this.queue) { - if (x.reason === 'renote') { - this.notificationService.createNotification(x.target, 'renote', { - noteId: this.note.id, - targetNoteId: this.note.renoteId!, - }, this.notifier.id); - } else { - this.notificationService.createNotification(x.target, x.reason, { - noteId: this.note.id, - }, this.notifier.id); - } - } - } -} - -type MinimumUser = { - id: MiUser['id']; - host: MiUser['host']; - username: MiUser['username']; - uri: MiUser['uri']; -}; - -type Option = { - createdAt?: Date | null; - name?: string | null; - text?: string | null; - reply?: MiNote | null; - renote?: MiNote | null; - files?: MiDriveFile[] | null; - poll?: IPoll | null; - localOnly?: boolean | null; - reactionAcceptance?: MiNote['reactionAcceptance']; - cw?: string | null; - visibility?: string; - visibleUsers?: MinimumUser[] | null; - channel?: MiChannel | null; - apMentions?: MinimumUser[] | null; - apHashtags?: string[] | null; - apEmojis?: string[] | null; - uri?: string | null; - url?: string | null; - app?: MiApp | null; - updatedAt?: Date | null; - editcount?: boolean | null; -}; - -@Injectable() -export class NoteEditService implements OnApplicationShutdown { - #shutdownController = new AbortController(); - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - - @Inject(DI.noteThreadMutingsRepository) - private noteThreadMutingsRepository: NoteThreadMutingsRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - - @Inject(DI.noteEditRepository) - private noteEditRepository: NoteEditRepository, - - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - private userEntityService: UserEntityService, - private noteEntityService: NoteEntityService, - private idService: IdService, - private globalEventService: GlobalEventService, - private queueService: QueueService, - private fanoutTimelineService: FanoutTimelineService, - private noteReadService: NoteReadService, - private notificationService: NotificationService, - private relayService: RelayService, - private federatedInstanceService: FederatedInstanceService, - private webhookService: WebhookService, - private remoteUserResolveService: RemoteUserResolveService, - private apDeliverManagerService: ApDeliverManagerService, - private apRendererService: ApRendererService, - private roleService: RoleService, - private metaService: MetaService, - private searchService: SearchService, - private activeUsersChart: ActiveUsersChart, - private instanceChart: InstanceChart, - private utilityService: UtilityService, - ) { } - - @bindThis - public async edit(user: { - id: MiUser['id']; - username: MiUser['username']; - host: MiUser['host']; - isBot: MiUser['isBot']; - noindex: MiUser['noindex']; - }, editid: MiNote['id'], data: Option, silent = false): Promise { - if (!editid) { - throw new Error('fail'); - } - - const oldnote = await this.notesRepository.findOneBy({ - id: editid, - }); - - if (oldnote == null) { - throw new Error('no such note'); - } - - if (oldnote.userId !== user.id) { - throw new Error('not the author'); - } - - // チャンネル外にリプライしたら対象のスコープに合わせる - // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) - if (data.reply && data.channel && data.reply.channelId !== data.channel.id) { - if (data.reply.channelId) { - data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId }); - } else { - data.channel = null; - } - } - - // チャンネル内にリプライしたら対象のスコープに合わせる - // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) - if (data.reply && (data.channel == null) && data.reply.channelId) { - data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId }); - } - - if (data.visibility == null) data.visibility = 'public'; - if (data.localOnly == null) data.localOnly = false; - if (data.channel != null) data.visibility = 'public'; - if (data.channel != null) data.visibleUsers = []; - if (data.channel != null) data.localOnly = true; - if (data.updatedAt == null) data.updatedAt = new Date(); - - if (data.visibility === 'public' && data.channel == null) { - const sensitiveWords = (await this.metaService.fetch()).sensitiveWords; - if (this.isSensitive(data, sensitiveWords)) { - data.visibility = 'home'; - } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { - data.visibility = 'home'; - } - } - - const inSilencedInstance = this.utilityService.isSilencedHost((await this.metaService.fetch()).silencedHosts, user.host); - - if (data.visibility === 'public' && inSilencedInstance && user.host !== null) { - data.visibility = 'home'; - } - - if (data.renote) { - switch (data.renote.visibility) { - case 'public': - // public noteは無条件にrenote可能 - break; - case 'home': - // home noteはhome以下にrenote可能 - if (data.visibility === 'public') { - data.visibility = 'home'; - } - break; - case 'followers': - // 他人のfollowers noteはreject - if (data.renote.userId !== user.id) { - throw new Error('Renote target is not public or home'); - } - - // Renote対象がfollowersならfollowersにする - data.visibility = 'followers'; - break; - case 'specified': - // specified / direct noteはreject - throw new Error('Renote target is not public or home'); - } - } - - // 返信対象がpublicではないならhomeにする - if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') { - data.visibility = 'home'; - } - - // ローカルのみをRenoteしたらローカルのみにする - if (data.renote && data.renote.localOnly && data.channel == null) { - data.localOnly = true; - } - - // ローカルのみにリプライしたらローカルのみにする - if (data.reply && data.reply.localOnly && data.channel == null) { - data.localOnly = true; - } - - if (data.text) { - if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) { - data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH); - } - data.text = data.text.trim(); - } else { - data.text = null; - } - - let tags = data.apHashtags; - let emojis = data.apEmojis; - let mentionedUsers = data.apMentions; - - // Parse MFM if needed - if (!tags || !emojis || !mentionedUsers) { - const tokens = data.text ? mfm.parse(data.text)! : []; - const cwTokens = data.cw ? mfm.parse(data.cw)! : []; - const choiceTokens = data.poll && data.poll.choices - ? concat(data.poll.choices.map(choice => mfm.parse(choice)!)) - : []; - - const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens); - - tags = data.apHashtags ?? extractHashtags(combinedTokens); - - emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens); - - mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens); - } - - tags = tags.filter(tag => Array.from(tag ?? '').length <= 128).splice(0, 32); - - if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) { - mentionedUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId })); - } - - if (data.visibility === 'specified') { - if (data.visibleUsers == null) throw new Error('invalid param'); - - for (const u of data.visibleUsers) { - if (!mentionedUsers.some(x => x.id === u.id)) { - mentionedUsers.push(u); - } - } - - if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) { - data.visibleUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId })); - } - } - - const update: Partial = {}; - if (data.text !== oldnote.text) { - update.text = data.text; - } - if (data.cw !== oldnote.cw) { - update.cw = data.cw; - } - if (data.localOnly !== oldnote.localOnly) { - update.localOnly = data.localOnly; - } - if (oldnote.hasPoll !== !!data.poll) { - update.hasPoll = !!data.poll; - } - - const poll = await this.pollsRepository.findOneBy({ noteId: oldnote.id }); - - const oldPoll = poll ? { choices: poll.choices, multiple: poll.multiple, expiresAt: poll.expiresAt } : null; - - if (Object.keys(update).length > 0) { - const exists = await this.noteEditRepository.findOneBy({ noteId: oldnote.id }); - - await this.noteEditRepository.insert({ - id: this.idService.gen(), - noteId: oldnote.id, - oldText: oldnote.text || undefined, - newText: update.text || undefined, - cw: update.cw || undefined, - fileIds: undefined, - oldDate: exists ? oldnote.updatedAt as Date : this.idService.parse(oldnote.id).date, - updatedAt: new Date(), - }); - - const note = new MiNote({ - id: oldnote.id, - updatedAt: data.updatedAt ? data.updatedAt : new Date(), - fileIds: data.files ? data.files.map(file => file.id) : [], - replyId: data.reply ? data.reply.id : null, - renoteId: data.renote ? data.renote.id : null, - channelId: data.channel ? data.channel.id : null, - threadId: data.reply - ? data.reply.threadId - ? data.reply.threadId - : data.reply.id - : null, - name: data.name, - text: data.text, - hasPoll: data.poll != null, - cw: data.cw ?? null, - tags: tags.map(tag => normalizeForSearch(tag)), - emojis, - reactions: oldnote.reactions, - userId: user.id, - localOnly: data.localOnly!, - reactionAcceptance: data.reactionAcceptance, - visibility: data.visibility as any, - visibleUserIds: data.visibility === 'specified' - ? data.visibleUsers - ? data.visibleUsers.map(u => u.id) - : [] - : [], - - attachedFileTypes: data.files ? data.files.map(file => file.type) : [], - - // 以下非正規化データ - replyUserId: data.reply ? data.reply.userId : null, - replyUserHost: data.reply ? data.reply.userHost : null, - renoteUserId: data.renote ? data.renote.userId : null, - renoteUserHost: data.renote ? data.renote.userHost : null, - userHost: user.host, - }); - - if (data.uri != null) note.uri = data.uri; - if (data.url != null) note.url = data.url; - - if (mentionedUsers.length > 0) { - note.mentions = mentionedUsers.map(u => u.id); - const profiles = await this.userProfilesRepository.findBy({ userId: In(note.mentions) }); - note.mentionedRemoteUsers = JSON.stringify(mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u)).map(u => { - const profile = profiles.find(p => p.userId === u.id); - const url = profile != null ? profile.url : null; - return { - uri: u.uri, - url: url ?? undefined, - username: u.username, - host: u.host, - } as IMentionedRemoteUsers[0]; - })); - } - - if (data.poll != null && JSON.stringify(data.poll) !== JSON.stringify(oldPoll)) { - // Start transaction - await this.db.transaction(async transactionalEntityManager => { - await transactionalEntityManager.update(MiNote, oldnote.id, note); - - const poll = new MiPoll({ - noteId: note.id, - choices: data.poll!.choices, - expiresAt: data.poll!.expiresAt, - multiple: data.poll!.multiple, - votes: new Array(data.poll!.choices.length).fill(0), - noteVisibility: note.visibility, - userId: user.id, - userHost: user.host, - }); - - if (!oldnote.hasPoll) { - await transactionalEntityManager.insert(MiPoll, poll); - } else { - await transactionalEntityManager.update(MiPoll, oldnote.id, poll); - } - }); - } else { - await this.notesRepository.update(oldnote.id, note); - } - - setImmediate('post edited', { signal: this.#shutdownController.signal }).then( - () => this.postNoteEdited(note, user, data, silent, tags!, mentionedUsers!), - () => { /* aborted, ignore this */ }, - ); - - return note; - } else { - return oldnote; - } - } - - @bindThis - private async postNoteEdited(note: MiNote, user: { - id: MiUser['id']; - username: MiUser['username']; - host: MiUser['host']; - isBot: MiUser['isBot']; - noindex: MiUser['noindex']; - }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { - // Register host - if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetch(user.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateNote(i.host, note, true); - } - }); - } - - // ハッシュタグ更新 - this.pushToTl(note, user); - - if (data.poll && data.poll.expiresAt) { - const delay = data.poll.expiresAt.getTime() - Date.now(); - this.queueService.endedPollNotificationQueue.remove(note.id); - this.queueService.endedPollNotificationQueue.add(note.id, { - noteId: note.id, - }, { - delay, - removeOnComplete: true, - }); - } - - if (!silent) { - if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); - - // 未読通知を作成 - if (data.visibility === 'specified') { - if (data.visibleUsers == null) throw new Error('invalid param'); - - for (const u of data.visibleUsers) { - // ローカルユーザーのみ - if (!this.userEntityService.isLocalUser(u)) continue; - - this.noteReadService.insertNoteUnread(u.id, note, { - isSpecified: true, - isMentioned: false, - }); - } - } else { - for (const u of mentionedUsers) { - // ローカルユーザーのみ - if (!this.userEntityService.isLocalUser(u)) continue; - - this.noteReadService.insertNoteUnread(u.id, note, { - isSpecified: false, - isMentioned: true, - }); - } - } - - // Pack the note - const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true }); - if (data.poll != null) { - this.globalEventService.publishNoteStream(note.id, 'updated', { - cw: note.cw, - text: note.text!, - }); - } else { - this.globalEventService.publishNoteStream(note.id, 'updated', { - cw: note.cw, - text: note.text! - }); - } - - this.roleService.addNoteToRoleTimeline(noteObj); - - this.webhookService.getActiveWebhooks().then(webhooks => { - webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); - for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'note', { - note: noteObj, - }); - } - }); - - const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note); - - await this.createMentionedEvents(mentionedUsers, note, nm); - - // If has in reply to note - if (data.reply) { - // 通知 - if (data.reply.userHost === null) { - const isThreadMuted = await this.noteThreadMutingsRepository.exist({ - where: { - userId: data.reply.userId, - threadId: data.reply.threadId ?? data.reply.id, - }, - }); - - if (!isThreadMuted) { - nm.push(data.reply.userId, 'reply'); - this.globalEventService.publishMainStream(data.reply.userId, 'reply', noteObj); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply')); - for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'reply', { - note: noteObj, - }); - } - } - } - } - - // If it is renote - if (data.renote) { - const type = data.text ? 'quote' : 'renote'; - - // Notify - if (data.renote.userHost === null) { - nm.push(data.renote.userId, type); - } - - // Publish event - if ((user.id !== data.renote.userId) && data.renote.userHost === null) { - this.globalEventService.publishMainStream(data.renote.userId, 'renote', noteObj); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote')); - for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'renote', { - note: noteObj, - }); - } - } - } - - nm.notify(); - - //#region AP deliver - if (this.userEntityService.isLocalUser(user)) { - (async () => { - const noteActivity = await this.renderNoteOrRenoteActivity(data, note); - const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); - - // メンションされたリモートユーザーに配送 - for (const u of mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u))) { - dm.addDirectRecipe(u as MiRemoteUser); - } - - // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送 - if (data.reply && data.reply.userHost !== null) { - const u = await this.usersRepository.findOneBy({ id: data.reply.userId }); - if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u); - } - - // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送 - if (data.renote && data.renote.userHost !== null) { - const u = await this.usersRepository.findOneBy({ id: data.renote.userId }); - if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u); - } - - // フォロワーに配送 - if (['public', 'home', 'followers'].includes(note.visibility)) { - dm.addFollowersRecipe(); - } - - if (['public'].includes(note.visibility)) { - this.relayService.deliverToRelays(user, noteActivity); - } - - dm.execute(); - })(); - } - //#endregion - } - - if (data.channel) { - this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1); - this.channelsRepository.update(data.channel.id, { - lastNotedAt: new Date(), - }); - - this.notesRepository.countBy({ - userId: user.id, - channelId: data.channel.id, - }).then(count => { - // この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる - // TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい - if (count === 1) { - this.channelsRepository.increment({ id: data.channel!.id }, 'usersCount', 1); - } - }); - } - - // Register to search database - if (!user.noindex) this.index(note); - } - - @bindThis - private isSensitive(note: Option, sensitiveWord: string[]): boolean { - if (sensitiveWord.length > 0) { - const text = note.cw ?? note.text ?? ''; - if (text === '') return false; - const matched = sensitiveWord.some(filter => { - // represents RegExp - const regexp = filter.match(/^\/(.+)\/(.*)$/); - // This should never happen due to input sanitisation. - if (!regexp) { - const words = filter.split(' '); - return words.every(keyword => text.includes(keyword)); - } - try { - return new RE2(regexp[1], regexp[2]).test(text); - } catch (err) { - // This should never happen due to input sanitisation. - return false; - } - }); - if (matched) return true; - } - return false; - } - - @bindThis - private async createMentionedEvents(mentionedUsers: MinimumUser[], note: MiNote, nm: NotificationManager) { - for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) { - const isThreadMuted = await this.noteThreadMutingsRepository.exist({ - where: { - userId: u.id, - threadId: note.threadId ?? note.id, - }, - }); - - if (isThreadMuted) { - continue; - } - - const detailPackedNote = await this.noteEntityService.pack(note, u, { - detail: true, - }); - - this.globalEventService.publishMainStream(u.id, 'mention', detailPackedNote); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention')); - for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'mention', { - note: detailPackedNote, - }); - } - - // Create notification - nm.push(u.id, 'mention'); - } - } - - @bindThis - private async renderNoteOrRenoteActivity(data: Option, note: MiNote) { - if (data.localOnly) return null; - const user = await this.usersRepository.findOneBy({ id: note.userId }); - if (user == null) throw new Error('user not found'); - - const content = data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length === 0) - ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note) - : this.apRendererService.renderUpdate(await this.apRendererService.renderUpNote(note, false), user); - - return this.apRendererService.addContext(content); - } - - @bindThis - private index(note: MiNote) { - if (note.text == null && note.cw == null) return; - - this.searchService.indexNote(note); - } - - @bindThis - private async extractMentionedUsers(user: { host: MiUser['host']; }, tokens: mfm.MfmNode[]): Promise { - if (tokens == null) return []; - - const mentions = extractMentions(tokens); - let mentionedUsers = (await Promise.all(mentions.map(m => - this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null), - ))).filter(x => x != null) as MiUser[]; - - // Drop duplicate users - mentionedUsers = mentionedUsers.filter((u, i, self) => - i === self.findIndex(u2 => u.id === u2.id), - ); - - return mentionedUsers; - } - - @bindThis - private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { - const meta = await this.metaService.fetch(); - - const r = this.redisForTimelines.pipeline(); - - if (note.channelId) { - this.fanoutTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r); - - this.fanoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); - - const channelFollowings = await this.channelFollowingsRepository.find({ - where: { - followeeId: note.channelId, - }, - select: ['followerId'], - }); - - for (const channelFollowing of channelFollowings) { - this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); - } - } - } else { - // TODO: キャッシュ? - // eslint-disable-next-line prefer-const - let [followings, userListMemberships] = await Promise.all([ - this.followingsRepository.find({ - where: { - followeeId: user.id, - followerHost: IsNull(), - isFollowerHibernated: false, - }, - select: ['followerId', 'withReplies'], - }), - this.userListMembershipsRepository.find({ - where: { - userId: user.id, - }, - select: ['userListId', 'userListUserId', 'withReplies'], - }), - ]); - - if (note.visibility === 'followers') { - // TODO: 重そうだから何とかしたい Set 使う? - userListMemberships = userListMemberships.filter(x => followings.some(f => f.followerId === x.userListUserId)); - } - - // TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする - for (const following of followings) { - // 基本的にvisibleUserIdsには自身のidが含まれている前提であること - if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue; - - // 「自分自身への返信 or そのフォロワーへの返信」のどちらでもない場合 - if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === following.followerId)) { - if (!following.withReplies) continue; - } - - this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); - } - } - - for (const userListMembership of userListMemberships) { - // ダイレクトのとき、そのリストが対象外のユーザーの場合 - if ( - note.visibility === 'specified' && - !note.visibleUserIds.some(v => v === userListMembership.userListUserId) - ) continue; - - // 「自分自身への返信 or そのリストの作成者への返信」のどちらでもない場合 - if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === userListMembership.userListUserId)) { - if (!userListMembership.withReplies) continue; - } - - this.fanoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r); - } - } - - if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { // 自分自身のHTL - this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); - } - } - - // 自分自身以外への返信 - if (note.replyId && note.replyUserId !== note.userId) { - this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); - - if (note.visibility === 'public' && note.userHost == null) { - this.fanoutTimelineService.push('localTimelineWithReplies', note.id, 300, r); - } - } else { - this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r); - } - - if (note.visibility === 'public' && note.userHost == null) { - this.fanoutTimelineService.push('localTimeline', note.id, 1000, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r); - } - } - } - - if (Math.random() < 0.1) { - process.nextTick(() => { - this.checkHibernation(followings); - }); - } - } - - r.exec(); - } - - @bindThis - public async checkHibernation(followings: MiFollowing[]) { - if (followings.length === 0) return; - - const shuffle = (array: MiFollowing[]) => { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } - return array; - }; - - // ランダムに最大1000件サンプリング - const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000)); - - const hibernatedUsers = await this.usersRepository.find({ - where: { - id: In(samples.map(x => x.followerId)), - lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))), - }, - select: ['id'], - }); - - if (hibernatedUsers.length > 0) { - this.usersRepository.update({ - id: In(hibernatedUsers.map(x => x.id)), - }, { - isHibernated: true, - }); - - this.followingsRepository.update({ - followerId: In(hibernatedUsers.map(x => x.id)), - }, { - isFollowerHibernated: true, - }); - } - } - - @bindThis - public dispose(): void { - this.#shutdownController.abort(); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/core/NotePiningService.ts b/packages/backend/src/core/NotePiningService.ts deleted file mode 100644 index 74e53c5c46..0000000000 --- a/packages/backend/src/core/NotePiningService.ts +++ /dev/null @@ -1,127 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { NotesRepository, UserNotePiningsRepository, UsersRepository } from '@/models/_.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiNote } from '@/models/Note.js'; -import { IdService } from '@/core/IdService.js'; -import type { MiUserNotePining } from '@/models/UserNotePining.js'; -import { RelayService } from '@/core/RelayService.js'; -import type { Config } from '@/config.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { bindThis } from '@/decorators.js'; -import { RoleService } from '@/core/RoleService.js'; - -@Injectable() -export class NotePiningService { - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.userNotePiningsRepository) - private userNotePiningsRepository: UserNotePiningsRepository, - - private userEntityService: UserEntityService, - private idService: IdService, - private roleService: RoleService, - private relayService: RelayService, - private apDeliverManagerService: ApDeliverManagerService, - private apRendererService: ApRendererService, - ) { - } - - /** - * 指定した投稿をピン留めします - * @param user - * @param noteId - */ - @bindThis - public async addPinned(user: { id: MiUser['id']; host: MiUser['host']; }, noteId: MiNote['id']) { - // Fetch pinee - const note = await this.notesRepository.findOneBy({ - id: noteId, - userId: user.id, - }); - - if (note == null) { - throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', 'No such note.'); - } - - const pinings = await this.userNotePiningsRepository.findBy({ userId: user.id }); - - if (pinings.length >= (await this.roleService.getUserPolicies(user.id)).pinLimit) { - throw new IdentifiableError('15a018eb-58e5-4da1-93be-330fcc5e4e1a', 'You can not pin notes any more.'); - } - - if (pinings.some(pining => pining.noteId === note.id)) { - throw new IdentifiableError('23f0cf4e-59a3-4276-a91d-61a5891c1514', 'That note has already been pinned.'); - } - - await this.userNotePiningsRepository.insert({ - id: this.idService.gen(), - userId: user.id, - noteId: note.id, - } as MiUserNotePining); - - // Deliver to remote followers - if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) { - this.deliverPinnedChange(user.id, note.id, true); - } - } - - /** - * 指定した投稿のピン留めを解除します - * @param user - * @param noteId - */ - @bindThis - public async removePinned(user: { id: MiUser['id']; host: MiUser['host']; }, noteId: MiNote['id']) { - // Fetch unpinee - const note = await this.notesRepository.findOneBy({ - id: noteId, - userId: user.id, - }); - - if (note == null) { - throw new IdentifiableError('b302d4cf-c050-400a-bbb3-be208681f40c', 'No such note.'); - } - - this.userNotePiningsRepository.delete({ - userId: user.id, - noteId: note.id, - }); - - // Deliver to remote followers - if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) { - this.deliverPinnedChange(user.id, noteId, false); - } - } - - @bindThis - public async deliverPinnedChange(userId: MiUser['id'], noteId: MiNote['id'], isAddition: boolean) { - const user = await this.usersRepository.findOneBy({ id: userId }); - if (user == null) throw new Error('user not found'); - - if (!this.userEntityService.isLocalUser(user)) return; - - const target = `${this.config.url}/users/${user.id}/collections/featured`; - const item = `${this.config.url}/notes/${noteId}`; - const content = this.apRendererService.addContext(isAddition ? this.apRendererService.renderAdd(user, target, item) : this.apRendererService.renderRemove(user, target, item)); - - this.apDeliverManagerService.deliverToFollowers(user, content); - this.relayService.deliverToRelays(user, content); - } -} diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts deleted file mode 100644 index 03c1735e04..0000000000 --- a/packages/backend/src/core/NoteReadService.ts +++ /dev/null @@ -1,141 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { setTimeout } from 'node:timers/promises'; -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import { In } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { MiUser } from '@/models/User.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { MiNote } from '@/models/Note.js'; -import { IdService } from '@/core/IdService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import type { NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository } from '@/models/_.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class NoteReadService implements OnApplicationShutdown { - #shutdownController = new AbortController(); - - constructor( - @Inject(DI.noteUnreadsRepository) - private noteUnreadsRepository: NoteUnreadsRepository, - - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - @Inject(DI.noteThreadMutingsRepository) - private noteThreadMutingsRepository: NoteThreadMutingsRepository, - - private idService: IdService, - private globalEventService: GlobalEventService, - ) { - } - - @bindThis - public async insertNoteUnread(userId: MiUser['id'], note: MiNote, params: { - // NOTE: isSpecifiedがtrueならisMentionedは必ずfalse - isSpecified: boolean; - isMentioned: boolean; - }): Promise { - //#region ミュートしているなら無視 - const mute = await this.mutingsRepository.findBy({ - muterId: userId, - }); - if (mute.map(m => m.muteeId).includes(note.userId)) return; - //#endregion - - // スレッドミュート - const isThreadMuted = await this.noteThreadMutingsRepository.exist({ - where: { - userId: userId, - threadId: note.threadId ?? note.id, - }, - }); - if (isThreadMuted) return; - - const unread = { - id: this.idService.gen(), - noteId: note.id, - userId: userId, - isSpecified: params.isSpecified, - isMentioned: params.isMentioned, - noteUserId: note.userId, - }; - - await this.noteUnreadsRepository.insert(unread); - - // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する - setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { - const exist = await this.noteUnreadsRepository.exist({ where: { id: unread.id } }); - - if (!exist) return; - - if (params.isMentioned) { - this.globalEventService.publishMainStream(userId, 'unreadMention', note.id); - } - if (params.isSpecified) { - this.globalEventService.publishMainStream(userId, 'unreadSpecifiedNote', note.id); - } - }, () => { /* aborted, ignore it */ }); - } - - @bindThis - public async read( - userId: MiUser['id'], - notes: (MiNote | Packed<'Note'>)[], - ): Promise { - const readMentions: (MiNote | Packed<'Note'>)[] = []; - const readSpecifiedNotes: (MiNote | Packed<'Note'>)[] = []; - - for (const note of notes) { - if (note.mentions && note.mentions.includes(userId)) { - readMentions.push(note); - } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) { - readSpecifiedNotes.push(note); - } - } - - if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0)) { - // Remove the record - await this.noteUnreadsRepository.delete({ - userId: userId, - noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]), - }); - - // TODO: ↓まとめてクエリしたい - - this.noteUnreadsRepository.countBy({ - userId: userId, - isMentioned: true, - }).then(mentionsCount => { - if (mentionsCount === 0) { - // 全て既読になったイベントを発行 - this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions'); - } - }); - - this.noteUnreadsRepository.countBy({ - userId: userId, - isSpecified: true, - }).then(specifiedCount => { - if (specifiedCount === 0) { - // 全て既読になったイベントを発行 - this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes'); - } - }); - } - } - - @bindThis - public dispose(): void { - this.#shutdownController.abort(); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts deleted file mode 100644 index ad7be83e5b..0000000000 --- a/packages/backend/src/core/NotificationService.ts +++ /dev/null @@ -1,204 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { setTimeout } from 'node:timers/promises'; -import * as Redis from 'ioredis'; -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import { In } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { UsersRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiNotification } from '@/models/Notification.js'; -import { bindThis } from '@/decorators.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { PushNotificationService } from '@/core/PushNotificationService.js'; -import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; -import { IdService } from '@/core/IdService.js'; -import { CacheService } from '@/core/CacheService.js'; -import type { Config } from '@/config.js'; -import { UserListService } from '@/core/UserListService.js'; -import type { FilterUnionByProperty } from '@/types.js'; - -@Injectable() -export class NotificationService implements OnApplicationShutdown { - #shutdownController = new AbortController(); - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.redis) - private redisClient: Redis.Redis, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private notificationEntityService: NotificationEntityService, - private idService: IdService, - private globalEventService: GlobalEventService, - private pushNotificationService: PushNotificationService, - private cacheService: CacheService, - private userListService: UserListService, - ) { - } - - @bindThis - public async readAllNotification( - userId: MiUser['id'], - force = false, - ) { - const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); - - const latestNotificationIdsRes = await this.redisClient.xrevrange( - `notificationTimeline:${userId}`, - '+', - '-', - 'COUNT', 1); - const latestNotificationId = latestNotificationIdsRes[0]?.[0]; - - if (latestNotificationId == null) return; - - this.redisClient.set(`latestReadNotification:${userId}`, latestNotificationId); - - if (force || latestReadNotificationId == null || (latestReadNotificationId < latestNotificationId)) { - return this.postReadAllNotifications(userId); - } - } - - @bindThis - private postReadAllNotifications(userId: MiUser['id']) { - this.globalEventService.publishMainStream(userId, 'readAllNotifications'); - this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined); - } - - @bindThis - public async createNotification( - notifieeId: MiUser['id'], - type: T, - data: Omit, 'type' | 'id' | 'createdAt' | 'notifierId'>, - notifierId?: MiUser['id'] | null, - ): Promise { - const profile = await this.cacheService.userProfileCache.fetch(notifieeId); - - // 古いMisskeyバージョンのキャッシュが残っている可能性がある - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - const recieveConfig = (profile.notificationRecieveConfig ?? {})[type]; - if (recieveConfig?.type === 'never') { - return null; - } - - if (notifierId) { - if (notifieeId === notifierId) { - return null; - } - - const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId); - if (mutings.has(notifierId)) { - return null; - } - - if (recieveConfig?.type === 'following') { - const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)); - if (!isFollowing) { - return null; - } - } else if (recieveConfig?.type === 'follower') { - const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)); - if (!isFollower) { - return null; - } - } else if (recieveConfig?.type === 'mutualFollow') { - const [isFollowing, isFollower] = await Promise.all([ - this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)), - this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)), - ]); - if (!isFollowing && !isFollower) { - return null; - } - } else if (recieveConfig?.type === 'list') { - const isMember = await this.userListService.membersCache.fetch(recieveConfig.userListId).then(members => members.has(notifierId)); - if (!isMember) { - return null; - } - } - } - - const notification = { - id: this.idService.gen(), - createdAt: new Date(), - type: type, - ...(notifierId ? { - notifierId, - } : {}), - ...data, - } as any as FilterUnionByProperty; - - const redisIdPromise = this.redisClient.xadd( - `notificationTimeline:${notifieeId}`, - 'MAXLEN', '~', this.config.perUserNotificationsMaxCount.toString(), - '*', - 'data', JSON.stringify(notification)); - - const packed = await this.notificationEntityService.pack(notification, notifieeId, {}); - - // Publish notification event - this.globalEventService.publishMainStream(notifieeId, 'notification', packed); - - // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する - // テスト通知の場合は即時発行 - const interval = notification.type === 'test' ? 0 : 2000; - setTimeout(interval, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => { - const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`); - if (latestReadNotificationId && (latestReadNotificationId >= (await redisIdPromise)!)) return; - - this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); - this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); - - if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: notifierId! })); - if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: notifierId! })); - }, () => { /* aborted, ignore it */ }); - - return notification; - } - - // TODO - //const locales = await import('../../../../locales/index.js'); - - // TODO: locale ファイルをクライアント用とサーバー用で分けたい - - @bindThis - private async emailNotificationFollow(userId: MiUser['id'], follower: MiUser) { - /* - const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); - if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return; - const locale = locales[userProfile.lang ?? 'ja-JP']; - const i18n = new I18n(locale); - // TODO: render user information html - sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); - */ - } - - @bindThis - private async emailNotificationReceiveFollowRequest(userId: MiUser['id'], follower: MiUser) { - /* - const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); - if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return; - const locale = locales[userProfile.lang ?? 'ja-JP']; - const i18n = new I18n(locale); - // TODO: render user information html - sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); - */ - } - - @bindThis - public dispose(): void { - this.#shutdownController.abort(); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/core/PollService.ts b/packages/backend/src/core/PollService.ts deleted file mode 100644 index 9e1b5ca78a..0000000000 --- a/packages/backend/src/core/PollService.ts +++ /dev/null @@ -1,108 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { NotesRepository, UsersRepository, PollsRepository, PollVotesRepository, MiUser } from '@/models/_.js'; -import type { MiNote } from '@/models/Note.js'; -import { RelayService } from '@/core/RelayService.js'; -import { IdService } from '@/core/IdService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; -import { bindThis } from '@/decorators.js'; -import { UserBlockingService } from '@/core/UserBlockingService.js'; - -@Injectable() -export class PollService { - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - @Inject(DI.pollVotesRepository) - private pollVotesRepository: PollVotesRepository, - - private userEntityService: UserEntityService, - private idService: IdService, - private relayService: RelayService, - private globalEventService: GlobalEventService, - private userBlockingService: UserBlockingService, - private apRendererService: ApRendererService, - private apDeliverManagerService: ApDeliverManagerService, - ) { - } - - @bindThis - public async vote(user: MiUser, note: MiNote, choice: number) { - const poll = await this.pollsRepository.findOneBy({ noteId: note.id }); - - if (poll == null) throw new Error('poll not found'); - - // Check whether is valid choice - if (poll.choices[choice] == null) throw new Error('invalid choice param'); - - // Check blocking - if (note.userId !== user.id) { - const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id); - if (blocked) { - throw new Error('blocked'); - } - } - - // if already voted - const exist = await this.pollVotesRepository.findBy({ - noteId: note.id, - userId: user.id, - }); - - if (poll.multiple) { - if (exist.some(x => x.choice === choice)) { - throw new Error('already voted'); - } - } else if (exist.length !== 0) { - throw new Error('already voted'); - } - - await this.pollVotesRepository.insert({ - id: this.idService.gen(), - noteId: note.id, - userId: user.id, - choice: choice, - }); - - // Increment votes count - const index = choice + 1; // In SQL, array index is 1 based - await this.pollsRepository.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`); - - this.globalEventService.publishNoteStream(note.id, 'pollVoted', { - choice: choice, - userId: user.id, - }); - } - - @bindThis - public async deliverQuestionUpdate(noteId: MiNote['id']) { - const note = await this.notesRepository.findOneBy({ id: noteId }); - if (note == null) throw new Error('note not found'); - - if (note.localOnly) return; - - const user = await this.usersRepository.findOneBy({ id: note.userId }); - if (user == null) throw new Error('note not found'); - - if (this.userEntityService.isLocalUser(user)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user)); - this.apDeliverManagerService.deliverToFollowers(user, content); - this.relayService.deliverToRelays(user, content); - } - } -} diff --git a/packages/backend/src/core/ProxyAccountService.ts b/packages/backend/src/core/ProxyAccountService.ts deleted file mode 100644 index b1bc60701b..0000000000 --- a/packages/backend/src/core/ProxyAccountService.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; -import type { MiLocalUser } from '@/models/User.js'; -import { DI } from '@/di-symbols.js'; -import { MetaService } from '@/core/MetaService.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class ProxyAccountService { - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private metaService: MetaService, - ) { - } - - @bindThis - public async fetch(): Promise { - const meta = await this.metaService.fetch(); - if (meta.proxyAccountId == null) return null; - return await this.usersRepository.findOneByOrFail({ id: meta.proxyAccountId }) as MiLocalUser; - } -} diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts deleted file mode 100644 index 40d1deceeb..0000000000 --- a/packages/backend/src/core/PushNotificationService.ts +++ /dev/null @@ -1,133 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import push from 'web-push'; -import * as Redis from 'ioredis'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { getNoteSummary } from '@/misc/get-note-summary.js'; -import type { MiSwSubscription, SwSubscriptionsRepository } from '@/models/_.js'; -import { MetaService } from '@/core/MetaService.js'; -import { bindThis } from '@/decorators.js'; -import { RedisKVCache } from '@/misc/cache.js'; - -// Defined also packages/sw/types.ts#L13 -type PushNotificationsTypes = { - 'notification': Packed<'Notification'>; - 'unreadAntennaNote': { - antenna: { id: string, name: string }; - note: Packed<'Note'>; - }; - 'readAllNotifications': undefined; -}; - -// Reduce length because push message servers have character limits -function truncateBody(type: T, body: PushNotificationsTypes[T]): PushNotificationsTypes[T] { - if (typeof body !== 'object') return body; - - return { - ...body, - ...(('note' in body && body.note) ? { - note: { - ...body.note, - // textをgetNoteSummaryしたものに置き換える - text: getNoteSummary(('type' in body && body.type === 'renote') ? body.note.renote as Packed<'Note'> : body.note), - - cw: undefined, - reply: undefined, - renote: undefined, - user: type === 'notification' ? undefined as any : body.note.user, - }, - } : {}), - }; -} - -@Injectable() -export class PushNotificationService implements OnApplicationShutdown { - private subscriptionsCache: RedisKVCache; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.redis) - private redisClient: Redis.Redis, - - @Inject(DI.swSubscriptionsRepository) - private swSubscriptionsRepository: SwSubscriptionsRepository, - - private metaService: MetaService, - ) { - this.subscriptionsCache = new RedisKVCache(this.redisClient, 'userSwSubscriptions', { - lifetime: 1000 * 60 * 60 * 1, // 1h - memoryCacheLifetime: 1000 * 60 * 3, // 3m - fetcher: (key) => this.swSubscriptionsRepository.findBy({ userId: key }), - toRedisConverter: (value) => JSON.stringify(value), - fromRedisConverter: (value) => JSON.parse(value), - }); - } - - @bindThis - public async pushNotification(userId: string, type: T, body: PushNotificationsTypes[T]) { - const meta = await this.metaService.fetch(); - - if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return; - - // アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録 - push.setVapidDetails(this.config.url, - meta.swPublicKey, - meta.swPrivateKey); - - const subscriptions = await this.subscriptionsCache.fetch(userId); - - for (const subscription of subscriptions) { - if ([ - 'readAllNotifications', - ].includes(type) && !subscription.sendReadMessage) continue; - - const pushSubscription = { - endpoint: subscription.endpoint, - keys: { - auth: subscription.auth, - p256dh: subscription.publickey, - }, - }; - - push.sendNotification(pushSubscription, JSON.stringify({ - type, - body: (type === 'notification' || type === 'unreadAntennaNote') ? truncateBody(type, body) : body, - userId, - dateTime: (new Date()).getTime(), - }), { - proxy: this.config.proxy, - }).catch((err: any) => { - //swLogger.info(err.statusCode); - //swLogger.info(err.headers); - //swLogger.info(err.body); - - if (err.statusCode === 410) { - this.swSubscriptionsRepository.delete({ - userId: userId, - endpoint: subscription.endpoint, - auth: subscription.auth, - publickey: subscription.publickey, - }); - } - }); - } - } - - @bindThis - public dispose(): void { - this.subscriptionsCache.dispose(); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts deleted file mode 100644 index f006ed4944..0000000000 --- a/packages/backend/src/core/QueryService.ts +++ /dev/null @@ -1,254 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Brackets, ObjectLiteral } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { MiUser } from '@/models/User.js'; -import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import type { SelectQueryBuilder } from 'typeorm'; - -@Injectable() -export class QueryService { - constructor( - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - - @Inject(DI.noteThreadMutingsRepository) - private noteThreadMutingsRepository: NoteThreadMutingsRepository, - - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - @Inject(DI.renoteMutingsRepository) - private renoteMutingsRepository: RenoteMutingsRepository, - - private idService: IdService, - ) { - } - - public makePaginationQuery(q: SelectQueryBuilder, sinceId?: string | null, untilId?: string | null, sinceDate?: number | null, untilDate?: number | null): SelectQueryBuilder { - if (sinceId && untilId) { - q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); - q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); - q.orderBy(`${q.alias}.id`, 'DESC'); - } else if (sinceId) { - q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); - q.orderBy(`${q.alias}.id`, 'ASC'); - } else if (untilId) { - q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); - q.orderBy(`${q.alias}.id`, 'DESC'); - } else if (sinceDate && untilDate) { - q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.gen(sinceDate) }); - q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.gen(untilDate) }); - q.orderBy(`${q.alias}.id`, 'DESC'); - } else if (sinceDate) { - q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.gen(sinceDate) }); - q.orderBy(`${q.alias}.id`, 'ASC'); - } else if (untilDate) { - q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.gen(untilDate) }); - q.orderBy(`${q.alias}.id`, 'DESC'); - } else { - q.orderBy(`${q.alias}.id`, 'DESC'); - } - return q; - } - - // ここでいうBlockedは被Blockedの意 - @bindThis - public generateBlockedUserQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { - const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') - .select('blocking.blockerId') - .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); - - // 投稿の作者にブロックされていない かつ - // 投稿の返信先の作者にブロックされていない かつ - // 投稿の引用元の作者にブロックされていない - q - .andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`) - .andWhere(new Brackets(qb => { - qb - .where('note.replyUserId IS NULL') - .orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`); - })) - .andWhere(new Brackets(qb => { - qb - .where('note.renoteUserId IS NULL') - .orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`); - })); - - q.setParameters(blockingQuery.getParameters()); - } - - @bindThis - public generateBlockQueryForUsers(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { - const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') - .select('blocking.blockeeId') - .where('blocking.blockerId = :blockerId', { blockerId: me.id }); - - const blockedQuery = this.blockingsRepository.createQueryBuilder('blocking') - .select('blocking.blockerId') - .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); - - q.andWhere(`user.id NOT IN (${ blockingQuery.getQuery() })`); - q.setParameters(blockingQuery.getParameters()); - - q.andWhere(`user.id NOT IN (${ blockedQuery.getQuery() })`); - q.setParameters(blockedQuery.getParameters()); - } - - @bindThis - public generateMutedNoteThreadQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { - const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') - .select('threadMuted.threadId') - .where('threadMuted.userId = :userId', { userId: me.id }); - - q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); - q.andWhere(new Brackets(qb => { - qb - .where('note.threadId IS NULL') - .orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`); - })); - - q.setParameters(mutedQuery.getParameters()); - } - - @bindThis - public generateMutedUserQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void { - const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') - .select('muting.muteeId') - .where('muting.muterId = :muterId', { muterId: me.id }); - - if (exclude) { - mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id }); - } - - const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile') - .select('user_profile.mutedInstances') - .where('user_profile.userId = :muterId', { muterId: me.id }); - - // 投稿の作者をミュートしていない かつ - // 投稿の返信先の作者をミュートしていない かつ - // 投稿の引用元の作者をミュートしていない - q - .andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`) - .andWhere(new Brackets(qb => { - qb - .where('note.replyUserId IS NULL') - .orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`); - })) - .andWhere(new Brackets(qb => { - qb - .where('note.renoteUserId IS NULL') - .orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`); - })) - // mute instances - .andWhere(new Brackets(qb => { - qb - .andWhere('note.userHost IS NULL') - .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`); - })) - .andWhere(new Brackets(qb => { - qb - .where('note.replyUserHost IS NULL') - .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`); - })) - .andWhere(new Brackets(qb => { - qb - .where('note.renoteUserHost IS NULL') - .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`); - })); - - q.setParameters(mutingQuery.getParameters()); - q.setParameters(mutingInstanceQuery.getParameters()); - } - - @bindThis - public generateMutedUserQueryForUsers(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { - const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') - .select('muting.muteeId') - .where('muting.muterId = :muterId', { muterId: me.id }); - - q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`); - - q.setParameters(mutingQuery.getParameters()); - } - - @bindThis - public generateVisibilityQuery(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null): void { - // This code must always be synchronized with the checks in Notes.isVisibleForMe. - if (me == null) { - q.andWhere(new Brackets(qb => { - qb - .where('note.visibility = \'public\'') - .orWhere('note.visibility = \'home\''); - })); - } else { - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :meId'); - - q.andWhere(new Brackets(qb => { - qb - // 公開投稿である - .where(new Brackets(qb => { - qb - .where('note.visibility = \'public\'') - .orWhere('note.visibility = \'home\''); - })) - // または 自分自身 - .orWhere('note.userId = :meId') - // または 自分宛て - .orWhere(':meId = ANY(note.visibleUserIds)') - .orWhere(':meId = ANY(note.mentions)') - .orWhere(new Brackets(qb => { - qb - // または フォロワー宛ての投稿であり、 - .where('note.visibility = \'followers\'') - .andWhere(new Brackets(qb => { - qb - // 自分がフォロワーである - .where(`note.userId IN (${ followingQuery.getQuery() })`) - // または 自分の投稿へのリプライ - .orWhere('note.replyUserId = :meId'); - })); - })); - })); - - q.setParameters({ meId: me.id }); - } - } - - @bindThis - public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { - const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting') - .select('renote_muting.muteeId') - .where('renote_muting.muterId = :muterId', { muterId: me.id }); - - q.andWhere(new Brackets(qb => { - qb - .where(new Brackets(qb => { - qb.where('note.renoteId IS NOT NULL'); - qb.andWhere('note.text IS NULL'); - qb.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`); - })) - .orWhere('note.renoteId IS NULL') - .orWhere('note.text IS NOT NULL'); - })); - - q.setParameters(mutingQuery.getParameters()); - } -} diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts deleted file mode 100644 index 4444dc9787..0000000000 --- a/packages/backend/src/core/QueueModule.ts +++ /dev/null @@ -1,132 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { setTimeout } from 'node:timers/promises'; -import { Inject, Module, OnApplicationShutdown } from '@nestjs/common'; -import * as Bull from 'bullmq'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { QUEUE, baseQueueOptions } from '@/queue/const.js'; -import type { Provider } from '@nestjs/common'; -import type { DeliverJobData, InboxJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData } from '../queue/types.js'; - -export type SystemQueue = Bull.Queue>; -export type EndedPollNotificationQueue = Bull.Queue; -export type DeliverQueue = Bull.Queue; -export type InboxQueue = Bull.Queue; -export type DbQueue = Bull.Queue; -export type RelationshipQueue = Bull.Queue; -export type ObjectStorageQueue = Bull.Queue; -export type WebhookDeliverQueue = Bull.Queue; - -const $system: Provider = { - provide: 'queue:system', - useFactory: (config: Config) => new Bull.Queue(QUEUE.SYSTEM, baseQueueOptions(config, QUEUE.SYSTEM)), - inject: [DI.config], -}; - -const $endedPollNotification: Provider = { - provide: 'queue:endedPollNotification', - useFactory: (config: Config) => new Bull.Queue(QUEUE.ENDED_POLL_NOTIFICATION, baseQueueOptions(config, QUEUE.ENDED_POLL_NOTIFICATION)), - inject: [DI.config], -}; - -const $deliver: Provider = { - provide: 'queue:deliver', - useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)), - inject: [DI.config], -}; - -const $inbox: Provider = { - provide: 'queue:inbox', - useFactory: (config: Config) => new Bull.Queue(QUEUE.INBOX, baseQueueOptions(config, QUEUE.INBOX)), - inject: [DI.config], -}; - -const $db: Provider = { - provide: 'queue:db', - useFactory: (config: Config) => new Bull.Queue(QUEUE.DB, baseQueueOptions(config, QUEUE.DB)), - inject: [DI.config], -}; - -const $relationship: Provider = { - provide: 'queue:relationship', - useFactory: (config: Config) => new Bull.Queue(QUEUE.RELATIONSHIP, baseQueueOptions(config, QUEUE.RELATIONSHIP)), - inject: [DI.config], -}; - -const $objectStorage: Provider = { - provide: 'queue:objectStorage', - useFactory: (config: Config) => new Bull.Queue(QUEUE.OBJECT_STORAGE, baseQueueOptions(config, QUEUE.OBJECT_STORAGE)), - inject: [DI.config], -}; - -const $webhookDeliver: Provider = { - provide: 'queue:webhookDeliver', - useFactory: (config: Config) => new Bull.Queue(QUEUE.WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.WEBHOOK_DELIVER)), - inject: [DI.config], -}; - -@Module({ - imports: [ - ], - providers: [ - $system, - $endedPollNotification, - $deliver, - $inbox, - $db, - $relationship, - $objectStorage, - $webhookDeliver, - ], - exports: [ - $system, - $endedPollNotification, - $deliver, - $inbox, - $db, - $relationship, - $objectStorage, - $webhookDeliver, - ], -}) -export class QueueModule implements OnApplicationShutdown { - constructor( - @Inject('queue:system') public systemQueue: SystemQueue, - @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, - @Inject('queue:deliver') public deliverQueue: DeliverQueue, - @Inject('queue:inbox') public inboxQueue: InboxQueue, - @Inject('queue:db') public dbQueue: DbQueue, - @Inject('queue:relationship') public relationshipQueue: RelationshipQueue, - @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, - @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, - ) {} - - public async dispose(): Promise { - if (process.env.NODE_ENV === 'test') { - // XXX: - // Shutting down the existing connections causes errors on Jest as - // Misskey has asynchronous postgres/redis connections that are not - // awaited. - // Let's wait for some random time for them to finish. - await setTimeout(5000); - } - await Promise.all([ - this.systemQueue.close(), - this.endedPollNotificationQueue.close(), - this.deliverQueue.close(), - this.inboxQueue.close(), - this.dbQueue.close(), - this.relationshipQueue.close(), - this.objectStorageQueue.close(), - this.webhookDeliverQueue.close(), - ]); - } - - async onApplicationShutdown(signal: string): Promise { - await this.dispose(); - } -} diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts deleted file mode 100644 index 2ee61eb549..0000000000 --- a/packages/backend/src/core/QueueService.ts +++ /dev/null @@ -1,510 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { randomUUID } from 'node:crypto'; -import { Inject, Injectable } from '@nestjs/common'; -import type { IActivity } from '@/core/activitypub/type.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiWebhook, webhookEventTypes } from '@/models/Webhook.js'; -import type { Config } from '@/config.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; -import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js'; -import type { DbJobData, DeliverJobData, RelationshipJobData, ThinUser } from '../queue/types.js'; -import type httpSignature from '@peertube/http-signature'; -import type * as Bull from 'bullmq'; -import { MiNote } from '@/models/Note.js'; - -@Injectable() -export class QueueService { - constructor( - @Inject(DI.config) - private config: Config, - - @Inject('queue:system') public systemQueue: SystemQueue, - @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, - @Inject('queue:deliver') public deliverQueue: DeliverQueue, - @Inject('queue:inbox') public inboxQueue: InboxQueue, - @Inject('queue:db') public dbQueue: DbQueue, - @Inject('queue:relationship') public relationshipQueue: RelationshipQueue, - @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, - @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, - ) { - this.systemQueue.add('tickCharts', { - }, { - repeat: { pattern: '55 * * * *' }, - removeOnComplete: true, - }); - - this.systemQueue.add('resyncCharts', { - }, { - repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, - }); - - this.systemQueue.add('cleanCharts', { - }, { - repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, - }); - - this.systemQueue.add('aggregateRetention', { - }, { - repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, - }); - - this.systemQueue.add('clean', { - }, { - repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, - }); - - this.systemQueue.add('checkExpiredMutings', { - }, { - repeat: { pattern: '*/5 * * * *' }, - removeOnComplete: true, - }); - } - - @bindThis - public deliver(user: ThinUser, content: IActivity | null, to: string | null, isSharedInbox: boolean) { - if (content == null) return null; - if (to == null) return null; - - const data: DeliverJobData = { - user: { - id: user.id, - }, - content, - to, - isSharedInbox, - }; - - return this.deliverQueue.add(to, data, { - attempts: this.config.deliverJobMaxAttempts ?? 12, - backoff: { - type: 'custom', - }, - removeOnComplete: true, - removeOnFail: true, - }); - } - - /** - * ApDeliverManager-DeliverManager.execute()からinboxesを突っ込んでaddBulkしたい - * @param user `{ id: string; }` この関数ではThinUserに変換しないので前もって変換してください - * @param content IActivity | null - * @param inboxes `Map` / key: to (inbox url), value: isSharedInbox (whether it is sharedInbox) - * @returns void - */ - @bindThis - public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map) { - if (content == null) return null; - - const opts = { - attempts: this.config.deliverJobMaxAttempts ?? 12, - backoff: { - type: 'custom', - }, - removeOnComplete: true, - removeOnFail: true, - }; - - await this.deliverQueue.addBulk(Array.from(inboxes.entries(), d => ({ - name: d[0], - data: { - user, - content, - to: d[0], - isSharedInbox: d[1], - } as DeliverJobData, - opts, - }))); - - return; - } - - @bindThis - public inbox(activity: IActivity, signature: httpSignature.IParsedSignature) { - const data = { - activity: activity, - signature, - }; - - return this.inboxQueue.add('', data, { - attempts: this.config.inboxJobMaxAttempts ?? 8, - backoff: { - type: 'custom', - }, - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createDeleteDriveFilesJob(user: ThinUser) { - return this.dbQueue.add('deleteDriveFiles', { - user: { id: user.id }, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createExportCustomEmojisJob(user: ThinUser) { - return this.dbQueue.add('exportCustomEmojis', { - user: { id: user.id }, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createExportAccountDataJob(user: ThinUser) { - return this.dbQueue.add('exportAccountData', { - user: { id: user.id }, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createExportNotesJob(user: ThinUser) { - return this.dbQueue.add('exportNotes', { - user: { id: user.id }, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createExportFavoritesJob(user: ThinUser) { - return this.dbQueue.add('exportFavorites', { - user: { id: user.id }, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) { - return this.dbQueue.add('exportFollowing', { - user: { id: user.id }, - excludeMuting, - excludeInactive, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createExportMuteJob(user: ThinUser) { - return this.dbQueue.add('exportMuting', { - user: { id: user.id }, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createExportBlockingJob(user: ThinUser) { - return this.dbQueue.add('exportBlocking', { - user: { id: user.id }, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createExportUserListsJob(user: ThinUser) { - return this.dbQueue.add('exportUserLists', { - user: { id: user.id }, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createExportAntennasJob(user: ThinUser) { - return this.dbQueue.add('exportAntennas', { - user: { id: user.id }, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createImportFollowingJob(user: ThinUser, fileId: MiDriveFile['id'], withReplies?: boolean) { - return this.dbQueue.add('importFollowing', { - user: { id: user.id }, - fileId: fileId, - withReplies, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createImportNotesJob(user: ThinUser, fileId: MiDriveFile['id'], type: string | null | undefined) { - return this.dbQueue.add('importNotes', { - user: { id: user.id }, - fileId: fileId, - type: type, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createImportTweetsToDbJob(user: ThinUser, targets: string[], note: MiNote['id'] | null) { - const jobs = targets.map(rel => this.generateToDbJobData('importTweetsToDb', { user, target: rel, note })); - return this.dbQueue.addBulk(jobs); - } - - @bindThis - public createImportMastoToDbJob(user: ThinUser, targets: string[], note: MiNote['id'] | null) { - const jobs = targets.map(rel => this.generateToDbJobData('importMastoToDb', { user, target: rel, note })); - return this.dbQueue.addBulk(jobs); - } - - @bindThis - public createImportPleroToDbJob(user: ThinUser, targets: string[], note: MiNote['id'] | null) { - const jobs = targets.map(rel => this.generateToDbJobData('importPleroToDb', { user, target: rel, note })); - return this.dbQueue.addBulk(jobs); - } - - @bindThis - public createImportKeyNotesToDbJob(user: ThinUser, targets: string[], note: MiNote['id'] | null) { - const jobs = targets.map(rel => this.generateToDbJobData('importKeyNotesToDb', { user, target: rel, note })); - return this.dbQueue.addBulk(jobs); - } - - @bindThis - public createImportIGToDbJob(user: ThinUser, targets: string[]) { - const jobs = targets.map(rel => this.generateToDbJobData('importIGToDb', { user, target: rel })); - return this.dbQueue.addBulk(jobs); - } - - @bindThis - public createImportFBToDbJob(user: ThinUser, targets: string[]) { - const jobs = targets.map(rel => this.generateToDbJobData('importFBToDb', { user, target: rel })); - return this.dbQueue.addBulk(jobs); - } - - @bindThis - public createImportFollowingToDbJob(user: ThinUser, targets: string[], withReplies?: boolean) { - const jobs = targets.map(rel => this.generateToDbJobData('importFollowingToDb', { user, target: rel, withReplies })); - return this.dbQueue.addBulk(jobs); - } - - @bindThis - public createImportMutingJob(user: ThinUser, fileId: MiDriveFile['id']) { - return this.dbQueue.add('importMuting', { - user: { id: user.id }, - fileId: fileId, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createImportBlockingJob(user: ThinUser, fileId: MiDriveFile['id']) { - return this.dbQueue.add('importBlocking', { - user: { id: user.id }, - fileId: fileId, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createImportBlockingToDbJob(user: ThinUser, targets: string[]) { - const jobs = targets.map(rel => this.generateToDbJobData('importBlockingToDb', { user, target: rel })); - return this.dbQueue.addBulk(jobs); - } - - @bindThis - private generateToDbJobData>(name: T, data: D): { - name: string, - data: D, - opts: Bull.JobsOptions, - } { - return { - name, - data, - opts: { - removeOnComplete: true, - removeOnFail: true, - }, - }; - } - - @bindThis - public createImportUserListsJob(user: ThinUser, fileId: MiDriveFile['id']) { - return this.dbQueue.add('importUserLists', { - user: { id: user.id }, - fileId: fileId, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createImportCustomEmojisJob(user: ThinUser, fileId: MiDriveFile['id']) { - return this.dbQueue.add('importCustomEmojis', { - user: { id: user.id }, - fileId: fileId, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createImportAntennasJob(user: ThinUser, antenna: Antenna) { - return this.dbQueue.add('importAntennas', { - user: { id: user.id }, - antenna, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) { - return this.dbQueue.add('deleteAccount', { - user: { id: user.id }, - soft: opts.soft, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createFollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string, silent?: boolean, withReplies?: boolean }[]) { - const jobs = followings.map(rel => this.generateRelationshipJobData('follow', rel)); - return this.relationshipQueue.addBulk(jobs); - } - - @bindThis - public createUnfollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string }[]) { - const jobs = followings.map(rel => this.generateRelationshipJobData('unfollow', rel)); - return this.relationshipQueue.addBulk(jobs); - } - - @bindThis - public createDelayedUnfollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string }[], delay: number) { - const jobs = followings.map(rel => this.generateRelationshipJobData('unfollow', rel, { delay })); - return this.relationshipQueue.addBulk(jobs); - } - - @bindThis - public createBlockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) { - const jobs = blockings.map(rel => this.generateRelationshipJobData('block', rel)); - return this.relationshipQueue.addBulk(jobs); - } - - @bindThis - public createUnblockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) { - const jobs = blockings.map(rel => this.generateRelationshipJobData('unblock', rel)); - return this.relationshipQueue.addBulk(jobs); - } - - @bindThis - private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData, opts: Bull.JobsOptions = {}): { - name: string, - data: RelationshipJobData, - opts: Bull.JobsOptions, - } { - return { - name, - data: { - from: { id: data.from.id }, - to: { id: data.to.id }, - silent: data.silent, - requestId: data.requestId, - withReplies: data.withReplies, - }, - opts: { - removeOnComplete: true, - removeOnFail: true, - ...opts, - }, - }; - } - - @bindThis - public createDeleteObjectStorageFileJob(key: string) { - return this.objectStorageQueue.add('deleteFile', { - key: key, - }, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public createCleanRemoteFilesJob() { - return this.objectStorageQueue.add('cleanRemoteFiles', {}, { - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public webhookDeliver(webhook: MiWebhook, type: typeof webhookEventTypes[number], content: unknown) { - const data = { - type, - content, - webhookId: webhook.id, - userId: webhook.userId, - to: webhook.url, - secret: webhook.secret, - createdAt: Date.now(), - eventId: randomUUID(), - }; - - return this.webhookDeliverQueue.add(webhook.id, data, { - attempts: 4, - backoff: { - type: 'custom', - }, - removeOnComplete: true, - removeOnFail: true, - }); - } - - @bindThis - public destroy() { - this.deliverQueue.once('cleaned', (jobs, status) => { - //deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); - }); - this.deliverQueue.clean(0, 0, 'delayed'); - - this.inboxQueue.once('cleaned', (jobs, status) => { - //inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); - }); - this.inboxQueue.clean(0, 0, 'delayed'); - } -} diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts deleted file mode 100644 index 0daee34ce5..0000000000 --- a/packages/backend/src/core/ReactionService.ts +++ /dev/null @@ -1,415 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * 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 { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository, NoteThreadMutingsRepository } from '@/models/_.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; -import type { MiRemoteUser, MiUser } from '@/models/User.js'; -import type { MiNote } from '@/models/Note.js'; -import { IdService } from '@/core/IdService.js'; -import type { MiNoteReaction } from '@/models/NoteReaction.js'; -import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { NotificationService } from '@/core/NotificationService.js'; -import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js'; -import { emojiRegex } from '@/misc/emoji-regex.js'; -import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { bindThis } from '@/decorators.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { UserBlockingService } from '@/core/UserBlockingService.js'; -import { CustomEmojiService } from '@/core/CustomEmojiService.js'; -import { RoleService } from '@/core/RoleService.js'; -import { FeaturedService } from '@/core/FeaturedService.js'; - -const FALLBACK = '❤'; -const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16; - -const legacies: Record = { - 'like': '👍', - 'love': '❤', // ここに記述する場合は異体字セレクタを入れない - 'laugh': '😆', - 'hmm': '🤔', - 'surprise': '😮', - 'congrats': '🎉', - 'angry': '💢', - 'confused': '😥', - 'rip': '😇', - 'pudding': '🍮', - 'star': '⭐', -}; - -type DecodedReaction = { - /** - * リアクション名 (Unicode Emoji or ':name@hostname' or ':name@.') - */ - reaction: string; - - /** - * name (カスタム絵文字の場合name, Emojiクエリに使う) - */ - name?: string; - - /** - * host (カスタム絵文字の場合host, Emojiクエリに使う) - */ - host?: string | null; -}; - -const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/; -const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/; - -@Injectable() -export class ReactionService { - constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.noteReactionsRepository) - private noteReactionsRepository: NoteReactionsRepository, - - @Inject(DI.noteThreadMutingsRepository) - private noteThreadMutingsRepository: NoteThreadMutingsRepository, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private utilityService: UtilityService, - private metaService: MetaService, - private customEmojiService: CustomEmojiService, - private roleService: RoleService, - private userEntityService: UserEntityService, - private noteEntityService: NoteEntityService, - private userBlockingService: UserBlockingService, - private idService: IdService, - private featuredService: FeaturedService, - private globalEventService: GlobalEventService, - private apRendererService: ApRendererService, - private apDeliverManagerService: ApDeliverManagerService, - private notificationService: NotificationService, - private perUserReactionsChart: PerUserReactionsChart, - ) { - } - - @bindThis - public async create(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot'] }, note: MiNote, _reaction?: string | null) { - // Check blocking - if (note.userId !== user.id) { - const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id); - if (blocked) { - throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7'); - } - } - - // check visibility - if (!await this.noteEntityService.isVisibleForMe(note, user.id)) { - throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); - } - - let reaction = _reaction ?? FALLBACK; - - if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && (user.host != null))) { - reaction = '❤️'; - } else if (_reaction) { - const custom = reaction.match(isCustomEmojiRegexp); - if (custom) { - const reacterHost = this.utilityService.toPunyNullable(user.host); - - const name = custom[1]; - const emoji = reacterHost == null - ? (await this.customEmojiService.localEmojisCache.fetch()).get(name) - : await this.emojisRepository.findOneBy({ - host: reacterHost, - name, - }); - - if (emoji) { - if (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0 || (await this.roleService.getUserRoles(user.id)).some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id))) { - reaction = reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`; - - // センシティブ - if ((note.reactionAcceptance === 'nonSensitiveOnly' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && emoji.isSensitive) { - reaction = FALLBACK; - } - } else { - // リアクションとして使う権限がない - reaction = FALLBACK; - } - } else { - reaction = FALLBACK; - } - } else { - reaction = this.normalize(reaction); - } - } - - const record: MiNoteReaction = { - id: this.idService.gen(), - noteId: note.id, - userId: user.id, - reaction, - }; - - // Create reaction - try { - await this.noteReactionsRepository.insert(record); - } catch (e) { - if (isDuplicateKeyValueError(e)) { - const exists = await this.noteReactionsRepository.findOneByOrFail({ - noteId: note.id, - userId: user.id, - }); - - if (exists.reaction !== reaction) { - // 別のリアクションがすでにされていたら置き換える - await this.delete(user, note); - await this.noteReactionsRepository.insert(record); - } else { - // 同じリアクションがすでにされていたらエラー - throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298'); - } - } else { - throw e; - } - } - - // 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(); - - // 30%の確率、セルフではない、3日以内に投稿されたノートの場合ハイライト用ランキング更新 - if ( - Math.random() < 0.3 && - note.userId !== user.id && - (Date.now() - this.idService.parse(note.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3 - ) { - if (note.channelId != null) { - if (note.replyId == null) { - this.featuredService.updateInChannelNotesRanking(note.channelId, note.id, 1); - } - } else { - if (note.visibility === 'public' && note.userHost == null && note.replyId == null) { - this.featuredService.updateGlobalNotesRanking(note.id, 1); - this.featuredService.updatePerUserNotesRanking(note.userId, note.id, 1); - } - } - } - - const meta = await this.metaService.fetch(); - - if (meta.enableChartsForRemoteUser || (user.host == null)) { - this.perUserReactionsChart.update(user, note); - } - - // カスタム絵文字リアクションだったら絵文字情報も送る - const decodedReaction = this.decodeReaction(reaction); - - const customEmoji = decodedReaction.name == null ? null : decodedReaction.host == null - ? (await this.customEmojiService.localEmojisCache.fetch()).get(decodedReaction.name) - : await this.emojisRepository.findOne( - { - where: { - name: decodedReaction.name, - host: decodedReaction.host, - }, - }); - - this.globalEventService.publishNoteStream(note.id, 'reacted', { - reaction: decodedReaction.reaction, - emoji: customEmoji != null ? { - name: customEmoji.host ? `${customEmoji.name}@${customEmoji.host}` : `${customEmoji.name}@.`, - // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - url: customEmoji.publicUrl || customEmoji.originalUrl, - } : null, - userId: user.id, - }); - - // リアクションされたユーザーがローカルユーザーなら通知を作成 - if (note.userHost === null) { - const isThreadMuted = await this.noteThreadMutingsRepository.exist({ - where: { - userId: note.userId, - threadId: note.threadId ?? note.id, - }, - }); - - if (!isThreadMuted) { - this.notificationService.createNotification(note.userId, 'reaction', { - noteId: note.id, - reaction: reaction, - }, user.id); - } - } - - //#region 配信 - if (this.userEntityService.isLocalUser(user) && !note.localOnly) { - const content = this.apRendererService.addContext(await this.apRendererService.renderLike(record, note)); - const dm = this.apDeliverManagerService.createDeliverManager(user, content); - if (note.userHost !== null) { - const reactee = await this.usersRepository.findOneBy({ id: note.userId }); - dm.addDirectRecipe(reactee as MiRemoteUser); - } - - if (['public', 'home', 'followers'].includes(note.visibility)) { - dm.addFollowersRecipe(); - } else if (note.visibility === 'specified') { - const visibleUsers = await Promise.all(note.visibleUserIds.map(id => this.usersRepository.findOneBy({ id }))); - for (const u of visibleUsers.filter(u => u && this.userEntityService.isRemoteUser(u))) { - dm.addDirectRecipe(u as MiRemoteUser); - } - } - - dm.execute(); - } - //#endregion - } - - @bindThis - public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote) { - // if already unreacted - const exist = await this.noteReactionsRepository.findOneBy({ - noteId: note.id, - userId: user.id, - }); - - if (exist == null) { - throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); - } - - // Delete reaction - const result = await this.noteReactionsRepository.delete(exist.id); - - if (result.affected !== 1) { - throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); - } - - // 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(); - - this.globalEventService.publishNoteStream(note.id, 'unreacted', { - reaction: this.decodeReaction(exist.reaction).reaction, - userId: user.id, - }); - - //#region 配信 - if (this.userEntityService.isLocalUser(user) && !note.localOnly) { - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(await this.apRendererService.renderLike(exist, note), user)); - const dm = this.apDeliverManagerService.createDeliverManager(user, content); - if (note.userHost !== null) { - const reactee = await this.usersRepository.findOneBy({ id: note.userId }); - dm.addDirectRecipe(reactee as MiRemoteUser); - } - dm.addFollowersRecipe(); - dm.execute(); - } - //#endregion - } - - @bindThis - public convertLegacyReactions(reactions: Record) { - const _reactions = {} as Record; - - for (const reaction of Object.keys(reactions)) { - if (reactions[reaction] <= 0) continue; - - if (Object.keys(legacies).includes(reaction)) { - if (_reactions[legacies[reaction]]) { - _reactions[legacies[reaction]] += reactions[reaction]; - } else { - _reactions[legacies[reaction]] = reactions[reaction]; - } - } else { - if (_reactions[reaction]) { - _reactions[reaction] += reactions[reaction]; - } else { - _reactions[reaction] = reactions[reaction]; - } - } - } - - const _reactions2 = {} as Record; - - for (const reaction of Object.keys(_reactions)) { - _reactions2[this.decodeReaction(reaction).reaction] = _reactions[reaction]; - } - - return _reactions2; - } - - @bindThis - public normalize(reaction: string | null): string { - if (reaction == null) return FALLBACK; - - // 文字列タイプのリアクションを絵文字に変換 - if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; - - // Unicode絵文字 - const match = emojiRegex.exec(reaction); - if (match) { - // 合字を含む1つの絵文字 - const unicode = match[0]; - - // 異体字セレクタ除去 - return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, ''); - } - - return FALLBACK; - } - - @bindThis - public decodeReaction(str: string): DecodedReaction { - const custom = str.match(decodeCustomEmojiRegexp); - - if (custom) { - const name = custom[1]; - const host = custom[2] ?? null; - - return { - reaction: `:${name}@${host ?? '.'}:`, // ローカル分は@以降を省略するのではなく.にする - name, - host, - }; - } - - return { - reaction: str, - name: undefined, - host: undefined, - }; - } - - @bindThis - public convertLegacyReaction(reaction: string): string { - reaction = this.decodeReaction(reaction).reaction; - if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; - return reaction; - } -} diff --git a/packages/backend/src/core/RegistryApiService.ts b/packages/backend/src/core/RegistryApiService.ts deleted file mode 100644 index d340c5e480..0000000000 --- a/packages/backend/src/core/RegistryApiService.ts +++ /dev/null @@ -1,147 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { MiRegistryItem, RegistryItemsRepository } from '@/models/_.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; -import type { MiUser } from '@/models/User.js'; -import { IdService } from '@/core/IdService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class RegistryApiService { - constructor( - @Inject(DI.registryItemsRepository) - private registryItemsRepository: RegistryItemsRepository, - - private idService: IdService, - private globalEventService: GlobalEventService, - ) { - } - - @bindThis - public async set(userId: MiUser['id'], domain: string | null, scope: string[], key: string, value: any) { - // TODO: 作成できるキーの数を制限する - - const query = this.registryItemsRepository.createQueryBuilder('item'); - if (domain) { - query.where('item.domain = :domain', { domain: domain }); - } else { - query.where('item.domain IS NULL'); - } - query.andWhere('item.userId = :userId', { userId: userId }); - query.andWhere('item.key = :key', { key: key }); - query.andWhere('item.scope = :scope', { scope: scope }); - - const existingItem = await query.getOne(); - - if (existingItem) { - await this.registryItemsRepository.update(existingItem.id, { - updatedAt: new Date(), - value: value, - }); - } else { - await this.registryItemsRepository.insert({ - id: this.idService.gen(), - updatedAt: new Date(), - userId: userId, - domain: domain, - scope: scope, - key: key, - value: value, - }); - } - - if (domain == null) { - // TODO: サードパーティアプリが傍受出来てしまうのでどうにかする - this.globalEventService.publishMainStream(userId, 'registryUpdated', { - scope: scope, - key: key, - value: value, - }); - } - } - - @bindThis - public async getItem(userId: MiUser['id'], domain: string | null, scope: string[], key: string): Promise { - const query = this.registryItemsRepository.createQueryBuilder('item') - .where(domain == null ? 'item.domain IS NULL' : 'item.domain = :domain', { domain: domain }) - .andWhere('item.userId = :userId', { userId: userId }) - .andWhere('item.key = :key', { key: key }) - .andWhere('item.scope = :scope', { scope: scope }); - - const item = await query.getOne(); - - return item; - } - - @bindThis - public async getAllItemsOfScope(userId: MiUser['id'], domain: string | null, scope: string[]): Promise { - const query = this.registryItemsRepository.createQueryBuilder('item'); - query.where(domain == null ? 'item.domain IS NULL' : 'item.domain = :domain', { domain: domain }); - query.andWhere('item.userId = :userId', { userId: userId }); - query.andWhere('item.scope = :scope', { scope: scope }); - - const items = await query.getMany(); - - return items; - } - - @bindThis - public async getAllKeysOfScope(userId: MiUser['id'], domain: string | null, scope: string[]): Promise { - const query = this.registryItemsRepository.createQueryBuilder('item'); - query.select('item.key'); - query.where(domain == null ? 'item.domain IS NULL' : 'item.domain = :domain', { domain: domain }); - query.andWhere('item.userId = :userId', { userId: userId }); - query.andWhere('item.scope = :scope', { scope: scope }); - - const items = await query.getMany(); - - return items.map(x => x.key); - } - - @bindThis - public async getAllScopeAndDomains(userId: MiUser['id']): Promise<{ domain: string | null; scopes: string[][] }[]> { - const query = this.registryItemsRepository.createQueryBuilder('item') - .select(['item.scope', 'item.domain']) - .where('item.userId = :userId', { userId: userId }); - - const items = await query.getMany(); - - const res = [] as { domain: string | null; scopes: string[][] }[]; - - for (const item of items) { - const target = res.find(x => x.domain === item.domain); - if (target) { - if (target.scopes.some(scope => scope.join('.') === item.scope.join('.'))) continue; - target.scopes.push(item.scope); - } else { - res.push({ - domain: item.domain, - scopes: [item.scope], - }); - } - } - - return res; - } - - @bindThis - public async remove(userId: MiUser['id'], domain: string | null, scope: string[], key: string) { - const query = this.registryItemsRepository.createQueryBuilder().delete(); - if (domain) { - query.where('domain = :domain', { domain: domain }); - } else { - query.where('domain IS NULL'); - } - query.andWhere('userId = :userId', { userId: userId }); - query.andWhere('key = :key', { key: key }); - query.andWhere('scope = :scope', { scope: scope }); - - await query.execute(); - } -} diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts deleted file mode 100644 index d40cd080c7..0000000000 --- a/packages/backend/src/core/RelayService.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull } from 'typeorm'; -import type { MiLocalUser, MiUser } from '@/models/User.js'; -import type { RelaysRepository, UsersRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { MemorySingleCache } from '@/misc/cache.js'; -import type { MiRelay } from '@/models/Relay.js'; -import { QueueService } from '@/core/QueueService.js'; -import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { DI } from '@/di-symbols.js'; -import { deepClone } from '@/misc/clone.js'; -import { bindThis } from '@/decorators.js'; - -const ACTOR_USERNAME = 'relay.actor' as const; - -@Injectable() -export class RelayService { - private relaysCache: MemorySingleCache; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.relaysRepository) - private relaysRepository: RelaysRepository, - - private idService: IdService, - private queueService: QueueService, - private createSystemUserService: CreateSystemUserService, - private apRendererService: ApRendererService, - ) { - this.relaysCache = new MemorySingleCache(1000 * 60 * 10); - } - - @bindThis - private async getRelayActor(): Promise { - const user = await this.usersRepository.findOneBy({ - host: IsNull(), - username: ACTOR_USERNAME, - }); - - if (user) return user as MiLocalUser; - - const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME); - return created as MiLocalUser; - } - - @bindThis - public async addRelay(inbox: string): Promise { - const relay = await this.relaysRepository.insert({ - id: this.idService.gen(), - inbox, - status: 'requesting', - }).then(x => this.relaysRepository.findOneByOrFail(x.identifiers[0])); - - const relayActor = await this.getRelayActor(); - const follow = await this.apRendererService.renderFollowRelay(relay, relayActor); - const activity = this.apRendererService.addContext(follow); - this.queueService.deliver(relayActor, activity, relay.inbox, false); - - return relay; - } - - @bindThis - public async removeRelay(inbox: string): Promise { - const relay = await this.relaysRepository.findOneBy({ - inbox, - }); - - if (relay == null) { - throw new Error('relay not found'); - } - - const relayActor = await this.getRelayActor(); - const follow = this.apRendererService.renderFollowRelay(relay, relayActor); - const undo = this.apRendererService.renderUndo(follow, relayActor); - const activity = this.apRendererService.addContext(undo); - this.queueService.deliver(relayActor, activity, relay.inbox, false); - - await this.relaysRepository.delete(relay.id); - } - - @bindThis - public async listRelay(): Promise { - const relays = await this.relaysRepository.find(); - return relays; - } - - @bindThis - public async relayAccepted(id: string): Promise { - const result = await this.relaysRepository.update(id, { - status: 'accepted', - }); - - return JSON.stringify(result); - } - - @bindThis - public async relayRejected(id: string): Promise { - const result = await this.relaysRepository.update(id, { - status: 'rejected', - }); - - return JSON.stringify(result); - } - - @bindThis - public async deliverToRelays(user: { id: MiUser['id']; host: null; }, activity: any): Promise { - if (activity == null) return; - - const relays = await this.relaysCache.fetch(() => this.relaysRepository.findBy({ - status: 'accepted', - })); - if (relays.length === 0) return; - - const copy = deepClone(activity); - if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public']; - - const signed = await this.apRendererService.attachLdSignature(copy, user); - - for (const relay of relays) { - this.queueService.deliver(user, signed, relay.inbox, false); - } - } -} diff --git a/packages/backend/src/core/RemoteLoggerService.ts b/packages/backend/src/core/RemoteLoggerService.ts deleted file mode 100644 index 5d13988ed7..0000000000 --- a/packages/backend/src/core/RemoteLoggerService.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import type Logger from '@/logger.js'; -import { LoggerService } from '@/core/LoggerService.js'; - -@Injectable() -export class RemoteLoggerService { - public logger: Logger; - - constructor( - private loggerService: LoggerService, - ) { - this.logger = this.loggerService.getLogger('remote', 'cyan'); - } -} diff --git a/packages/backend/src/core/RemoteUserResolveService.ts b/packages/backend/src/core/RemoteUserResolveService.ts deleted file mode 100644 index 75c5f14aa4..0000000000 --- a/packages/backend/src/core/RemoteUserResolveService.ts +++ /dev/null @@ -1,158 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { URL } from 'node:url'; -import { Inject, Injectable } from '@nestjs/common'; -import chalk from 'chalk'; -import { IsNull } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { UsersRepository } from '@/models/_.js'; -import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; -import type { Config } from '@/config.js'; -import type Logger from '@/logger.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { ILink, WebfingerService } from '@/core/WebfingerService.js'; -import { RemoteLoggerService } from '@/core/RemoteLoggerService.js'; -import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; -import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class RemoteUserResolveService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private utilityService: UtilityService, - private webfingerService: WebfingerService, - private remoteLoggerService: RemoteLoggerService, - private apDbResolverService: ApDbResolverService, - private apPersonService: ApPersonService, - ) { - this.logger = this.remoteLoggerService.logger.createSubLogger('resolve-user'); - } - - @bindThis - public async resolveUser(username: string, host: string | null): Promise { - const usernameLower = username.toLowerCase(); - - if (host == null) { - this.logger.info(`return local user: ${usernameLower}`); - return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => { - if (u == null) { - throw new Error('user not found'); - } else { - return u; - } - }) as MiLocalUser; - } - - host = this.utilityService.toPuny(host); - - if (this.config.host === host) { - this.logger.info(`return local user: ${usernameLower}`); - return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => { - if (u == null) { - throw new Error('user not found'); - } else { - return u; - } - }) as MiLocalUser; - } - - const user = await this.usersRepository.findOneBy({ usernameLower, host }) as MiRemoteUser | null; - - const acctLower = `${usernameLower}@${host}`; - - if (user == null) { - const self = await this.resolveSelf(acctLower); - - if (self.href.startsWith(this.config.url)) { - const local = this.apDbResolverService.parseUri(self.href); - if (local.local && local.type === 'users') { - // the LR points to local - return (await this.apDbResolverService - .getUserFromApId(self.href) - .then((u) => { - if (u == null) { - throw new Error('local user not found'); - } else { - return u; - } - })) as MiLocalUser; - } - } - - this.logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`); - return await this.apPersonService.createPerson(self.href); - } - - // ユーザー情報が古い場合は、WebFingerからやりなおして返す - if (user.lastFetchedAt == null || Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { - // 繋がらないインスタンスに何回も試行するのを防ぐ, 後続の同様処理の連続試行を防ぐ ため 試行前にも更新する - await this.usersRepository.update(user.id, { - lastFetchedAt: new Date(), - }); - - this.logger.info(`try resync: ${acctLower}`); - const self = await this.resolveSelf(acctLower); - - if (user.uri !== self.href) { - // if uri mismatch, Fix (user@host <=> AP's Person id(RemoteUser.uri)) mapping. - this.logger.info(`uri missmatch: ${acctLower}`); - this.logger.info(`recovery missmatch uri for (username=${username}, host=${host}) from ${user.uri} to ${self.href}`); - - // validate uri - const uri = new URL(self.href); - if (uri.hostname !== host) { - throw new Error('Invalid uri'); - } - - await this.usersRepository.update({ - usernameLower, - host: host, - }, { - uri: self.href, - }); - } else { - this.logger.info(`uri is fine: ${acctLower}`); - } - - await this.apPersonService.updatePerson(self.href); - - this.logger.info(`return resynced remote user: ${acctLower}`); - return await this.usersRepository.findOneBy({ uri: self.href }).then(u => { - if (u == null) { - throw new Error('user not found'); - } else { - return u as MiLocalUser | MiRemoteUser; - } - }); - } - - this.logger.info(`return existing remote user: ${acctLower}`); - return user; - } - - @bindThis - private async resolveSelf(acctLower: string): Promise { - this.logger.info(`WebFinger for ${chalk.yellow(acctLower)}`); - const finger = await this.webfingerService.webfinger(acctLower).catch(err => { - this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ err.statusCode ?? err.message }`); - throw new Error(`Failed to WebFinger for ${acctLower}: ${ err.statusCode ?? err.message }`); - }); - const self = finger.links.find(link => link.rel != null && link.rel.toLowerCase() === 'self'); - if (!self) { - this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`); - throw new Error('self link not found'); - } - return self; - } -} diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts deleted file mode 100644 index dcd9d7399f..0000000000 --- a/packages/backend/src/core/RoleService.ts +++ /dev/null @@ -1,600 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import { In } from 'typeorm'; -import { ModuleRef } from '@nestjs/core'; -import type { - MiRole, - MiRoleAssignment, - RoleAssignmentsRepository, - RolesRepository, - UsersRepository, -} from '@/models/_.js'; -import { MemoryKVCache, MemorySingleCache } from '@/misc/cache.js'; -import type { MiUser } from '@/models/User.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; -import { MetaService } from '@/core/MetaService.js'; -import { CacheService } from '@/core/CacheService.js'; -import type { RoleCondFormulaValue } from '@/models/Role.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import type { GlobalEvents } from '@/core/GlobalEventService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { IdService } from '@/core/IdService.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; -import { NotificationService } from '@/core/NotificationService.js'; -import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; - -export type RolePolicies = { - gtlAvailable: boolean; - ltlAvailable: boolean; - btlAvailable: boolean; - canPublicNote: boolean; - canInvite: boolean; - inviteLimit: number; - inviteLimitCycle: number; - inviteExpirationTime: number; - canManageCustomEmojis: boolean; - canManageAvatarDecorations: boolean; - canSearchNotes: boolean; - canUseTranslator: boolean; - canHideAds: boolean; - driveCapacityMb: number; - alwaysMarkNsfw: boolean; - pinLimit: number; - antennaLimit: number; - wordMuteLimit: number; - webhookLimit: number; - clipLimit: number; - noteEachClipsLimit: number; - userListLimit: number; - userEachUserListsLimit: number; - rateLimitFactor: number; - canImportNotes: boolean; - avatarDecorationLimit: number; -}; - -export const DEFAULT_POLICIES: RolePolicies = { - gtlAvailable: true, - ltlAvailable: true, - btlAvailable: false, - canPublicNote: true, - canInvite: false, - inviteLimit: 0, - inviteLimitCycle: 60 * 24 * 7, - inviteExpirationTime: 0, - canManageCustomEmojis: false, - canManageAvatarDecorations: false, - canSearchNotes: false, - canUseTranslator: true, - canHideAds: false, - driveCapacityMb: 100, - alwaysMarkNsfw: false, - pinLimit: 5, - antennaLimit: 5, - wordMuteLimit: 200, - webhookLimit: 3, - clipLimit: 10, - noteEachClipsLimit: 200, - userListLimit: 10, - userEachUserListsLimit: 50, - rateLimitFactor: 1, - canImportNotes: true, - avatarDecorationLimit: 1, -}; - -@Injectable() -export class RoleService implements OnApplicationShutdown, OnModuleInit { - private rolesCache: MemorySingleCache; - private roleAssignmentByUserIdCache: MemoryKVCache; - private notificationService: NotificationService; - - public static AlreadyAssignedError = class extends Error {}; - public static NotAssignedError = class extends Error {}; - - constructor( - private moduleRef: ModuleRef, - - @Inject(DI.redis) - private redisClient: Redis.Redis, - - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, - - @Inject(DI.redisForSub) - private redisForSub: Redis.Redis, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.rolesRepository) - private rolesRepository: RolesRepository, - - @Inject(DI.roleAssignmentsRepository) - private roleAssignmentsRepository: RoleAssignmentsRepository, - - private metaService: MetaService, - private cacheService: CacheService, - private userEntityService: UserEntityService, - private globalEventService: GlobalEventService, - private idService: IdService, - private moderationLogService: ModerationLogService, - private fanoutTimelineService: FanoutTimelineService, - ) { - //this.onMessage = this.onMessage.bind(this); - - this.rolesCache = new MemorySingleCache(1000 * 60 * 60 * 1); - this.roleAssignmentByUserIdCache = new MemoryKVCache(1000 * 60 * 60 * 1); - - this.redisForSub.on('message', this.onMessage); - } - - async onModuleInit() { - this.notificationService = this.moduleRef.get(NotificationService.name); - } - - @bindThis - private async onMessage(_: string, data: string): Promise { - const obj = JSON.parse(data); - - if (obj.channel === 'internal') { - const { type, body } = obj.message as GlobalEvents['internal']['payload']; - switch (type) { - case 'roleCreated': { - const cached = this.rolesCache.get(); - if (cached) { - cached.push({ - ...body, - updatedAt: new Date(body.updatedAt), - lastUsedAt: new Date(body.lastUsedAt), - }); - } - break; - } - case 'roleUpdated': { - const cached = this.rolesCache.get(); - if (cached) { - const i = cached.findIndex(x => x.id === body.id); - if (i > -1) { - cached[i] = { - ...body, - updatedAt: new Date(body.updatedAt), - lastUsedAt: new Date(body.lastUsedAt), - }; - } - } - break; - } - case 'roleDeleted': { - const cached = this.rolesCache.get(); - if (cached) { - this.rolesCache.set(cached.filter(x => x.id !== body.id)); - } - break; - } - case 'userRoleAssigned': { - const cached = this.roleAssignmentByUserIdCache.get(body.userId); - if (cached) { - cached.push({ - ...body, - expiresAt: body.expiresAt ? new Date(body.expiresAt) : null, - }); - } - break; - } - case 'userRoleUnassigned': { - const cached = this.roleAssignmentByUserIdCache.get(body.userId); - if (cached) { - this.roleAssignmentByUserIdCache.set(body.userId, cached.filter(x => x.id !== body.id)); - } - break; - } - default: - break; - } - } - } - - @bindThis - private evalCond(user: MiUser, value: RoleCondFormulaValue): boolean { - try { - switch (value.type) { - case 'and': { - return value.values.every(v => this.evalCond(user, v)); - } - case 'or': { - return value.values.some(v => this.evalCond(user, v)); - } - case 'not': { - return !this.evalCond(user, value.value); - } - case 'isLocal': { - return this.userEntityService.isLocalUser(user); - } - case 'isRemote': { - return this.userEntityService.isRemoteUser(user); - } - case 'createdLessThan': { - return this.idService.parse(user.id).date.getTime() > (Date.now() - (value.sec * 1000)); - } - case 'createdMoreThan': { - return this.idService.parse(user.id).date.getTime() < (Date.now() - (value.sec * 1000)); - } - case 'followersLessThanOrEq': { - return user.followersCount <= value.value; - } - case 'followersMoreThanOrEq': { - return user.followersCount >= value.value; - } - case 'followingLessThanOrEq': { - return user.followingCount <= value.value; - } - case 'followingMoreThanOrEq': { - return user.followingCount >= value.value; - } - case 'notesLessThanOrEq': { - return user.notesCount <= value.value; - } - case 'notesMoreThanOrEq': { - return user.notesCount >= value.value; - } - default: - return false; - } - } catch (err) { - // TODO: log error - return false; - } - } - - @bindThis - public async getRoles() { - const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); - return roles; - } - - @bindThis - public async getUserAssigns(userId: MiUser['id']) { - const now = Date.now(); - let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); - // 期限切れのロールを除外 - assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); - return assigns; - } - - @bindThis - public async getUserRoles(userId: MiUser['id']) { - const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); - const assigns = await this.getUserAssigns(userId); - const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id)); - const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; - const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula)); - return [...assignedRoles, ...matchedCondRoles]; - } - - /** - * 指定ユーザーのバッジロール一覧取得 - */ - @bindThis - public async getUserBadgeRoles(userId: MiUser['id']) { - const now = Date.now(); - let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); - // 期限切れのロールを除外 - assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); - const assignedRoleIds = assigns.map(x => x.roleId); - const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); - const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id)); - const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional')); - if (badgeCondRoles.length > 0) { - const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; - const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, r.condFormula)); - return [...assignedBadgeRoles, ...matchedBadgeCondRoles]; - } else { - return assignedBadgeRoles; - } - } - - @bindThis - public async getUserPolicies(userId: MiUser['id'] | null): Promise { - const meta = await this.metaService.fetch(); - const basePolicies = { ...DEFAULT_POLICIES, ...meta.policies }; - - if (userId == null) return basePolicies; - - const roles = await this.getUserRoles(userId); - - function calc(name: T, aggregate: (values: RolePolicies[T][]) => RolePolicies[T]) { - if (roles.length === 0) return basePolicies[name]; - - const policies = roles.map(role => role.policies[name] ?? { priority: 0, useDefault: true }); - - const p2 = policies.filter(policy => policy.priority === 2); - if (p2.length > 0) return aggregate(p2.map(policy => policy.useDefault ? basePolicies[name] : policy.value)); - - const p1 = policies.filter(policy => policy.priority === 1); - if (p1.length > 0) return aggregate(p1.map(policy => policy.useDefault ? basePolicies[name] : policy.value)); - - return aggregate(policies.map(policy => policy.useDefault ? basePolicies[name] : policy.value)); - } - - return { - gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), - btlAvailable: calc('btlAvailable', vs => vs.some(v => v === true)), - ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), - canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), - canInvite: calc('canInvite', vs => vs.some(v => v === true)), - inviteLimit: calc('inviteLimit', vs => Math.max(...vs)), - inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)), - inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)), - canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)), - canManageAvatarDecorations: calc('canManageAvatarDecorations', vs => vs.some(v => v === true)), - canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), - canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)), - canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), - driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), - alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), - pinLimit: calc('pinLimit', vs => Math.max(...vs)), - antennaLimit: calc('antennaLimit', vs => Math.max(...vs)), - wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)), - webhookLimit: calc('webhookLimit', vs => Math.max(...vs)), - clipLimit: calc('clipLimit', vs => Math.max(...vs)), - noteEachClipsLimit: calc('noteEachClipsLimit', vs => Math.max(...vs)), - userListLimit: calc('userListLimit', vs => Math.max(...vs)), - userEachUserListsLimit: calc('userEachUserListsLimit', vs => Math.max(...vs)), - rateLimitFactor: calc('rateLimitFactor', vs => Math.max(...vs)), - canImportNotes: calc('canImportNotes', vs => vs.some(v => v === true)), - avatarDecorationLimit: calc('avatarDecorationLimit', vs => Math.max(...vs)), - }; - } - - @bindThis - public async isModerator(user: { id: MiUser['id']; isRoot: MiUser['isRoot'] } | null): Promise { - if (user == null) return false; - return user.isRoot || (await this.getUserRoles(user.id)).some(r => r.isModerator || r.isAdministrator); - } - - @bindThis - public async isAdministrator(user: { id: MiUser['id']; isRoot: MiUser['isRoot'] } | null): Promise { - if (user == null) return false; - return user.isRoot || (await this.getUserRoles(user.id)).some(r => r.isAdministrator); - } - - @bindThis - public async isExplorable(role: { id: MiRole['id']} | null): Promise { - if (role == null) return false; - const check = await this.rolesRepository.findOneBy({ id: role.id }); - if (check == null) return false; - return check.isExplorable; - } - - @bindThis - public async getModeratorIds(includeAdmins = true): Promise { - const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); - const moderatorRoles = includeAdmins ? roles.filter(r => r.isModerator || r.isAdministrator) : roles.filter(r => r.isModerator); - const assigns = moderatorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ - roleId: In(moderatorRoles.map(r => r.id)), - }) : []; - // TODO: isRootなアカウントも含める - return assigns.map(a => a.userId); - } - - @bindThis - public async getModerators(includeAdmins = true): Promise { - const ids = await this.getModeratorIds(includeAdmins); - const users = ids.length > 0 ? await this.usersRepository.findBy({ - id: In(ids), - }) : []; - return users; - } - - @bindThis - public async getAdministratorIds(): Promise { - const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); - const administratorRoles = roles.filter(r => r.isAdministrator); - const assigns = administratorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ - roleId: In(administratorRoles.map(r => r.id)), - }) : []; - // TODO: isRootなアカウントも含める - return assigns.map(a => a.userId); - } - - @bindThis - public async getAdministrators(): Promise { - const ids = await this.getAdministratorIds(); - const users = ids.length > 0 ? await this.usersRepository.findBy({ - id: In(ids), - }) : []; - return users; - } - - @bindThis - public async assign(userId: MiUser['id'], roleId: MiRole['id'], expiresAt: Date | null = null, moderator?: MiUser): Promise { - const now = Date.now(); - - const role = await this.rolesRepository.findOneByOrFail({ id: roleId }); - - const existing = await this.roleAssignmentsRepository.findOneBy({ - roleId: roleId, - userId: userId, - }); - - if (existing) { - if (existing.expiresAt && (existing.expiresAt.getTime() < now)) { - await this.roleAssignmentsRepository.delete({ - roleId: roleId, - userId: userId, - }); - } else { - throw new RoleService.AlreadyAssignedError(); - } - } - - const created = await this.roleAssignmentsRepository.insert({ - id: this.idService.gen(now), - expiresAt: expiresAt, - roleId: roleId, - userId: userId, - }).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0])); - - this.rolesRepository.update(roleId, { - lastUsedAt: new Date(), - }); - - this.globalEventService.publishInternalEvent('userRoleAssigned', created); - - if (role.isPublic) { - this.notificationService.createNotification(userId, 'roleAssigned', { - roleId: roleId, - }); - } - - if (moderator) { - const user = await this.usersRepository.findOneByOrFail({ id: userId }); - this.moderationLogService.log(moderator, 'assignRole', { - roleId: roleId, - roleName: role.name, - userId: userId, - userUsername: user.username, - userHost: user.host, - expiresAt: expiresAt ? expiresAt.toISOString() : null, - }); - } - } - - @bindThis - public async unassign(userId: MiUser['id'], roleId: MiRole['id'], moderator?: MiUser): Promise { - const now = new Date(); - - const existing = await this.roleAssignmentsRepository.findOneBy({ roleId, userId }); - if (existing == null) { - throw new RoleService.NotAssignedError(); - } else if (existing.expiresAt && (existing.expiresAt.getTime() < now.getTime())) { - await this.roleAssignmentsRepository.delete({ - roleId: roleId, - userId: userId, - }); - throw new RoleService.NotAssignedError(); - } - - await this.roleAssignmentsRepository.delete(existing.id); - - this.rolesRepository.update(roleId, { - lastUsedAt: now, - }); - - this.globalEventService.publishInternalEvent('userRoleUnassigned', existing); - - if (moderator) { - const [user, role] = await Promise.all([ - this.usersRepository.findOneByOrFail({ id: userId }), - this.rolesRepository.findOneByOrFail({ id: roleId }), - ]); - this.moderationLogService.log(moderator, 'unassignRole', { - roleId: roleId, - roleName: role.name, - userId: userId, - userUsername: user.username, - userHost: user.host, - }); - } - } - - @bindThis - public async addNoteToRoleTimeline(note: Packed<'Note'>): Promise { - const roles = await this.getUserRoles(note.userId); - - const redisPipeline = this.redisForTimelines.pipeline(); - - for (const role of roles) { - this.fanoutTimelineService.push(`roleTimeline:${role.id}`, note.id, 1000, redisPipeline); - this.globalEventService.publishRoleTimelineStream(role.id, 'note', note); - } - - redisPipeline.exec(); - } - - @bindThis - public async create(values: Partial, moderator?: MiUser): Promise { - const date = new Date(); - const created = await this.rolesRepository.insert({ - id: this.idService.gen(date.getTime()), - updatedAt: date, - lastUsedAt: date, - name: values.name, - description: values.description, - color: values.color, - iconUrl: values.iconUrl, - target: values.target, - condFormula: values.condFormula, - isPublic: values.isPublic, - isAdministrator: values.isAdministrator, - isModerator: values.isModerator, - isExplorable: values.isExplorable, - asBadge: values.asBadge, - canEditMembersByModerator: values.canEditMembersByModerator, - displayOrder: values.displayOrder, - policies: values.policies, - }).then(x => this.rolesRepository.findOneByOrFail(x.identifiers[0])); - - this.globalEventService.publishInternalEvent('roleCreated', created); - - if (moderator) { - this.moderationLogService.log(moderator, 'createRole', { - roleId: created.id, - role: created, - }); - } - - return created; - } - - @bindThis - public async update(role: MiRole, params: Partial, moderator?: MiUser): Promise { - const date = new Date(); - await this.rolesRepository.update(role.id, { - updatedAt: date, - ...params, - }); - - const updated = await this.rolesRepository.findOneByOrFail({ id: role.id }); - this.globalEventService.publishInternalEvent('roleUpdated', updated); - - if (moderator) { - this.moderationLogService.log(moderator, 'updateRole', { - roleId: role.id, - before: role, - after: updated, - }); - } - } - - @bindThis - public async delete(role: MiRole, moderator?: MiUser): Promise { - await this.rolesRepository.delete({ id: role.id }); - this.globalEventService.publishInternalEvent('roleDeleted', role); - - if (moderator) { - this.moderationLogService.log(moderator, 'deleteRole', { - roleId: role.id, - role: role, - }); - } - } - - @bindThis - public dispose(): void { - this.redisForSub.off('message', this.onMessage); - this.roleAssignmentByUserIdCache.dispose(); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/core/S3Service.ts b/packages/backend/src/core/S3Service.ts deleted file mode 100644 index df0991539d..0000000000 --- a/packages/backend/src/core/S3Service.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { URL } from 'node:url'; -import * as http from 'node:http'; -import * as https from 'node:https'; -import { Injectable } from '@nestjs/common'; -import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3'; -import { Upload } from '@aws-sdk/lib-storage'; -import { NodeHttpHandler, NodeHttpHandlerOptions } from '@smithy/node-http-handler'; -import type { MiMeta } from '@/models/Meta.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { bindThis } from '@/decorators.js'; -import type { DeleteObjectCommandInput, PutObjectCommandInput } from '@aws-sdk/client-s3'; - -@Injectable() -export class S3Service { - constructor( - private httpRequestService: HttpRequestService, - ) { - } - - @bindThis - public getS3Client(meta: MiMeta): S3Client { - const u = meta.objectStorageEndpoint - ? `${meta.objectStorageUseSSL ? 'https' : 'http'}://${meta.objectStorageEndpoint}` - : `${meta.objectStorageUseSSL ? 'https' : 'http'}://example.net`; // dummy url to select http(s) agent - - const agent = this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy); - const handlerOption: NodeHttpHandlerOptions = {}; - if (meta.objectStorageUseSSL) { - handlerOption.httpsAgent = agent as https.Agent; - } else { - handlerOption.httpAgent = agent as http.Agent; - } - - return new S3Client({ - endpoint: meta.objectStorageEndpoint ? u : undefined, - credentials: (meta.objectStorageAccessKey !== null && meta.objectStorageSecretKey !== null) ? { - accessKeyId: meta.objectStorageAccessKey, - secretAccessKey: meta.objectStorageSecretKey, - } : undefined, - region: meta.objectStorageRegion ? meta.objectStorageRegion : undefined, // 空文字列もundefinedにするため ?? は使わない - tls: meta.objectStorageUseSSL, - forcePathStyle: meta.objectStorageEndpoint ? meta.objectStorageS3ForcePathStyle : false, // AWS with endPoint omitted - requestHandler: new NodeHttpHandler(handlerOption), - }); - } - - @bindThis - public async upload(meta: MiMeta, input: PutObjectCommandInput) { - const client = this.getS3Client(meta); - return new Upload({ - client, - params: input, - partSize: (client.config.endpoint && (await client.config.endpoint()).hostname === 'storage.googleapis.com') - ? 500 * 1024 * 1024 - : 8 * 1024 * 1024, - }).done(); - } - - @bindThis - public delete(meta: MiMeta, input: DeleteObjectCommandInput) { - const client = this.getS3Client(meta); - return client.send(new DeleteObjectCommand(input)); - } -} diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts deleted file mode 100644 index 57c7e4baba..0000000000 --- a/packages/backend/src/core/SearchService.ts +++ /dev/null @@ -1,298 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { bindThis } from '@/decorators.js'; -import { MiNote } from '@/models/Note.js'; -import { MiUser } from '@/models/_.js'; -import type { NotesRepository } from '@/models/_.js'; -import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; -import { CacheService } from '@/core/CacheService.js'; -import { QueryService } from '@/core/QueryService.js'; -import { IdService } from '@/core/IdService.js'; -import type { Index, MeiliSearch } from 'meilisearch'; - -type K = string; -type V = string | number | boolean; -type Q = - { op: '=', k: K, v: V } | - { op: '!=', k: K, v: V } | - { op: '>', k: K, v: number } | - { op: '<', k: K, v: number } | - { op: '>=', k: K, v: number } | - { op: '<=', k: K, v: number } | - { op: 'is null', k: K} | - { op: 'is not null', k: K} | - { op: 'and', qs: Q[] } | - { op: 'or', qs: Q[] } | - { op: 'not', q: Q }; - -function compileValue(value: V): string { - if (typeof value === 'string') { - return `'${value}'`; // TODO: escape - } else if (typeof value === 'number') { - return value.toString(); - } else if (typeof value === 'boolean') { - return value.toString(); - } - throw new Error('unrecognized value'); -} - -function compileQuery(q: Q): string { - switch (q.op) { - case '=': return `(${q.k} = ${compileValue(q.v)})`; - case '!=': return `(${q.k} != ${compileValue(q.v)})`; - case '>': return `(${q.k} > ${compileValue(q.v)})`; - case '<': return `(${q.k} < ${compileValue(q.v)})`; - case '>=': return `(${q.k} >= ${compileValue(q.v)})`; - case '<=': return `(${q.k} <= ${compileValue(q.v)})`; - case 'and': return q.qs.length === 0 ? '' : `(${ q.qs.map(_q => compileQuery(_q)).join(' AND ') })`; - case 'or': return q.qs.length === 0 ? '' : `(${ q.qs.map(_q => compileQuery(_q)).join(' OR ') })`; - case 'is null': return `(${q.k} IS NULL)`; - case 'is not null': return `(${q.k} IS NOT NULL)`; - case 'not': return `(NOT ${compileQuery(q.q)})`; - default: throw new Error('unrecognized query operator'); - } -} - -@Injectable() -export class SearchService { - private readonly meilisearchIndexScope: 'local' | 'global' | string[] = 'local'; - private meilisearchNoteIndex: Index | null = null; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.meilisearch) - private meilisearch: MeiliSearch | null, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - private cacheService: CacheService, - private queryService: QueryService, - private idService: IdService, - ) { - if (meilisearch) { - this.meilisearchNoteIndex = meilisearch.index(`${this.config.meilisearch?.index}---notes`); - this.meilisearchNoteIndex.updateSettings({ - searchableAttributes: [ - 'text', - 'cw', - ], - sortableAttributes: [ - 'createdAt', - ], - filterableAttributes: [ - 'createdAt', - 'userId', - 'userHost', - 'channelId', - 'tags', - 'attachedFileTypes', - ], - typoTolerance: { - enabled: false, - }, - pagination: { - maxTotalHits: 10000, - }, - }); - } - - if (this.config.meilisearch?.scope) { - this.meilisearchIndexScope = this.config.meilisearch.scope; - } - } - - @bindThis - public async indexNote(note: MiNote): Promise { - if (note.text == null && note.cw == null) return; - if (!['home', 'public'].includes(note.visibility)) return; - - if (this.meilisearch) { - switch (this.meilisearchIndexScope) { - case 'global': - break; - - case 'local': - if (note.userHost == null) break; - return; - - default: { - if (note.userHost == null) break; - if (this.meilisearchIndexScope.includes(note.userHost)) break; - return; - } - } - - await this.meilisearchNoteIndex?.addDocuments([{ - id: note.id, - createdAt: this.idService.parse(note.id).date.getTime(), - userId: note.userId, - userHost: note.userHost, - channelId: note.channelId, - cw: note.cw, - text: note.text, - tags: note.tags, - attachedFileTypes: note.attachedFileTypes, - }], { - primaryKey: 'id', - }); - } - } - - @bindThis - public async unindexNote(note: MiNote): Promise { - if (!['home', 'public'].includes(note.visibility)) return; - - if (this.meilisearch) { - this.meilisearchNoteIndex!.deleteDocument(note.id); - } - } - - @bindThis - public async searchNote(q: string, me: MiUser | null, opts: { - userId?: MiNote['userId'] | null; - channelId?: MiNote['channelId'] | null; - host?: string | null; - filetype?: string | null; - order?: string | null; - disableMeili?: boolean | null; - }, pagination: { - untilId?: MiNote['id']; - sinceId?: MiNote['id']; - limit?: number; - }): Promise { - if (this.meilisearch && !opts.disableMeili) { - const filter: Q = { - op: 'and', - qs: [], - }; - if (pagination.untilId) filter.qs.push({ op: '<', k: 'createdAt', v: this.idService.parse(pagination.untilId).date.getTime() }); - if (pagination.sinceId) filter.qs.push({ op: '>', k: 'createdAt', v: this.idService.parse(pagination.sinceId).date.getTime() }); - if (opts.userId) filter.qs.push({ op: '=', k: 'userId', v: opts.userId }); - if (opts.channelId) filter.qs.push({ op: '=', k: 'channelId', v: opts.channelId }); - if (opts.host) { - if (opts.host === '.') { - filter.qs.push({ op: 'is null', k: 'userHost' }); - } else { - filter.qs.push({ op: '=', k: 'userHost', v: opts.host }); - } - } - if (opts.filetype) { - if (opts.filetype === 'image') { - filter.qs.push({ op: 'or', qs: [ - { op: '=', k: 'attachedFileTypes', v: 'image/webp' }, - { op: '=', k: 'attachedFileTypes', v: 'image/png' }, - { op: '=', k: 'attachedFileTypes', v: 'image/jpeg' }, - { op: '=', k: 'attachedFileTypes', v: 'image/avif' }, - { op: '=', k: 'attachedFileTypes', v: 'image/apng' }, - { op: '=', k: 'attachedFileTypes', v: 'image/gif' }, - ] }); - } else if (opts.filetype === 'video') { - filter.qs.push({ op: 'or', qs: [ - { op: '=', k: 'attachedFileTypes', v: 'video/mp4' }, - { op: '=', k: 'attachedFileTypes', v: 'video/webm' }, - { op: '=', k: 'attachedFileTypes', v: 'video/mpeg' }, - { op: '=', k: 'attachedFileTypes', v: 'video/x-m4v' }, - ] }); - } else if (opts.filetype === 'audio') { - filter.qs.push({ op: 'or', qs: [ - { op: '=', k: 'attachedFileTypes', v: 'audio/mpeg' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/flac' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/wav' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/aac' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/webm' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/opus' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/ogg' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/x-m4a' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/mod' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/s3m' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/xm' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/it' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/x-mod' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/x-s3m' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/x-xm' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/x-it' }, - ] }); - } - } - const res = await this.meilisearchNoteIndex!.search(q, { - sort: [`createdAt:${opts.order ? opts.order : 'desc'}`], - matchingStrategy: 'all', - attributesToRetrieve: ['id', 'createdAt'], - filter: compileQuery(filter), - limit: pagination.limit, - }); - if (res.hits.length === 0) return []; - const [ - userIdsWhoMeMuting, - userIdsWhoBlockingMe, - ] = me ? await Promise.all([ - this.cacheService.userMutingsCache.fetch(me.id), - this.cacheService.userBlockedCache.fetch(me.id), - ]) : [new Set(), new Set()]; - const notes = (await this.notesRepository.findBy({ - id: In(res.hits.map(x => x.id)), - })).filter(note => { - if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; - return true; - }); - return notes.sort((a, b) => a.id > b.id ? -1 : 1); - } else { - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId); - - if (opts.userId) { - query.andWhere('note.userId = :userId', { userId: opts.userId }); - } else if (opts.channelId) { - query.andWhere('note.channelId = :channelId', { channelId: opts.channelId }); - } - - query - .andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - if (opts.host) { - if (opts.host === '.') { - query.andWhere('user.host IS NULL'); - } else { - query.andWhere('user.host = :host', { host: opts.host }); - } - } - - if (opts.filetype) { - /* this is very ugly, but the "correct" solution would - be `and exists (select 1 from - unnest(note."attachedFileTypes") x(t) where t like - :type)` and I can't find a way to get TypeORM to - generate that; this hack works because `~*` is - "regexp match, ignoring case" and the stringified - version of an array of varchars (which is what - `attachedFileTypes` is) looks like `{foo,bar}`, so - we're looking for opts.filetype as the first half of - a MIME type, either at start of the array (after the - `{`) or later (after a `,`) */ - query.andWhere(`note."attachedFileTypes"::varchar ~* :type`, { type: `[{,]${opts.filetype}/` }); - } - - this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); - - return await query.limit(pagination.limit).getMany(); - } - } -} diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts deleted file mode 100644 index 32e3dee937..0000000000 --- a/packages/backend/src/core/SignupService.ts +++ /dev/null @@ -1,161 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { generateKeyPair } from 'node:crypto'; -import { Inject, Injectable } from '@nestjs/common'; -//import bcrypt from 'bcryptjs'; -import * as argon2 from 'argon2'; -import { DataSource, IsNull } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { UsedUsernamesRepository, UsersRepository } from '@/models/_.js'; -import { MiUser } from '@/models/User.js'; -import { MiUserProfile } from '@/models/UserProfile.js'; -import { IdService } from '@/core/IdService.js'; -import { MiUserKeypair } from '@/models/UserKeypair.js'; -import { MiUsedUsername } from '@/models/UsedUsername.js'; -import generateUserToken from '@/misc/generate-native-user-token.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { bindThis } from '@/decorators.js'; -import UsersChart from '@/core/chart/charts/users.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { MetaService } from '@/core/MetaService.js'; - -@Injectable() -export class SignupService { - constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.usedUsernamesRepository) - private usedUsernamesRepository: UsedUsernamesRepository, - - private utilityService: UtilityService, - private userEntityService: UserEntityService, - private idService: IdService, - private metaService: MetaService, - private usersChart: UsersChart, - ) { - } - - @bindThis - public async signup(opts: { - username: MiUser['username']; - password?: string | null; - passwordHash?: MiUserProfile['password'] | null; - host?: string | null; - reason?: string | null; - ignorePreservedUsernames?: boolean; - }) { - const { username, password, passwordHash, host, reason } = opts; - let hash = passwordHash; - const instance = await this.metaService.fetch(true); - - // Validate username - if (!this.userEntityService.validateLocalUsername(username)) { - throw new Error('INVALID_USERNAME'); - } - - if (password != null && passwordHash == null) { - // Validate password - if (!this.userEntityService.validatePassword(password)) { - throw new Error('INVALID_PASSWORD'); - } - - // Generate hash of password - //const salt = await bcrypt.genSalt(8); - hash = await argon2.hash(password); - } - - // Generate secret - const secret = generateUserToken(); - - // Check username duplication - if (await this.usersRepository.exist({ where: { usernameLower: username.toLowerCase(), host: IsNull() } })) { - throw new Error('DUPLICATED_USERNAME'); - } - - // Check deleted username duplication - if (await this.usedUsernamesRepository.exist({ where: { username: username.toLowerCase() } })) { - throw new Error('USED_USERNAME'); - } - - const isTheFirstUser = (await this.usersRepository.countBy({ host: IsNull() })) === 0; - - if (!opts.ignorePreservedUsernames && !isTheFirstUser) { - const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); - if (isPreserved) { - throw new Error('USED_USERNAME'); - } - } - - const keyPair = await new Promise((res, rej) => - generateKeyPair('rsa', { - modulusLength: 2048, - publicKeyEncoding: { - type: 'spki', - format: 'pem', - }, - privateKeyEncoding: { - type: 'pkcs8', - format: 'pem', - cipher: undefined, - passphrase: undefined, - }, - }, (err, publicKey, privateKey) => - err ? rej(err) : res([publicKey, privateKey]), - )); - - let account!: MiUser; - let defaultApproval = false; - - if (!instance.approvalRequiredForSignup) defaultApproval = true; - - // Start transaction - await this.db.transaction(async transactionalEntityManager => { - const exist = await transactionalEntityManager.findOneBy(MiUser, { - usernameLower: username.toLowerCase(), - host: IsNull(), - }); - - if (exist) throw new Error(' the username is already used'); - - account = await transactionalEntityManager.save(new MiUser({ - id: this.idService.gen(), - username: username, - usernameLower: username.toLowerCase(), - host: this.utilityService.toPunyNullable(host), - token: secret, - isRoot: isTheFirstUser, - approved: defaultApproval, - signupReason: reason, - })); - - await transactionalEntityManager.save(new MiUserKeypair({ - publicKey: keyPair[0], - privateKey: keyPair[1], - userId: account.id, - })); - - await transactionalEntityManager.save(new MiUserProfile({ - userId: account.id, - autoAcceptFollowed: true, - password: hash, - })); - - await transactionalEntityManager.save(new MiUsedUsername({ - createdAt: new Date(), - username: username.toLowerCase(), - })); - }); - - this.usersChart.update(account, true); - - return { account, secret }; - } -} - diff --git a/packages/backend/src/core/UserAuthService.ts b/packages/backend/src/core/UserAuthService.ts deleted file mode 100644 index ccf4dfc6bd..0000000000 --- a/packages/backend/src/core/UserAuthService.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { QueryFailedError } from 'typeorm'; -import * as OTPAuth from 'otpauth'; -import { DI } from '@/di-symbols.js'; -import type { MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js'; -import { bindThis } from '@/decorators.js'; -import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import type { MiLocalUser } from '@/models/User.js'; - -@Injectable() -export class UserAuthService { - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - ) { - } - - @bindThis - public async twoFactorAuthenticate(profile: MiUserProfile, token: string): Promise { - if (profile.twoFactorBackupSecret?.includes(token)) { - await this.userProfilesRepository.update({ userId: profile.userId }, { - twoFactorBackupSecret: profile.twoFactorBackupSecret.filter((secret) => secret !== token), - }); - } else { - const delta = OTPAuth.TOTP.validate({ - secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret!), - digits: 6, - token, - window: 5, - }); - - if (delta === null) { - throw new Error('authentication failed'); - } - } - } -} diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts deleted file mode 100644 index 39b19325c3..0000000000 --- a/packages/backend/src/core/UserBlockingService.ts +++ /dev/null @@ -1,196 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; -import { IdService } from '@/core/IdService.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiBlocking } from '@/models/Blocking.js'; -import { QueueService } from '@/core/QueueService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; -import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListMembershipsRepository } from '@/models/_.js'; -import Logger from '@/logger.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { LoggerService } from '@/core/LoggerService.js'; -import { WebhookService } from '@/core/WebhookService.js'; -import { bindThis } from '@/decorators.js'; -import { CacheService } from '@/core/CacheService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; - -@Injectable() -export class UserBlockingService implements OnModuleInit { - private logger: Logger; - private userFollowingService: UserFollowingService; - - constructor( - private moduleRef: ModuleRef, - - @Inject(DI.followRequestsRepository) - private followRequestsRepository: FollowRequestsRepository, - - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - private cacheService: CacheService, - private userEntityService: UserEntityService, - private idService: IdService, - private queueService: QueueService, - private globalEventService: GlobalEventService, - private webhookService: WebhookService, - private apRendererService: ApRendererService, - private loggerService: LoggerService, - ) { - this.logger = this.loggerService.getLogger('user-block'); - } - - onModuleInit() { - this.userFollowingService = this.moduleRef.get('UserFollowingService'); - } - - @bindThis - public async block(blocker: MiUser, blockee: MiUser, silent = false) { - await Promise.all([ - this.cancelRequest(blocker, blockee, silent), - this.cancelRequest(blockee, blocker, silent), - this.userFollowingService.unfollow(blocker, blockee, silent), - this.userFollowingService.unfollow(blockee, blocker, silent), - this.removeFromList(blockee, blocker), - ]); - - const blocking = { - id: this.idService.gen(), - blocker, - blockerId: blocker.id, - blockee, - blockeeId: blockee.id, - } as MiBlocking; - - await this.blockingsRepository.insert(blocking); - - this.cacheService.userBlockingCache.refresh(blocker.id); - this.cacheService.userBlockedCache.refresh(blockee.id); - - this.globalEventService.publishInternalEvent('blockingCreated', { - blockerId: blocker.id, - blockeeId: blockee.id, - }); - - if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderBlock(blocking)); - this.queueService.deliver(blocker, content, blockee.inbox, false); - } - } - - @bindThis - private async cancelRequest(follower: MiUser, followee: MiUser, silent = false) { - const request = await this.followRequestsRepository.findOneBy({ - followeeId: followee.id, - followerId: follower.id, - }); - - if (request == null) { - return; - } - - await this.followRequestsRepository.delete({ - followeeId: followee.id, - followerId: follower.id, - }); - - if (this.userEntityService.isLocalUser(followee)) { - this.userEntityService.pack(followee, followee, { - detail: true, - }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); - } - - if (this.userEntityService.isLocalUser(follower) && !silent) { - this.userEntityService.pack(followee, follower, { - detail: true, - }).then(async packed => { - this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); - for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'unfollow', { - user: packed, - }); - } - }); - } - - // リモートにフォローリクエストをしていたらUndoFollow送信 - if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); - this.queueService.deliver(follower, content, followee.inbox, false); - } - - // リモートからフォローリクエストを受けていたらReject送信 - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee)); - this.queueService.deliver(followee, content, follower.inbox, false); - } - } - - @bindThis - private async removeFromList(listOwner: MiUser, user: MiUser) { - const userLists = await this.userListsRepository.findBy({ - userId: listOwner.id, - }); - - for (const userList of userLists) { - await this.userListMembershipsRepository.delete({ - userListId: userList.id, - userId: user.id, - }); - } - } - - @bindThis - public async unblock(blocker: MiUser, blockee: MiUser) { - const blocking = await this.blockingsRepository.findOneBy({ - blockerId: blocker.id, - blockeeId: blockee.id, - }); - - if (blocking == null) { - this.logger.warn('ブロック解除がリクエストされましたがブロックしていませんでした'); - return; - } - - // Since we already have the blocker and blockee, we do not need to fetch - // them in the query above and can just manually insert them here. - blocking.blocker = blocker; - blocking.blockee = blockee; - - await this.blockingsRepository.delete(blocking.id); - - this.cacheService.userBlockingCache.refresh(blocker.id); - this.cacheService.userBlockedCache.refresh(blockee.id); - - this.globalEventService.publishInternalEvent('blockingDeleted', { - blockerId: blocker.id, - blockeeId: blockee.id, - }); - - // deliver if remote bloking - if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderBlock(blocking), blocker)); - this.queueService.deliver(blocker, content, blockee.inbox, false); - } - } - - @bindThis - public async checkBlocked(blockerId: MiUser['id'], blockeeId: MiUser['id']): Promise { - return (await this.cacheService.userBlockingCache.fetch(blockerId)).has(blockeeId); - } -} diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts deleted file mode 100644 index d600ffb9d9..0000000000 --- a/packages/backend/src/core/UserFollowingService.ts +++ /dev/null @@ -1,719 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; -import { IsNull } from 'typeorm'; -import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { QueueService } from '@/core/QueueService.js'; -import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { IdService } from '@/core/IdService.js'; -import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import type { Packed } from '@/misc/json-schema.js'; -import InstanceChart from '@/core/chart/charts/instance.js'; -import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import { WebhookService } from '@/core/WebhookService.js'; -import { NotificationService } from '@/core/NotificationService.js'; -import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { bindThis } from '@/decorators.js'; -import { UserBlockingService } from '@/core/UserBlockingService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { CacheService } from '@/core/CacheService.js'; -import type { Config } from '@/config.js'; -import { AccountMoveService } from '@/core/AccountMoveService.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; -import Logger from '../logger.js'; - -const logger = new Logger('following/create'); - -type Local = MiLocalUser | { - id: MiLocalUser['id']; - host: MiLocalUser['host']; - uri: MiLocalUser['uri'] -}; -type Remote = MiRemoteUser | { - id: MiRemoteUser['id']; - host: MiRemoteUser['host']; - uri: MiRemoteUser['uri']; - inbox: MiRemoteUser['inbox']; -}; -type Both = Local | Remote; - -@Injectable() -export class UserFollowingService implements OnModuleInit { - private userBlockingService: UserBlockingService; - - constructor( - private moduleRef: ModuleRef, - - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - @Inject(DI.followRequestsRepository) - private followRequestsRepository: FollowRequestsRepository, - - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, - - private cacheService: CacheService, - private utilityService: UtilityService, - private userEntityService: UserEntityService, - private idService: IdService, - private queueService: QueueService, - private globalEventService: GlobalEventService, - private metaService: MetaService, - private notificationService: NotificationService, - private federatedInstanceService: FederatedInstanceService, - private webhookService: WebhookService, - private apRendererService: ApRendererService, - private accountMoveService: AccountMoveService, - private fanoutTimelineService: FanoutTimelineService, - private perUserFollowingChart: PerUserFollowingChart, - private instanceChart: InstanceChart, - ) { - } - - onModuleInit() { - this.userBlockingService = this.moduleRef.get('UserBlockingService'); - } - - @bindThis - public async follow( - _follower: { id: MiUser['id'] }, - _followee: { id: MiUser['id'] }, - { requestId, silent = false, withReplies }: { - requestId?: string, - silent?: boolean, - withReplies?: boolean, - } = {}, - ): Promise { - const [follower, followee] = await Promise.all([ - this.usersRepository.findOneByOrFail({ id: _follower.id }), - this.usersRepository.findOneByOrFail({ id: _followee.id }), - ]) as [MiLocalUser | MiRemoteUser, MiLocalUser | MiRemoteUser]; - - // check blocking - const [blocking, blocked] = await Promise.all([ - this.userBlockingService.checkBlocked(follower.id, followee.id), - this.userBlockingService.checkBlocked(followee.id, follower.id), - ]); - - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocked) { - // リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。 - const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, requestId), followee)); - this.queueService.deliver(followee, content, follower.inbox, false); - return; - } else if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocking) { - // リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。 - await this.userBlockingService.unblock(follower, followee); - } else { - // それ以外は単純に例外 - if (blocking) throw new IdentifiableError('710e8fb0-b8c3-4922-be49-d5d93d8e6a6e', 'blocking'); - if (blocked) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked'); - } - - const followeeProfile = await this.userProfilesRepository.findOneByOrFail({ userId: followee.id }); - // フォロー対象が鍵アカウントである or - // フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or - // フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである or - // フォロワーがローカルユーザーであり、フォロー対象がサイレンスされているサーバーである - // 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく - if ( - followee.isLocked || - (followeeProfile.carefulBot && follower.isBot) || - (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee) && process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING !== 'true') || - (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower) && this.utilityService.isSilencedHost((await this.metaService.fetch()).silencedHosts, follower.host)) - ) { - let autoAccept = false; - - // 鍵アカウントであっても、既にフォローされていた場合はスルー - const isFollowing = await this.followingsRepository.exist({ - where: { - followerId: follower.id, - followeeId: followee.id, - }, - }); - if (isFollowing) { - autoAccept = true; - } - - // フォローしているユーザーは自動承認オプション - if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) { - const isFollowed = await this.followingsRepository.exist({ - where: { - followerId: followee.id, - followeeId: follower.id, - }, - }); - - if (isFollowed) autoAccept = true; - } - - // Automatically accept if the follower is an account who has moved and the locked followee had accepted the old account. - if (followee.isLocked && !autoAccept) { - autoAccept = !!(await this.accountMoveService.validateAlsoKnownAs( - follower, - (oldSrc, newSrc) => this.followingsRepository.exist({ - where: { - followeeId: followee.id, - followerId: newSrc.id, - }, - }), - true, - )); - } - - if (!autoAccept) { - await this.createFollowRequest(follower, followee, requestId, withReplies); - return; - } - } - - await this.insertFollowingDoc(followee, follower, silent, withReplies); - - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee)); - this.queueService.deliver(followee, content, follower.inbox, false); - } - } - - @bindThis - private async insertFollowingDoc( - followee: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox'] - }, - follower: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox'] - }, - silent = false, - withReplies?: boolean, - ): Promise { - if (follower.id === followee.id) return; - - let alreadyFollowed = false as boolean; - - await this.followingsRepository.insert({ - id: this.idService.gen(), - followerId: follower.id, - followeeId: followee.id, - withReplies: withReplies, - - // 非正規化 - followerHost: follower.host, - followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : null, - followerSharedInbox: this.userEntityService.isRemoteUser(follower) ? follower.sharedInbox : null, - followeeHost: followee.host, - followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : null, - followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : null, - }).catch(err => { - if (isDuplicateKeyValueError(err) && this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`); - alreadyFollowed = true; - } else { - throw err; - } - }); - - this.cacheService.userFollowingsCache.refresh(follower.id); - - const requestExist = await this.followRequestsRepository.exist({ - where: { - followeeId: followee.id, - followerId: follower.id, - }, - }); - - if (requestExist) { - await this.followRequestsRepository.delete({ - followeeId: followee.id, - followerId: follower.id, - }); - - // 通知を作成 - this.notificationService.createNotification(follower.id, 'followRequestAccepted', { - }, followee.id); - } - - if (alreadyFollowed) return; - - this.globalEventService.publishInternalEvent('follow', { followerId: follower.id, followeeId: followee.id }); - - const [followeeUser, followerUser] = await Promise.all([ - this.usersRepository.findOneByOrFail({ id: followee.id }), - this.usersRepository.findOneByOrFail({ id: follower.id }), - ]); - - // Neither followee nor follower has moved. - if (!followeeUser.movedToUri && !followerUser.movedToUri) { - //#region Increment counts - await Promise.all([ - this.usersRepository.increment({ id: follower.id }, 'followingCount', 1), - this.usersRepository.increment({ id: followee.id }, 'followersCount', 1), - ]); - //#endregion - - //#region Update instance stats - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - this.federatedInstanceService.fetch(follower.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowing(i.host, true); - } - }); - } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - this.federatedInstanceService.fetch(followee.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowers(i.host, true); - } - }); - } - //#endregion - - this.perUserFollowingChart.update(follower, followee, true); - } - - if (this.userEntityService.isLocalUser(follower) && !silent) { - // Publish follow event - this.userEntityService.pack(followee.id, follower, { - detail: true, - }).then(async packed => { - this.globalEventService.publishMainStream(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow')); - for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'follow', { - user: packed, - }); - } - }); - } - - // Publish followed event - if (this.userEntityService.isLocalUser(followee)) { - this.userEntityService.pack(follower.id, followee).then(async packed => { - this.globalEventService.publishMainStream(followee.id, 'followed', packed); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed')); - for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'followed', { - user: packed, - }); - } - }); - - // 通知を作成 - this.notificationService.createNotification(followee.id, 'follow', { - }, follower.id); - } - } - - @bindThis - public async unfollow( - follower: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; - }, - followee: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; - }, - silent = false, - ): Promise { - const following = await this.followingsRepository.findOne({ - relations: { - follower: true, - followee: true, - }, - where: { - followerId: follower.id, - followeeId: followee.id, - }, - }); - - if (following === null || !following.follower || !following.followee) { - logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); - return; - } - - await this.followingsRepository.delete(following.id); - - this.cacheService.userFollowingsCache.refresh(follower.id); - - this.decrementFollowing(following.follower, following.followee); - - if (!silent && this.userEntityService.isLocalUser(follower)) { - // Publish unfollow event - this.userEntityService.pack(followee.id, follower, { - detail: true, - }).then(async packed => { - this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); - for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'unfollow', { - user: packed, - }); - } - }); - } - - if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower as MiPartialLocalUser, followee as MiPartialRemoteUser), follower)); - this.queueService.deliver(follower, content, followee.inbox, false); - } - - if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) { - // local user has null host - const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower as MiPartialRemoteUser, followee as MiPartialLocalUser), followee)); - this.queueService.deliver(followee, content, follower.inbox, false); - } - } - - @bindThis - private async decrementFollowing( - follower: MiUser, - followee: MiUser, - ): Promise { - this.globalEventService.publishInternalEvent('unfollow', { followerId: follower.id, followeeId: followee.id }); - - // Neither followee nor follower has moved. - if (!follower.movedToUri && !followee.movedToUri) { - //#region Decrement following / followers counts - await Promise.all([ - this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), - this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), - ]); - //#endregion - - //#region Update instance stats - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - this.federatedInstanceService.fetch(follower.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowing(i.host, false); - } - }); - } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - this.federatedInstanceService.fetch(followee.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowers(i.host, false); - } - }); - } - //#endregion - - this.perUserFollowingChart.update(follower, followee, false); - } else { - // Adjust following/followers counts - for (const user of [follower, followee]) { - if (user.movedToUri) continue; // No need to update if the user has already moved. - - const nonMovedFollowees = await this.followingsRepository.count({ - relations: { - followee: true, - }, - where: { - followerId: user.id, - followee: { - movedToUri: IsNull(), - }, - }, - }); - const nonMovedFollowers = await this.followingsRepository.count({ - relations: { - follower: true, - }, - where: { - followeeId: user.id, - follower: { - movedToUri: IsNull(), - }, - }, - }); - await this.usersRepository.update( - { id: user.id }, - { followingCount: nonMovedFollowees, followersCount: nonMovedFollowers }, - ); - } - - // TODO: adjust charts - } - } - - @bindThis - public async createFollowRequest( - follower: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; - }, - followee: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; - }, - requestId?: string, - withReplies?: boolean, - ): Promise { - if (follower.id === followee.id) return; - - // check blocking - const [blocking, blocked] = await Promise.all([ - this.userBlockingService.checkBlocked(follower.id, followee.id), - this.userBlockingService.checkBlocked(followee.id, follower.id), - ]); - - if (blocking) throw new Error('blocking'); - if (blocked) throw new Error('blocked'); - - const followRequest = await this.followRequestsRepository.insert({ - id: this.idService.gen(), - followerId: follower.id, - followeeId: followee.id, - requestId, - withReplies, - - // 非正規化 - followerHost: follower.host, - followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : undefined, - followerSharedInbox: this.userEntityService.isRemoteUser(follower) ? follower.sharedInbox : undefined, - followeeHost: followee.host, - followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : undefined, - followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : undefined, - }).then(x => this.followRequestsRepository.findOneByOrFail(x.identifiers[0])); - - // Publish receiveRequest event - if (this.userEntityService.isLocalUser(followee)) { - this.userEntityService.pack(follower.id, followee).then(packed => this.globalEventService.publishMainStream(followee.id, 'receiveFollowRequest', packed)); - - this.userEntityService.pack(followee.id, followee, { - detail: true, - }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); - - // 通知を作成 - this.notificationService.createNotification(followee.id, 'receiveFollowRequest', { - }, follower.id); - } - - if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower as MiPartialLocalUser, followee as MiPartialRemoteUser, requestId ?? `${this.config.url}/follows/${followRequest.id}`)); - this.queueService.deliver(follower, content, followee.inbox, false); - } - } - - @bindThis - public async cancelFollowRequest( - followee: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox'] - }, - follower: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host'] - }, - ): Promise { - if (this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower as MiPartialLocalUser | MiPartialRemoteUser, followee as MiPartialRemoteUser), follower)); - - if (this.userEntityService.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので - this.queueService.deliver(follower, content, followee.inbox, false); - } - } - - const requestExist = await this.followRequestsRepository.exist({ - where: { - followeeId: followee.id, - followerId: follower.id, - }, - }); - - if (!requestExist) { - throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found'); - } - - await this.followRequestsRepository.delete({ - followeeId: followee.id, - followerId: follower.id, - }); - - this.userEntityService.pack(followee.id, followee, { - detail: true, - }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); - } - - @bindThis - public async acceptFollowRequest( - followee: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; - }, - follower: MiUser, - ): Promise { - const request = await this.followRequestsRepository.findOneBy({ - followeeId: followee.id, - followerId: follower.id, - }); - - if (request == null) { - throw new IdentifiableError('8884c2dd-5795-4ac9-b27e-6a01d38190f9', 'No follow request.'); - } - - await this.insertFollowingDoc(followee, follower, false, request.withReplies); - - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee as MiPartialLocalUser, request.requestId!), followee)); - this.queueService.deliver(followee, content, follower.inbox, false); - } - - this.userEntityService.pack(followee.id, followee, { - detail: true, - }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); - } - - @bindThis - public async acceptAllFollowRequests( - user: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; - }, - ): Promise { - const requests = await this.followRequestsRepository.findBy({ - followeeId: user.id, - }); - - for (const request of requests) { - const follower = await this.usersRepository.findOneByOrFail({ id: request.followerId }); - this.acceptFollowRequest(user, follower); - } - } - - /** - * API following/request/reject - */ - @bindThis - public async rejectFollowRequest(user: Local, follower: Both): Promise { - if (this.userEntityService.isRemoteUser(follower)) { - this.deliverReject(user, follower); - } - - await this.removeFollowRequest(user, follower); - - if (this.userEntityService.isLocalUser(follower)) { - this.publishUnfollow(user, follower); - } - } - - /** - * API following/reject - */ - @bindThis - public async rejectFollow(user: Local, follower: Both): Promise { - if (this.userEntityService.isRemoteUser(follower)) { - this.deliverReject(user, follower); - } - - await this.removeFollow(user, follower); - - if (this.userEntityService.isLocalUser(follower)) { - this.publishUnfollow(user, follower); - } - } - - /** - * AP Reject/Follow - */ - @bindThis - public async remoteReject(actor: Remote, follower: Local): Promise { - await this.removeFollowRequest(actor, follower); - await this.removeFollow(actor, follower); - this.publishUnfollow(actor, follower); - } - - /** - * Remove follow request record - */ - @bindThis - private async removeFollowRequest(followee: Both, follower: Both): Promise { - const request = await this.followRequestsRepository.findOneBy({ - followeeId: followee.id, - followerId: follower.id, - }); - - if (!request) return; - - await this.followRequestsRepository.delete(request.id); - } - - /** - * Remove follow record - */ - @bindThis - private async removeFollow(followee: Both, follower: Both): Promise { - const following = await this.followingsRepository.findOne({ - relations: { - followee: true, - follower: true, - }, - where: { - followeeId: followee.id, - followerId: follower.id, - }, - }); - - if (!following || !following.followee || !following.follower) return; - - await this.followingsRepository.delete(following.id); - - this.decrementFollowing(following.follower, following.followee); - } - - /** - * Deliver Reject to remote - */ - @bindThis - private async deliverReject(followee: Local, follower: Remote): Promise { - const request = await this.followRequestsRepository.findOneBy({ - followeeId: followee.id, - followerId: follower.id, - }); - - const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request?.requestId ?? undefined), followee)); - this.queueService.deliver(followee, content, follower.inbox, false); - } - - /** - * Publish unfollow to local - */ - @bindThis - private async publishUnfollow(followee: Both, follower: Local): Promise { - const packedFollowee = await this.userEntityService.pack(followee.id, follower, { - detail: true, - }); - - this.globalEventService.publishMainStream(follower.id, 'unfollow', packedFollowee); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); - for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'unfollow', { - user: packedFollowee, - }); - } - } - - @bindThis - public getFollowees(userId: MiUser['id']) { - return this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: userId }) - .getMany(); - } -} diff --git a/packages/backend/src/core/UserKeypairService.ts b/packages/backend/src/core/UserKeypairService.ts deleted file mode 100644 index 425a97f3f1..0000000000 --- a/packages/backend/src/core/UserKeypairService.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import type { MiUser } from '@/models/User.js'; -import type { UserKeypairsRepository } from '@/models/_.js'; -import { RedisKVCache } from '@/misc/cache.js'; -import type { MiUserKeypair } from '@/models/UserKeypair.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class UserKeypairService implements OnApplicationShutdown { - private cache: RedisKVCache; - - constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, - - @Inject(DI.userKeypairsRepository) - private userKeypairsRepository: UserKeypairsRepository, - ) { - this.cache = new RedisKVCache(this.redisClient, 'userKeypair', { - lifetime: 1000 * 60 * 60 * 24, // 24h - memoryCacheLifetime: Infinity, - fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }), - toRedisConverter: (value) => JSON.stringify(value), - fromRedisConverter: (value) => JSON.parse(value), - }); - } - - @bindThis - public async getUserKeypair(userId: MiUser['id']): Promise { - return await this.cache.fetch(userId); - } - - @bindThis - public dispose(): void { - this.cache.dispose(); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts deleted file mode 100644 index b6e4e1e884..0000000000 --- a/packages/backend/src/core/UserListService.ts +++ /dev/null @@ -1,160 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable, OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import { ModuleRef } from '@nestjs/core'; -import type { UserListMembershipsRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiUserList } from '@/models/UserList.js'; -import type { MiUserListMembership } from '@/models/UserListMembership.js'; -import { IdService } from '@/core/IdService.js'; -import type { GlobalEvents } from '@/core/GlobalEventService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { ProxyAccountService } from '@/core/ProxyAccountService.js'; -import { bindThis } from '@/decorators.js'; -import { QueueService } from '@/core/QueueService.js'; -import { RedisKVCache } from '@/misc/cache.js'; -import { RoleService } from '@/core/RoleService.js'; - -@Injectable() -export class UserListService implements OnApplicationShutdown, OnModuleInit { - public static TooManyUsersError = class extends Error {}; - - public membersCache: RedisKVCache>; - private roleService: RoleService; - - constructor( - private moduleRef: ModuleRef, - - @Inject(DI.redis) - private redisClient: Redis.Redis, - - @Inject(DI.redisForSub) - private redisForSub: Redis.Redis, - - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - private userEntityService: UserEntityService, - private idService: IdService, - private globalEventService: GlobalEventService, - private proxyAccountService: ProxyAccountService, - private queueService: QueueService, - ) { - this.membersCache = new RedisKVCache>(this.redisClient, 'userListMembers', { - lifetime: 1000 * 60 * 30, // 30m - memoryCacheLifetime: 1000 * 60, // 1m - fetcher: (key) => this.userListMembershipsRepository.find({ where: { userListId: key }, select: ['userId'] }).then(xs => new Set(xs.map(x => x.userId))), - toRedisConverter: (value) => JSON.stringify(Array.from(value)), - fromRedisConverter: (value) => new Set(JSON.parse(value)), - }); - - this.redisForSub.on('message', this.onMessage); - } - - async onModuleInit() { - this.roleService = this.moduleRef.get(RoleService.name); - } - - @bindThis - private async onMessage(_: string, data: string): Promise { - const obj = JSON.parse(data); - - if (obj.channel === 'internal') { - const { type, body } = obj.message as GlobalEvents['internal']['payload']; - switch (type) { - case 'userListMemberAdded': { - const { userListId, memberId } = body; - const members = await this.membersCache.get(userListId); - if (members) { - members.add(memberId); - } - break; - } - case 'userListMemberRemoved': { - const { userListId, memberId } = body; - const members = await this.membersCache.get(userListId); - if (members) { - members.delete(memberId); - } - break; - } - default: - break; - } - } - } - - @bindThis - public async addMember(target: MiUser, list: MiUserList, me: MiUser) { - const currentCount = await this.userListMembershipsRepository.countBy({ - userListId: list.id, - }); - if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) { - throw new UserListService.TooManyUsersError(); - } - - await this.userListMembershipsRepository.insert({ - id: this.idService.gen(), - userId: target.id, - userListId: list.id, - userListUserId: list.userId, - } as MiUserListMembership); - - this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id }); - this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target)); - - // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする - if (this.userEntityService.isRemoteUser(target)) { - const proxy = await this.proxyAccountService.fetch(); - if (proxy) { - this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: target.id } }]); - } - } - } - - @bindThis - public async removeMember(target: MiUser, list: MiUserList) { - await this.userListMembershipsRepository.delete({ - userId: target.id, - userListId: list.id, - }); - - this.globalEventService.publishInternalEvent('userListMemberRemoved', { userListId: list.id, memberId: target.id }); - this.globalEventService.publishUserListStream(list.id, 'userRemoved', await this.userEntityService.pack(target)); - } - - @bindThis - public async updateMembership(target: MiUser, list: MiUserList, options: { withReplies?: boolean }) { - const membership = await this.userListMembershipsRepository.findOneBy({ - userId: target.id, - userListId: list.id, - }); - - if (membership == null) { - throw new Error('User is not a member of the list'); - } - - await this.userListMembershipsRepository.update({ - id: membership.id, - }, { - withReplies: options.withReplies, - }); - } - - @bindThis - public dispose(): void { - this.redisForSub.off('message', this.onMessage); - this.membersCache.dispose(); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/core/UserMutingService.ts b/packages/backend/src/core/UserMutingService.ts deleted file mode 100644 index 397e6bdd5d..0000000000 --- a/packages/backend/src/core/UserMutingService.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; -import type { MutingsRepository, MiMuting } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import type { MiUser } from '@/models/User.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; -import { CacheService } from '@/core/CacheService.js'; - -@Injectable() -export class UserMutingService { - constructor( - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - private idService: IdService, - private cacheService: CacheService, - ) { - } - - @bindThis - public async mute(user: MiUser, target: MiUser, expiresAt: Date | null = null): Promise { - await this.mutingsRepository.insert({ - id: this.idService.gen(), - expiresAt: expiresAt ?? null, - muterId: user.id, - muteeId: target.id, - }); - - this.cacheService.userMutingsCache.refresh(user.id); - } - - @bindThis - public async unmute(mutings: MiMuting[]): Promise { - if (mutings.length === 0) return; - - await this.mutingsRepository.delete({ - id: In(mutings.map(m => m.id)), - }); - - const muterIds = [...new Set(mutings.map(m => m.muterId))]; - for (const muterId of muterIds) { - this.cacheService.userMutingsCache.refresh(muterId); - } - } -} diff --git a/packages/backend/src/core/UserService.ts b/packages/backend/src/core/UserService.ts deleted file mode 100644 index d16e1be615..0000000000 --- a/packages/backend/src/core/UserService.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { FollowingsRepository, UsersRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class UserService { - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - ) { - } - - @bindThis - public async updateLastActiveDate(user: MiUser): Promise { - if (user.isHibernated) { - const result = await this.usersRepository.createQueryBuilder().update() - .set({ - lastActiveDate: new Date(), - }) - .where('id = :id', { id: user.id }) - .returning('*') - .execute() - .then((response) => { - return response.raw[0]; - }); - const wokeUp = result.isHibernated; - if (wokeUp) { - this.usersRepository.update(user.id, { - isHibernated: false, - }); - this.followingsRepository.update({ - followerId: user.id, - }, { - isFollowerHibernated: false, - }); - } - } else { - this.usersRepository.update(user.id, { - lastActiveDate: new Date(), - }); - } - } -} diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts deleted file mode 100644 index 8940a142d1..0000000000 --- a/packages/backend/src/core/UserSuspendService.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Not, IsNull } from 'typeorm'; -import type { FollowingsRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; -import { QueueService } from '@/core/QueueService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class UserSuspendService { - constructor( - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - private userEntityService: UserEntityService, - private queueService: QueueService, - private globalEventService: GlobalEventService, - private apRendererService: ApRendererService, - ) { - } - - @bindThis - public async doPostSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise { - this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true }); - - if (this.userEntityService.isLocalUser(user)) { - // 知り得る全SharedInboxにDelete配信 - const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); - - const queue: string[] = []; - - const followings = await this.followingsRepository.find({ - where: [ - { followerSharedInbox: Not(IsNull()) }, - { followeeSharedInbox: Not(IsNull()) }, - ], - select: ['followerSharedInbox', 'followeeSharedInbox'], - }); - - const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); - - for (const inbox of inboxes) { - if (inbox != null && !queue.includes(inbox)) queue.push(inbox); - } - - for (const inbox of queue) { - this.queueService.deliver(user, content, inbox, true); - } - } - } - - @bindThis - public async doPostUnsuspend(user: MiUser): Promise { - this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false }); - - if (this.userEntityService.isLocalUser(user)) { - // 知り得る全SharedInboxにUndo Delete配信 - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user)); - - const queue: string[] = []; - - const followings = await this.followingsRepository.find({ - where: [ - { followerSharedInbox: Not(IsNull()) }, - { followeeSharedInbox: Not(IsNull()) }, - ], - select: ['followerSharedInbox', 'followeeSharedInbox'], - }); - - const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); - - for (const inbox of inboxes) { - if (inbox != null && !queue.includes(inbox)) queue.push(inbox); - } - - for (const inbox of queue) { - this.queueService.deliver(user as any, content, inbox, true); - } - } - } -} diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts deleted file mode 100644 index b040d061e5..0000000000 --- a/packages/backend/src/core/UtilityService.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { URL } from 'node:url'; -import { toASCII } from 'punycode'; -import { Inject, Injectable } from '@nestjs/common'; -import RE2 from 're2'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class UtilityService { - constructor( - @Inject(DI.config) - private config: Config, - ) { - } - - @bindThis - public getFullApAccount(username: string, host: string | null): string { - return host ? `${username}@${this.toPuny(host)}` : `${username}@${this.toPuny(this.config.host)}`; - } - - @bindThis - public isSelfHost(host: string | null): boolean { - if (host == null) return true; - return this.toPuny(this.config.host) === this.toPuny(host); - } - - @bindThis - public isBlockedHost(blockedHosts: string[], host: string | null): boolean { - if (host == null) return false; - return blockedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); - } - - @bindThis - public isSilencedHost(silencedHosts: string[] | undefined, host: string | null): boolean { - if (!silencedHosts || host == null) return false; - return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); - } - - @bindThis - public isAllowedHost(allowedHosts: string[], host: string | null): boolean { - if (host == null) return false; - return allowedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); - } - - @bindThis - public isSensitiveWordIncluded(text: string, sensitiveWords: string[]): boolean { - if (sensitiveWords.length === 0) return false; - if (text === '') return false; - - const regexpregexp = /^\/(.+)\/(.*)$/; - - const matched = sensitiveWords.some(filter => { - // represents RegExp - const regexp = filter.match(regexpregexp); - // This should never happen due to input sanitisation. - if (!regexp) { - const words = filter.split(' '); - return words.every(keyword => text.includes(keyword)); - } - try { - // TODO: RE2インスタンスをキャッシュ - return new RE2(regexp[1], regexp[2]).test(text); - } catch (err) { - // This should never happen due to input sanitisation. - return false; - } - }); - - return matched; - } - - @bindThis - public extractDbHost(uri: string): string { - const url = new URL(uri); - return this.toPuny(url.hostname); - } - - @bindThis - public toPuny(host: string): string { - return toASCII(host.toLowerCase()); - } - - @bindThis - public toPunyNullable(host: string | null | undefined): string | null { - if (host == null) return null; - return toASCII(host.toLowerCase()); - } -} diff --git a/packages/backend/src/core/VideoProcessingService.ts b/packages/backend/src/core/VideoProcessingService.ts deleted file mode 100644 index ffb7573358..0000000000 --- a/packages/backend/src/core/VideoProcessingService.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import FFmpeg from 'fluent-ffmpeg'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { ImageProcessingService } from '@/core/ImageProcessingService.js'; -import type { IImage } from '@/core/ImageProcessingService.js'; -import { createTempDir } from '@/misc/create-temp.js'; -import { bindThis } from '@/decorators.js'; -import { appendQuery, query } from '@/misc/prelude/url.js'; - -@Injectable() -export class VideoProcessingService { - constructor( - @Inject(DI.config) - private config: Config, - - private imageProcessingService: ImageProcessingService, - ) { - } - - @bindThis - public async generateVideoThumbnail(source: string): Promise { - const [dir, cleanup] = await createTempDir(); - - try { - await new Promise((res, rej) => { - FFmpeg({ - source, - }) - .on('end', res) - .on('error', rej) - .screenshot({ - folder: dir, - filename: 'out.png', // must have .png extension - count: 1, - timestamps: ['5%'], - }); - }); - - return await this.imageProcessingService.convertToWebp(`${dir}/out.png`, 498, 422); - } finally { - cleanup(); - } - } - - @bindThis - public getExternalVideoThumbnailUrl(url: string): string | null { - if (this.config.videoThumbnailGenerator == null) return null; - - return appendQuery( - `${this.config.videoThumbnailGenerator}/thumbnail.webp`, - query({ - thumbnail: '1', - url, - }), - ); - } -} - diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts deleted file mode 100644 index 5945dc2919..0000000000 --- a/packages/backend/src/core/WebAuthnService.ts +++ /dev/null @@ -1,252 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import { - generateAuthenticationOptions, - generateRegistrationOptions, verifyAuthenticationResponse, - verifyRegistrationResponse, -} from '@simplewebauthn/server'; -import { AttestationFormat, isoCBOR } from '@simplewebauthn/server/helpers'; -import { DI } from '@/di-symbols.js'; -import type { UserSecurityKeysRepository } from '@/models/_.js'; -import type { Config } from '@/config.js'; -import { bindThis } from '@/decorators.js'; -import { MetaService } from '@/core/MetaService.js'; -import { MiUser } from '@/models/_.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; -import type { - AuthenticationResponseJSON, - AuthenticatorTransportFuture, - CredentialDeviceType, - PublicKeyCredentialCreationOptionsJSON, - PublicKeyCredentialDescriptorFuture, - PublicKeyCredentialRequestOptionsJSON, - RegistrationResponseJSON, -} from '@simplewebauthn/typescript-types'; - -@Injectable() -export class WebAuthnService { - constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, - - @Inject(DI.config) - private config: Config, - - @Inject(DI.userSecurityKeysRepository) - private userSecurityKeysRepository: UserSecurityKeysRepository, - - private metaService: MetaService, - ) { - } - - @bindThis - public async getRelyingParty(): Promise<{ origin: string; rpId: string; rpName: string; rpIcon?: string; }> { - const instance = await this.metaService.fetch(); - return { - origin: this.config.url, - rpId: this.config.host, - rpName: instance.name ?? this.config.host, - rpIcon: instance.iconUrl ?? undefined, - }; - } - - @bindThis - public async initiateRegistration(userId: MiUser['id'], userName: string, userDisplayName?: string): Promise { - const relyingParty = await this.getRelyingParty(); - const keys = await this.userSecurityKeysRepository.findBy({ - userId: userId, - }); - - const registrationOptions = await generateRegistrationOptions({ - rpName: relyingParty.rpName, - rpID: relyingParty.rpId, - userID: userId, - userName: userName, - userDisplayName: userDisplayName, - attestationType: 'indirect', - excludeCredentials: keys.map(key => ({ - id: Buffer.from(key.id, 'base64url'), - type: 'public-key', - transports: key.transports ?? undefined, - })), - authenticatorSelection: { - residentKey: 'required', - userVerification: 'preferred', - }, - }); - - await this.redisClient.setex(`webauthn:challenge:${userId}`, 90, registrationOptions.challenge); - - return registrationOptions; - } - - @bindThis - public async verifyRegistration(userId: MiUser['id'], response: RegistrationResponseJSON): Promise<{ - credentialID: Uint8Array; - credentialPublicKey: Uint8Array; - attestationObject: Uint8Array; - fmt: AttestationFormat; - counter: number; - userVerified: boolean; - credentialDeviceType: CredentialDeviceType; - credentialBackedUp: boolean; - transports?: AuthenticatorTransportFuture[]; - }> { - const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`); - - if (!challenge) { - throw new IdentifiableError('7dbfb66c-9216-4e2b-9c27-cef2ac8efb84', 'challenge not found'); - } - - await this.redisClient.del(`webauthn:challenge:${userId}`); - - const relyingParty = await this.getRelyingParty(); - - let verification; - try { - verification = await verifyRegistrationResponse({ - response: response, - expectedChallenge: challenge, - expectedOrigin: relyingParty.origin, - expectedRPID: relyingParty.rpId, - requireUserVerification: true, - }); - } catch (error) { - console.error(error); - throw new IdentifiableError('5c1446f8-8ca7-4d31-9f39-656afe9c5d87', 'verification failed'); - } - - const { verified } = verification; - - if (!verified || !verification.registrationInfo) { - throw new IdentifiableError('bb333667-3832-4a80-8bb5-c505be7d710d', 'verification failed'); - } - - const { registrationInfo } = verification; - - return { - credentialID: registrationInfo.credentialID, - credentialPublicKey: registrationInfo.credentialPublicKey, - attestationObject: registrationInfo.attestationObject, - fmt: registrationInfo.fmt, - counter: registrationInfo.counter, - userVerified: registrationInfo.userVerified, - credentialDeviceType: registrationInfo.credentialDeviceType, - credentialBackedUp: registrationInfo.credentialBackedUp, - transports: response.response.transports, - }; - } - - @bindThis - public async initiateAuthentication(userId: MiUser['id']): Promise { - const keys = await this.userSecurityKeysRepository.findBy({ - userId: userId, - }); - - if (keys.length === 0) { - throw new IdentifiableError('f27fd449-9af4-4841-9249-1f989b9fa4a4', 'no keys found'); - } - - const authenticationOptions = await generateAuthenticationOptions({ - allowCredentials: keys.map(key => ({ - id: Buffer.from(key.id, 'base64url'), - type: 'public-key', - transports: key.transports ?? undefined, - })), - userVerification: 'preferred', - }); - - await this.redisClient.setex(`webauthn:challenge:${userId}`, 90, authenticationOptions.challenge); - - return authenticationOptions; - } - - @bindThis - public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise { - const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`); - - if (!challenge) { - throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', 'challenge not found'); - } - - await this.redisClient.del(`webauthn:challenge:${userId}`); - - const key = await this.userSecurityKeysRepository.findOneBy({ - id: response.id, - userId: userId, - }); - - if (!key) { - throw new IdentifiableError('36b96a7d-b547-412d-aeed-2d611cdc8cdc', 'unknown key'); - } - - // マイグレーション - if (key.counter === 0 && key.publicKey.length === 87) { - const cert = new Uint8Array(Buffer.from(key.publicKey, 'base64url')); - if (cert[0] === 0x04) { // 前の実装ではいつも 0x04 で始まっていた - const halfLength = (cert.length - 1) / 2; - - const cborMap = new Map(); - cborMap.set(1, 2); // kty, EC2 - cborMap.set(3, -7); // alg, ES256 - cborMap.set(-1, 1); // crv, P256 - cborMap.set(-2, cert.slice(1, halfLength + 1)); // x - cborMap.set(-3, cert.slice(halfLength + 1)); // y - - const cborPubKey = Buffer.from(isoCBOR.encode(cborMap)).toString('base64url'); - await this.userSecurityKeysRepository.update({ - id: response.id, - userId: userId, - }, { - publicKey: cborPubKey, - }); - key.publicKey = cborPubKey; - } - } - - const relyingParty = await this.getRelyingParty(); - - let verification; - try { - verification = await verifyAuthenticationResponse({ - response: response, - expectedChallenge: challenge, - expectedOrigin: relyingParty.origin, - expectedRPID: relyingParty.rpId, - authenticator: { - credentialID: Buffer.from(key.id, 'base64url'), - credentialPublicKey: Buffer.from(key.publicKey, 'base64url'), - counter: key.counter, - transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined, - }, - requireUserVerification: true, - }); - } catch (error) { - console.error(error); - throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', 'verification failed'); - } - - const { verified, authenticationInfo } = verification; - - if (!verified) { - return false; - } - - await this.userSecurityKeysRepository.update({ - id: response.id, - userId: userId, - }, { - lastUsed: new Date(), - counter: authenticationInfo.newCounter, - credentialDeviceType: authenticationInfo.credentialDeviceType, - credentialBackedUp: authenticationInfo.credentialBackedUp, - }); - - return verified; - } -} diff --git a/packages/backend/src/core/WebfingerService.ts b/packages/backend/src/core/WebfingerService.ts deleted file mode 100644 index 3d5747aebd..0000000000 --- a/packages/backend/src/core/WebfingerService.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { URL } from 'node:url'; -import { Injectable } from '@nestjs/common'; -import { query as urlQuery } from '@/misc/prelude/url.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { bindThis } from '@/decorators.js'; - -export type ILink = { - href: string; - rel?: string; -}; - -export type IWebFinger = { - links: ILink[]; - subject: string; -}; - -const urlRegex = /^https?:\/\//; -const mRegex = /^([^@]+)@(.*)/; - -@Injectable() -export class WebfingerService { - constructor( - private httpRequestService: HttpRequestService, - ) { - } - - @bindThis - public async webfinger(query: string): Promise { - const url = this.genUrl(query); - - return await this.httpRequestService.getJson(url, 'application/jrd+json, application/json'); - } - - @bindThis - private genUrl(query: string): string { - if (query.match(urlRegex)) { - const u = new URL(query); - return `${u.protocol}//${u.hostname}/.well-known/webfinger?` + urlQuery({ resource: query }); - } - - const m = query.match(mRegex); - if (m) { - const hostname = m[2]; - const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true'; - return `http${useHttp ? '' : 's'}://${hostname}/.well-known/webfinger?${urlQuery({ resource: `acct:${query}` })}`; - } - - throw new Error(`Invalid query (${query})`); - } -} diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts deleted file mode 100644 index 930e6ef64a..0000000000 --- a/packages/backend/src/core/WebhookService.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -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 { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; -import type { GlobalEvents } from '@/core/GlobalEventService.js'; -import type { OnApplicationShutdown } from '@nestjs/common'; - -@Injectable() -export class WebhookService implements OnApplicationShutdown { - private webhooksFetched = false; - private webhooks: MiWebhook[] = []; - - constructor( - @Inject(DI.redisForSub) - private redisForSub: Redis.Redis, - - @Inject(DI.webhooksRepository) - private webhooksRepository: WebhooksRepository, - ) { - //this.onMessage = this.onMessage.bind(this); - this.redisForSub.on('message', this.onMessage); - } - - @bindThis - public async getActiveWebhooks() { - if (!this.webhooksFetched) { - this.webhooks = await this.webhooksRepository.findBy({ - active: true, - }); - this.webhooksFetched = true; - } - - return this.webhooks; - } - - @bindThis - private async onMessage(_: string, data: string): Promise { - const obj = JSON.parse(data); - - if (obj.channel === 'internal') { - const { type, body } = obj.message as GlobalEvents['internal']['payload']; - switch (type) { - case 'webhookCreated': - if (body.active) { - this.webhooks.push({ - ...body, - latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, - }); - } - break; - case 'webhookUpdated': - if (body.active) { - const i = this.webhooks.findIndex(a => a.id === body.id); - if (i > -1) { - this.webhooks[i] = { - ...body, - latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, - }; - } else { - this.webhooks.push({ - ...body, - latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, - }); - } - } else { - this.webhooks = this.webhooks.filter(a => a.id !== body.id); - } - break; - case 'webhookDeleted': - this.webhooks = this.webhooks.filter(a => a.id !== body.id); - break; - default: - break; - } - } - } - - @bindThis - public dispose(): void { - this.redisForSub.off('message', this.onMessage); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/core/activitypub/ApAudienceService.ts b/packages/backend/src/core/activitypub/ApAudienceService.ts deleted file mode 100644 index 440852bdf3..0000000000 --- a/packages/backend/src/core/activitypub/ApAudienceService.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import promiseLimit from 'promise-limit'; -import type { MiRemoteUser, MiUser } from '@/models/User.js'; -import { concat, unique } from '@/misc/prelude/array.js'; -import { bindThis } from '@/decorators.js'; -import { getApIds } from './type.js'; -import { ApPersonService } from './models/ApPersonService.js'; -import type { ApObject } from './type.js'; -import type { Resolver } from './ApResolverService.js'; - -type Visibility = 'public' | 'home' | 'followers' | 'specified'; - -type AudienceInfo = { - visibility: Visibility, - mentionedUsers: MiUser[], - visibleUsers: MiUser[], -}; - -type GroupedAudience = Record<'public' | 'followers' | 'other', string[]>; - -@Injectable() -export class ApAudienceService { - constructor( - private apPersonService: ApPersonService, - ) { - } - - @bindThis - public async parseAudience(actor: MiRemoteUser, to?: ApObject, cc?: ApObject, resolver?: Resolver): Promise { - const toGroups = this.groupingAudience(getApIds(to), actor); - const ccGroups = this.groupingAudience(getApIds(cc), actor); - - const others = unique(concat([toGroups.other, ccGroups.other])); - - const limit = promiseLimit(2); - const mentionedUsers = (await Promise.all( - others.map(id => limit(() => this.apPersonService.resolvePerson(id, resolver).catch(() => null))), - )).filter((x): x is MiUser => x != null); - - if (toGroups.public.length > 0) { - return { - visibility: 'public', - mentionedUsers, - visibleUsers: [], - }; - } - - if (ccGroups.public.length > 0) { - return { - visibility: 'home', - mentionedUsers, - visibleUsers: [], - }; - } - - if (toGroups.followers.length > 0) { - return { - visibility: 'followers', - mentionedUsers, - visibleUsers: [], - }; - } - - return { - visibility: 'specified', - mentionedUsers, - visibleUsers: mentionedUsers, - }; - } - - @bindThis - private groupingAudience(ids: string[], actor: MiRemoteUser): GroupedAudience { - const groups: GroupedAudience = { - public: [], - followers: [], - other: [], - }; - - for (const id of ids) { - if (this.isPublic(id)) { - groups.public.push(id); - } else if (this.isFollowers(id, actor)) { - groups.followers.push(id); - } else { - groups.other.push(id); - } - } - - groups.other = unique(groups.other); - - return groups; - } - - @bindThis - private isPublic(id: string): boolean { - return [ - 'https://www.w3.org/ns/activitystreams#Public', - 'as:Public', - 'Public', - ].includes(id); - } - - @bindThis - private isFollowers(id: string, actor: MiRemoteUser): boolean { - return id === (actor.followersUri ?? `${actor.uri}/followers`); - } -} diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts deleted file mode 100644 index dd1687edeb..0000000000 --- a/packages/backend/src/core/activitypub/ApDbResolverService.ts +++ /dev/null @@ -1,190 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js'; -import type { Config } from '@/config.js'; -import { MemoryKVCache } from '@/misc/cache.js'; -import type { MiUserPublickey } from '@/models/UserPublickey.js'; -import { CacheService } from '@/core/CacheService.js'; -import type { MiNote } from '@/models/Note.js'; -import { bindThis } from '@/decorators.js'; -import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; -import { getApId } from './type.js'; -import { ApPersonService } from './models/ApPersonService.js'; -import type { IObject } from './type.js'; - -export type UriParseResult = { - /** wether the URI was generated by us */ - local: true; - /** id in DB */ - id: string; - /** hint of type, e.g. "notes", "users" */ - type: string; - /** any remaining text after type and id, not including the slash after id. undefined if empty */ - rest?: string; -} | { - /** wether the URI was generated by us */ - local: false; - /** uri in DB */ - uri: string; -}; - -@Injectable() -export class ApDbResolverService implements OnApplicationShutdown { - private publicKeyCache: MemoryKVCache; - private publicKeyByUserIdCache: MemoryKVCache; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.userPublickeysRepository) - private userPublickeysRepository: UserPublickeysRepository, - - private cacheService: CacheService, - private apPersonService: ApPersonService, - ) { - this.publicKeyCache = new MemoryKVCache(Infinity); - this.publicKeyByUserIdCache = new MemoryKVCache(Infinity); - } - - @bindThis - public parseUri(value: string | IObject): UriParseResult { - const separator = '/'; - - const uri = new URL(getApId(value)); - if (uri.origin !== this.config.url) return { local: false, uri: uri.href }; - - const [, type, id, ...rest] = uri.pathname.split(separator); - return { - local: true, - type, - id, - rest: rest.length === 0 ? undefined : rest.join(separator), - }; - } - - /** - * AP Note => Misskey Note in DB - */ - @bindThis - public async getNoteFromApId(value: string | IObject): Promise { - const parsed = this.parseUri(value); - - if (parsed.local) { - if (parsed.type !== 'notes') return null; - - return await this.notesRepository.findOneBy({ - id: parsed.id, - }); - } else { - return await this.notesRepository.findOneBy({ - uri: parsed.uri, - }); - } - } - - /** - * AP Person => Misskey User in DB - */ - @bindThis - public async getUserFromApId(value: string | IObject): Promise { - const parsed = this.parseUri(value); - - if (parsed.local) { - if (parsed.type !== 'users') return null; - - return await this.cacheService.userByIdCache.fetchMaybe( - parsed.id, - () => this.usersRepository.findOneBy({ id: parsed.id }).then(x => x ?? undefined), - ) as MiLocalUser | undefined ?? null; - } else { - return await this.cacheService.uriPersonCache.fetch( - parsed.uri, - () => this.usersRepository.findOneBy({ uri: parsed.uri }), - ) as MiRemoteUser | null; - } - } - - /** - * AP KeyId => Misskey User and Key - */ - @bindThis - public async getAuthUserFromKeyId(keyId: string): Promise<{ - user: MiRemoteUser; - key: MiUserPublickey; - } | null> { - const key = await this.publicKeyCache.fetch(keyId, async () => { - const key = await this.userPublickeysRepository.findOneBy({ - keyId, - }); - - if (key == null) return null; - - return key; - }, key => key != null); - - if (key == null) return null; - - return { - user: await this.cacheService.findUserById(key.userId) as MiRemoteUser, - key, - }; - } - - /** - * AP Actor id => Misskey User and Key - */ - @bindThis - public async getAuthUserFromApId(uri: string): Promise<{ - user: MiRemoteUser; - key: MiUserPublickey | null; - } | null> { - const user = await this.apPersonService.resolvePerson(uri) as MiRemoteUser; - - const key = await this.publicKeyByUserIdCache.fetch( - user.id, - () => this.userPublickeysRepository.findOneBy({ userId: user.id }), - v => v != null, - ); - - return { - user, - key, - }; - } - - /** - * Sharkey User -> Refetched Key - */ - @bindThis - public async refetchPublicKeyForApId(user: MiRemoteUser): Promise { - await this.apPersonService.updatePerson(user.uri); - const key = await this.userPublickeysRepository.findOneBy({ userId: user.id }); - if (key != null) { - await this.publicKeyByUserIdCache.set(user.id, key); - } - return key; - } - - @bindThis - public dispose(): void { - this.publicKeyCache.dispose(); - this.publicKeyByUserIdCache.dispose(); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts deleted file mode 100644 index 81003bcf1c..0000000000 --- a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts +++ /dev/null @@ -1,210 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, Not } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository } from '@/models/_.js'; -import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; -import { QueueService } from '@/core/QueueService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { bindThis } from '@/decorators.js'; -import type { IActivity } from '@/core/activitypub/type.js'; -import { ThinUser } from '@/queue/types.js'; - -interface IRecipe { - type: string; -} - -interface IFollowersRecipe extends IRecipe { - type: 'Followers'; -} - -interface IDirectRecipe extends IRecipe { - type: 'Direct'; - to: MiRemoteUser; -} - -const isFollowers = (recipe: IRecipe): recipe is IFollowersRecipe => - recipe.type === 'Followers'; - -const isDirect = (recipe: IRecipe): recipe is IDirectRecipe => - recipe.type === 'Direct'; - -class DeliverManager { - private actor: ThinUser; - private activity: IActivity | null; - private recipes: IRecipe[] = []; - - /** - * Constructor - * @param userEntityService - * @param followingsRepository - * @param queueService - * @param actor Actor - * @param activity Activity to deliver - */ - constructor( - private userEntityService: UserEntityService, - private followingsRepository: FollowingsRepository, - private queueService: QueueService, - - actor: { id: MiUser['id']; host: null; }, - activity: IActivity | null, - ) { - // 型で弾いてはいるが一応ローカルユーザーかチェック - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (actor.host != null) throw new Error('actor.host must be null'); - - // パフォーマンス向上のためキューに突っ込むのはidのみに絞る - this.actor = { - id: actor.id, - }; - this.activity = activity; - } - - /** - * Add recipe for followers deliver - */ - @bindThis - public addFollowersRecipe(): void { - const deliver: IFollowersRecipe = { - type: 'Followers', - }; - - this.addRecipe(deliver); - } - - /** - * Add recipe for direct deliver - * @param to To - */ - @bindThis - public addDirectRecipe(to: MiRemoteUser): void { - const recipe: IDirectRecipe = { - type: 'Direct', - to, - }; - - this.addRecipe(recipe); - } - - /** - * Add recipe - * @param recipe Recipe - */ - @bindThis - public addRecipe(recipe: IRecipe): void { - this.recipes.push(recipe); - } - - /** - * Execute delivers - */ - @bindThis - public async execute(): Promise { - // The value flags whether it is shared or not. - // key: inbox URL, value: whether it is sharedInbox - const inboxes = new Map(); - - // build inbox list - // Process follower recipes first to avoid duplication when processing direct recipes later. - if (this.recipes.some(r => isFollowers(r))) { - // followers deliver - // TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう - // ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう? - const followers = await this.followingsRepository.find({ - where: { - followeeId: this.actor.id, - followerHost: Not(IsNull()), - }, - select: { - followerSharedInbox: true, - followerInbox: true, - }, - }); - - for (const following of followers) { - const inbox = following.followerSharedInbox ?? following.followerInbox; - if (inbox === null) throw new Error('inbox is null'); - inboxes.set(inbox, following.followerSharedInbox != null); - } - } - - for (const recipe of this.recipes.filter(isDirect)) { - // check that shared inbox has not been added yet - if (recipe.to.sharedInbox !== null && inboxes.has(recipe.to.sharedInbox)) continue; - - // check that they actually have an inbox - if (recipe.to.inbox === null) continue; - - inboxes.set(recipe.to.inbox, false); - } - - // deliver - this.queueService.deliverMany(this.actor, this.activity, inboxes); - } -} - -@Injectable() -export class ApDeliverManagerService { - constructor( - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - private userEntityService: UserEntityService, - private queueService: QueueService, - ) { - } - - /** - * Deliver activity to followers - * @param actor - * @param activity Activity - */ - @bindThis - public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity): Promise { - const manager = new DeliverManager( - this.userEntityService, - this.followingsRepository, - this.queueService, - actor, - activity, - ); - manager.addFollowersRecipe(); - await manager.execute(); - } - - /** - * Deliver activity to user - * @param actor - * @param activity Activity - * @param to Target user - */ - @bindThis - public async deliverToUser(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, to: MiRemoteUser): Promise { - const manager = new DeliverManager( - this.userEntityService, - this.followingsRepository, - this.queueService, - actor, - activity, - ); - manager.addDirectRecipe(to); - await manager.execute(); - } - - @bindThis - public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager { - return new DeliverManager( - this.userEntityService, - this.followingsRepository, - this.queueService, - - actor, - activity, - ); - } -} diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts deleted file mode 100644 index 1916bbe540..0000000000 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ /dev/null @@ -1,768 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; -import { ReactionService } from '@/core/ReactionService.js'; -import { RelayService } from '@/core/RelayService.js'; -import { NotePiningService } from '@/core/NotePiningService.js'; -import { UserBlockingService } from '@/core/UserBlockingService.js'; -import { NoteDeleteService } from '@/core/NoteDeleteService.js'; -import { NoteCreateService } from '@/core/NoteCreateService.js'; -import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; -import { AppLockService } from '@/core/AppLockService.js'; -import type Logger from '@/logger.js'; -import { MetaService } from '@/core/MetaService.js'; -import { IdService } from '@/core/IdService.js'; -import { StatusError } from '@/misc/status-error.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { QueueService } from '@/core/QueueService.js'; -import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/_.js'; -import { bindThis } from '@/decorators.js'; -import type { MiRemoteUser } from '@/models/User.js'; -import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; -import { ApNoteService } from './models/ApNoteService.js'; -import { ApLoggerService } from './ApLoggerService.js'; -import { ApDbResolverService } from './ApDbResolverService.js'; -import { ApResolverService } from './ApResolverService.js'; -import { ApAudienceService } from './ApAudienceService.js'; -import { ApPersonService } from './models/ApPersonService.js'; -import { ApQuestionService } from './models/ApQuestionService.js'; -import type { Resolver } from './ApResolverService.js'; -import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove } from './type.js'; - -@Injectable() -export class ApInboxService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - @Inject(DI.abuseUserReportsRepository) - private abuseUserReportsRepository: AbuseUserReportsRepository, - - @Inject(DI.followRequestsRepository) - private followRequestsRepository: FollowRequestsRepository, - - private userEntityService: UserEntityService, - private noteEntityService: NoteEntityService, - private utilityService: UtilityService, - private idService: IdService, - private metaService: MetaService, - private userFollowingService: UserFollowingService, - private apAudienceService: ApAudienceService, - private reactionService: ReactionService, - private relayService: RelayService, - private notePiningService: NotePiningService, - private userBlockingService: UserBlockingService, - private noteCreateService: NoteCreateService, - private noteDeleteService: NoteDeleteService, - private appLockService: AppLockService, - private apResolverService: ApResolverService, - private apDbResolverService: ApDbResolverService, - private apLoggerService: ApLoggerService, - private apNoteService: ApNoteService, - private apPersonService: ApPersonService, - private apQuestionService: ApQuestionService, - private queueService: QueueService, - ) { - this.logger = this.apLoggerService.logger; - } - - @bindThis - public async performActivity(actor: MiRemoteUser, activity: IObject): Promise { - if (isCollectionOrOrderedCollection(activity)) { - const resolver = this.apResolverService.createResolver(); - for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) { - const act = await resolver.resolve(item); - try { - await this.performOneActivity(actor, act); - } catch (err) { - if (err instanceof Error || typeof err === 'string') { - this.logger.error(err); - } - } - } - } else { - await this.performOneActivity(actor, activity); - } - - // ついでにリモートユーザーの情報が古かったら更新しておく - if (actor.uri) { - if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { - setImmediate(() => { - this.apPersonService.updatePerson(actor.uri); - }); - } - } - } - - @bindThis - public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise { - if (actor.isSuspended) return; - - if (isCreate(activity)) { - await this.create(actor, activity); - } else if (isDelete(activity)) { - await this.delete(actor, activity); - } else if (isUpdate(activity)) { - await this.update(actor, activity); - } else if (isFollow(activity)) { - await this.follow(actor, activity); - } else if (isAccept(activity)) { - await this.accept(actor, activity); - } else if (isReject(activity)) { - await this.reject(actor, activity); - } else if (isAdd(activity)) { - await this.add(actor, activity).catch(err => this.logger.error(err)); - } else if (isRemove(activity)) { - await this.remove(actor, activity).catch(err => this.logger.error(err)); - } else if (isAnnounce(activity)) { - await this.announce(actor, activity); - } else if (isLike(activity)) { - await this.like(actor, activity); - } else if (isUndo(activity)) { - await this.undo(actor, activity); - } else if (isBlock(activity)) { - await this.block(actor, activity); - } else if (isFlag(activity)) { - await this.flag(actor, activity); - } else if (isMove(activity)) { - await this.move(actor, activity); - } else { - this.logger.warn(`unrecognized activity type: ${activity.type}`); - } - } - - @bindThis - private async follow(actor: MiRemoteUser, activity: IFollow): Promise { - const followee = await this.apDbResolverService.getUserFromApId(activity.object); - - if (followee == null) { - return 'skip: followee not found'; - } - - if (followee.host != null) { - return 'skip: フォローしようとしているユーザーはローカルユーザーではありません'; - } - - // don't queue because the sender may attempt again when timeout - await this.userFollowingService.follow(actor, followee, { requestId: activity.id }); - return 'ok'; - } - - @bindThis - private async like(actor: MiRemoteUser, activity: ILike): Promise { - const targetUri = getApId(activity.object); - - const note = await this.apNoteService.fetchNote(targetUri); - if (!note) return `skip: target note not found ${targetUri}`; - - await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null); - - return await this.reactionService.create(actor, note, activity._misskey_reaction ?? activity.content ?? activity.name).catch(err => { - if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') { - return 'skip: already reacted'; - } else { - throw err; - } - }).then(() => 'ok'); - } - - @bindThis - private async accept(actor: MiRemoteUser, activity: IAccept): Promise { - const uri = activity.id ?? activity; - - this.logger.info(`Accept: ${uri}`); - - const resolver = this.apResolverService.createResolver(); - - const object = await resolver.resolve(activity.object).catch(err => { - this.logger.error(`Resolution failed: ${err}`); - throw err; - }); - - if (isFollow(object)) return await this.acceptFollow(actor, object); - - return `skip: Unknown Accept type: ${getApType(object)}`; - } - - @bindThis - private async acceptFollow(actor: MiRemoteUser, activity: IFollow): Promise { - // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある - - const follower = await this.apDbResolverService.getUserFromApId(activity.actor); - - if (follower == null) { - return 'skip: follower not found'; - } - - if (follower.host != null) { - return 'skip: follower is not a local user'; - } - - // relay - const match = activity.id?.match(/follow-relay\/(\w+)/); - if (match) { - return await this.relayService.relayAccepted(match[1]); - } - - await this.userFollowingService.acceptFollowRequest(actor, follower); - return 'ok'; - } - - @bindThis - private async add(actor: MiRemoteUser, activity: IAdd): Promise { - if (actor.uri !== activity.actor) { - throw new Error('invalid actor'); - } - - if (activity.target == null) { - throw new Error('target is null'); - } - - if (activity.target === actor.featured) { - const note = await this.apNoteService.resolveNote(activity.object); - if (note == null) throw new Error('note not found'); - await this.notePiningService.addPinned(actor, note.id); - return; - } - - throw new Error(`unknown target: ${activity.target}`); - } - - @bindThis - private async announce(actor: MiRemoteUser, activity: IAnnounce): Promise { - const uri = getApId(activity); - - this.logger.info(`Announce: ${uri}`); - - const targetUri = getApId(activity.object); - - this.announceNote(actor, activity, targetUri); - } - - @bindThis - private async announceNote(actor: MiRemoteUser, activity: IAnnounce, targetUri: string): Promise { - const uri = getApId(activity); - - if (actor.isSuspended) { - return; - } - - // アナウンス先をブロックしてたら中断 - const meta = await this.metaService.fetch(); - const dbHost = this.utilityService.extractDbHost(uri); - if (this.utilityService.isBlockedHost(meta.blockedHosts, dbHost)) return; - if (meta.allowlistMode && !this.utilityService.isAllowedHost(meta.allowedHosts, dbHost)) return; - - const unlock = await this.appLockService.getApLock(uri); - - try { - // 既に同じURIを持つものが登録されていないかチェック - const exist = await this.apNoteService.fetchNote(uri); - if (exist) { - return; - } - - // Announce対象をresolve - let renote; - try { - renote = await this.apNoteService.resolveNote(targetUri); - if (renote == null) throw new Error('announce target is null'); - } catch (err) { - // 対象が4xxならスキップ - if (err instanceof StatusError) { - if (err.isClientError) { - this.logger.warn(`Ignored announce target ${targetUri} - ${err.statusCode}`); - return; - } - - this.logger.warn(`Error in announce target ${targetUri} - ${err.statusCode}`); - } - throw err; - } - - if (!await this.noteEntityService.isVisibleForMe(renote, actor.id)) { - this.logger.warn('skip: invalid actor for this activity'); - return; - } - - this.logger.info(`Creating the (Re)Note: ${uri}`); - - const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc); - const createdAt = activity.published ? new Date(activity.published) : null; - - if (createdAt && createdAt < this.idService.parse(renote.id).date) { - this.logger.warn('skip: malformed createdAt'); - return; - } - - await this.noteCreateService.create(actor, { - createdAt, - renote, - visibility: activityAudience.visibility, - visibleUsers: activityAudience.visibleUsers, - uri, - }); - } finally { - unlock(); - } - } - - @bindThis - private async block(actor: MiRemoteUser, activity: IBlock): Promise { - // ※ activity.objectにブロック対象があり、それは存在するローカルユーザーのはず - - const blockee = await this.apDbResolverService.getUserFromApId(activity.object); - - if (blockee == null) { - return 'skip: blockee not found'; - } - - if (blockee.host != null) { - return 'skip: ブロックしようとしているユーザーはローカルユーザーではありません'; - } - - await this.userBlockingService.block(await this.usersRepository.findOneByOrFail({ id: actor.id }), await this.usersRepository.findOneByOrFail({ id: blockee.id })); - return 'ok'; - } - - @bindThis - private async create(actor: MiRemoteUser, activity: ICreate): Promise { - const uri = getApId(activity); - - this.logger.info(`Create: ${uri}`); - - // copy audiences between activity <=> object. - if (typeof activity.object === 'object') { - const to = unique(concat([toArray(activity.to), toArray(activity.object.to)])); - const cc = unique(concat([toArray(activity.cc), toArray(activity.object.cc)])); - - activity.to = to; - activity.cc = cc; - activity.object.to = to; - activity.object.cc = cc; - } - - // If there is no attributedTo, use Activity actor. - if (typeof activity.object === 'object' && !activity.object.attributedTo) { - activity.object.attributedTo = activity.actor; - } - - const resolver = this.apResolverService.createResolver(); - - const object = await resolver.resolve(activity.object).catch(e => { - this.logger.error(`Resolution failed: ${e}`); - throw e; - }); - - if (isPost(object)) { - this.createNote(resolver, actor, object, false, activity); - } else { - this.logger.warn(`Unknown type: ${getApType(object)}`); - } - } - - @bindThis - private async createNote(resolver: Resolver, actor: MiRemoteUser, note: IObject, silent = false, activity?: ICreate): Promise { - const uri = getApId(note); - - if (typeof note === 'object') { - if (actor.uri !== note.attributedTo) { - return 'skip: actor.uri !== note.attributedTo'; - } - - if (typeof note.id === 'string') { - if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) { - return 'skip: host in actor.uri !== note.id'; - } - } - } - - const unlock = await this.appLockService.getApLock(uri); - - try { - const exist = await this.apNoteService.fetchNote(note); - if (exist) return 'skip: note exists'; - - await this.apNoteService.createNote(note, resolver, silent); - return 'ok'; - } catch (err) { - if (err instanceof StatusError && err.isClientError) { - return `skip ${err.statusCode}`; - } else { - throw err; - } - } finally { - unlock(); - } - } - - @bindThis - private async delete(actor: MiRemoteUser, activity: IDelete): Promise { - if (actor.uri !== activity.actor) { - throw new Error('invalid actor'); - } - - // 削除対象objectのtype - let formerType: string | undefined; - - if (typeof activity.object === 'string') { - // typeが不明だけど、どうせ消えてるのでremote resolveしない - formerType = undefined; - } else { - const object = activity.object; - if (isTombstone(object)) { - formerType = toSingle(object.formerType); - } else { - formerType = toSingle(object.type); - } - } - - const uri = getApId(activity.object); - - // type不明でもactorとobjectが同じならばそれはPersonに違いない - if (!formerType && actor.uri === uri) { - formerType = 'Person'; - } - - // それでもなかったらおそらくNote - if (!formerType) { - formerType = 'Note'; - } - - if (validPost.includes(formerType)) { - return await this.deleteNote(actor, uri); - } else if (validActor.includes(formerType)) { - return await this.deleteActor(actor, uri); - } else { - return `Unknown type ${formerType}`; - } - } - - @bindThis - private async deleteActor(actor: MiRemoteUser, uri: string): Promise { - this.logger.info(`Deleting the Actor: ${uri}`); - - if (actor.uri !== uri) { - return `skip: delete actor ${actor.uri} !== ${uri}`; - } - - const user = await this.usersRepository.findOneBy({ id: actor.id }); - if (user == null) { - return 'skip: actor not found'; - } else if (user.isDeleted) { - return 'skip: already deleted'; - } - - const job = await this.queueService.createDeleteAccountJob(actor); - - await this.usersRepository.update(actor.id, { - isDeleted: true, - }); - - return `ok: queued ${job.name} ${job.id}`; - } - - @bindThis - private async deleteNote(actor: MiRemoteUser, uri: string): Promise { - this.logger.info(`Deleting the Note: ${uri}`); - - const unlock = await this.appLockService.getApLock(uri); - - try { - const note = await this.apDbResolverService.getNoteFromApId(uri); - - if (note == null) { - return 'message not found'; - } - - if (note.userId !== actor.id) { - return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; - } - - await this.noteDeleteService.delete(actor, note); - return 'ok: note deleted'; - } finally { - unlock(); - } - } - - @bindThis - private async flag(actor: MiRemoteUser, activity: IFlag): Promise { - // objectは `(User|Note) | (User|Note)[]` だけど、全パターンDBスキーマと対応させられないので - // 対象ユーザーは一番最初のユーザー として あとはコメントとして格納する - const uris = getApIds(activity.object); - - const userIds = uris - .filter(uri => uri.startsWith(this.config.url + '/users/')) - .map(uri => uri.split('/').at(-1)) - .filter((userId): userId is string => userId !== undefined); - const users = await this.usersRepository.findBy({ - id: In(userIds), - }); - if (users.length < 1) return 'skip'; - - await this.abuseUserReportsRepository.insert({ - id: this.idService.gen(), - targetUserId: users[0].id, - targetUserHost: users[0].host, - reporterId: actor.id, - reporterHost: actor.host, - comment: `${activity.content}\n${JSON.stringify(uris, null, 2)}`, - }); - - return 'ok'; - } - - @bindThis - private async reject(actor: MiRemoteUser, activity: IReject): Promise { - const uri = activity.id ?? activity; - - this.logger.info(`Reject: ${uri}`); - - const resolver = this.apResolverService.createResolver(); - - const object = await resolver.resolve(activity.object).catch(e => { - this.logger.error(`Resolution failed: ${e}`); - throw e; - }); - - if (isFollow(object)) return await this.rejectFollow(actor, object); - - return `skip: Unknown Reject type: ${getApType(object)}`; - } - - @bindThis - private async rejectFollow(actor: MiRemoteUser, activity: IFollow): Promise { - // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある - - const follower = await this.apDbResolverService.getUserFromApId(activity.actor); - - if (follower == null) { - return 'skip: follower not found'; - } - - if (!this.userEntityService.isLocalUser(follower)) { - return 'skip: follower is not a local user'; - } - - // relay - const match = activity.id?.match(/follow-relay\/(\w+)/); - if (match) { - return await this.relayService.relayRejected(match[1]); - } - - await this.userFollowingService.remoteReject(actor, follower); - return 'ok'; - } - - @bindThis - private async remove(actor: MiRemoteUser, activity: IRemove): Promise { - if (actor.uri !== activity.actor) { - throw new Error('invalid actor'); - } - - if (activity.target == null) { - throw new Error('target is null'); - } - - if (activity.target === actor.featured) { - const note = await this.apNoteService.resolveNote(activity.object); - if (note == null) throw new Error('note not found'); - await this.notePiningService.removePinned(actor, note.id); - return; - } - - throw new Error(`unknown target: ${activity.target}`); - } - - @bindThis - private async undo(actor: MiRemoteUser, activity: IUndo): Promise { - if (actor.uri !== activity.actor) { - throw new Error('invalid actor'); - } - - const uri = activity.id ?? activity; - - this.logger.info(`Undo: ${uri}`); - - const resolver = this.apResolverService.createResolver(); - - const object = await resolver.resolve(activity.object).catch(e => { - this.logger.error(`Resolution failed: ${e}`); - throw e; - }); - - // don't queue because the sender may attempt again when timeout - if (isFollow(object)) return await this.undoFollow(actor, object); - if (isBlock(object)) return await this.undoBlock(actor, object); - if (isLike(object)) return await this.undoLike(actor, object); - if (isAnnounce(object)) return await this.undoAnnounce(actor, object); - if (isAccept(object)) return await this.undoAccept(actor, object); - - return `skip: unknown object type ${getApType(object)}`; - } - - @bindThis - private async undoAccept(actor: MiRemoteUser, activity: IAccept): Promise { - const follower = await this.apDbResolverService.getUserFromApId(activity.object); - if (follower == null) { - return 'skip: follower not found'; - } - - const isFollowing = await this.followingsRepository.exist({ - where: { - followerId: follower.id, - followeeId: actor.id, - }, - }); - - if (isFollowing) { - await this.userFollowingService.unfollow(follower, actor); - return 'ok: unfollowed'; - } - - return 'skip: フォローされていない'; - } - - @bindThis - private async undoAnnounce(actor: MiRemoteUser, activity: IAnnounce): Promise { - const uri = getApId(activity); - - const note = await this.notesRepository.findOneBy({ - uri, - userId: actor.id, - }); - - if (!note) return 'skip: no such Announce'; - - await this.noteDeleteService.delete(actor, note); - return 'ok: deleted'; - } - - @bindThis - private async undoBlock(actor: MiRemoteUser, activity: IBlock): Promise { - const blockee = await this.apDbResolverService.getUserFromApId(activity.object); - - if (blockee == null) { - return 'skip: blockee not found'; - } - - if (blockee.host != null) { - return 'skip: ブロック解除しようとしているユーザーはローカルユーザーではありません'; - } - - await this.userBlockingService.unblock(await this.usersRepository.findOneByOrFail({ id: actor.id }), blockee); - return 'ok'; - } - - @bindThis - private async undoFollow(actor: MiRemoteUser, activity: IFollow): Promise { - const followee = await this.apDbResolverService.getUserFromApId(activity.object); - if (followee == null) { - return 'skip: followee not found'; - } - - if (followee.host != null) { - return 'skip: フォロー解除しようとしているユーザーはローカルユーザーではありません'; - } - - const requestExist = await this.followRequestsRepository.exist({ - where: { - followerId: actor.id, - followeeId: followee.id, - }, - }); - - const isFollowing = await this.followingsRepository.exist({ - where: { - followerId: actor.id, - followeeId: followee.id, - }, - }); - - if (requestExist) { - await this.userFollowingService.cancelFollowRequest(followee, actor); - return 'ok: follow request canceled'; - } - - if (isFollowing) { - await this.userFollowingService.unfollow(actor, followee); - return 'ok: unfollowed'; - } - - return 'skip: リクエストもフォローもされていない'; - } - - @bindThis - private async undoLike(actor: MiRemoteUser, activity: ILike): Promise { - const targetUri = getApId(activity.object); - - const note = await this.apNoteService.fetchNote(targetUri); - if (!note) return `skip: target note not found ${targetUri}`; - - await this.reactionService.delete(actor, note).catch(e => { - if (e.id === '60527ec9-b4cb-4a88-a6bd-32d3ad26817d') return; - throw e; - }); - - return 'ok'; - } - - @bindThis - private async update(actor: MiRemoteUser, activity: IUpdate): Promise { - if (actor.uri !== activity.actor) { - return 'skip: invalid actor'; - } - - this.logger.debug('Update'); - - const resolver = this.apResolverService.createResolver(); - - const object = await resolver.resolve(activity.object).catch(e => { - this.logger.error(`Resolution failed: ${e}`); - throw e; - }); - - if (isActor(object)) { - await this.apPersonService.updatePerson(actor.uri, resolver, object); - return 'ok: Person updated'; - } else if (getApType(object) === 'Question') { - await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err)); - return 'ok: Question updated'; - } else if (getApType(object) === 'Note') { - await this.apNoteService.updateNote(object, resolver).catch(err => console.error(err)); - return 'ok: Note updated'; - } else { - return `skip: Unknown type: ${getApType(object)}`; - } - } - - @bindThis - private async move(actor: MiRemoteUser, activity: IMove): Promise { - // fetch the new and old accounts - const targetUri = getApHrefNullable(activity.target); - if (!targetUri) return 'skip: invalid activity target'; - - return await this.apPersonService.updatePerson(actor.uri) ?? 'skip: nothing to do'; - } -} diff --git a/packages/backend/src/core/activitypub/ApLoggerService.ts b/packages/backend/src/core/activitypub/ApLoggerService.ts deleted file mode 100644 index cd9597e423..0000000000 --- a/packages/backend/src/core/activitypub/ApLoggerService.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import type Logger from '@/logger.js'; -import { RemoteLoggerService } from '@/core/RemoteLoggerService.js'; - -@Injectable() -export class ApLoggerService { - public logger: Logger; - - constructor( - private remoteLoggerService: RemoteLoggerService, - ) { - this.logger = this.remoteLoggerService.logger.createSubLogger('ap', 'magenta'); - } -} diff --git a/packages/backend/src/core/activitypub/ApMfmService.ts b/packages/backend/src/core/activitypub/ApMfmService.ts deleted file mode 100644 index c19eb310d2..0000000000 --- a/packages/backend/src/core/activitypub/ApMfmService.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import * as mfm from '@sharkey/sfm-js'; -import { MfmService } from '@/core/MfmService.js'; -import type { MiNote } from '@/models/Note.js'; -import { bindThis } from '@/decorators.js'; -import { extractApHashtagObjects } from './models/tag.js'; -import type { IObject } from './type.js'; - -@Injectable() -export class ApMfmService { - constructor( - private mfmService: MfmService, - ) { - } - - @bindThis - public htmlToMfm(html: string, tag?: IObject | IObject[]): string { - const hashtagNames = extractApHashtagObjects(tag).map(x => x.name); - return this.mfmService.fromHtml(html, hashtagNames); - } - - @bindThis - public getNoteHtml(note: MiNote): string | null { - if (!note.text) return ''; - return this.mfmService.toHtml(mfm.parse(note.text), note.mentionedRemoteUsers ? JSON.parse(note.mentionedRemoteUsers) : []); - } -} diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts deleted file mode 100644 index f4d39d2408..0000000000 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ /dev/null @@ -1,905 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { createPublicKey, randomUUID } from 'node:crypto'; -import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; -import * as mfm from '@sharkey/sfm-js'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import type { MiPartialLocalUser, MiLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js'; -import type { IMentionedRemoteUsers, MiNote } from '@/models/Note.js'; -import type { MiBlocking } from '@/models/Blocking.js'; -import type { MiRelay } from '@/models/Relay.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiNoteReaction } from '@/models/NoteReaction.js'; -import type { MiEmoji } from '@/models/Emoji.js'; -import type { MiPoll } from '@/models/Poll.js'; -import type { MiPollVote } from '@/models/PollVote.js'; -import { UserKeypairService } from '@/core/UserKeypairService.js'; -import { MfmService } from '@/core/MfmService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import type { MiUserKeypair } from '@/models/UserKeypair.js'; -import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, PollsRepository, InstancesRepository } from '@/models/_.js'; -import { bindThis } from '@/decorators.js'; -import { CustomEmojiService } from '@/core/CustomEmojiService.js'; -import { isNotNull } from '@/misc/is-not-null.js'; -import { IdService } from '@/core/IdService.js'; -import { LdSignatureService } from './LdSignatureService.js'; -import { ApMfmService } from './ApMfmService.js'; -import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; -import { MetaService } from '../MetaService.js'; - -@Injectable() -export class ApRendererService { - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, - - private customEmojiService: CustomEmojiService, - private userEntityService: UserEntityService, - private driveFileEntityService: DriveFileEntityService, - private ldSignatureService: LdSignatureService, - private userKeypairService: UserKeypairService, - private apMfmService: ApMfmService, - private mfmService: MfmService, - private idService: IdService, - private metaService: MetaService, - ) { - } - - @bindThis - public renderAccept(object: string | IObject, user: { id: MiUser['id']; host: null }): IAccept { - return { - type: 'Accept', - actor: this.userEntityService.genLocalUserUri(user.id), - object, - }; - } - - @bindThis - public renderAdd(user: MiLocalUser, target: string | IObject | undefined, object: string | IObject): IAdd { - return { - type: 'Add', - actor: this.userEntityService.genLocalUserUri(user.id), - target, - object, - }; - } - - @bindThis - public renderAnnounce(object: string | IObject, note: MiNote): IAnnounce { - const attributedTo = this.userEntityService.genLocalUserUri(note.userId); - - let to: string[] = []; - let cc: string[] = []; - - if (note.visibility === 'public') { - to = ['https://www.w3.org/ns/activitystreams#Public']; - cc = [`${attributedTo}/followers`]; - } else if (note.visibility === 'home') { - to = [`${attributedTo}/followers`]; - cc = ['https://www.w3.org/ns/activitystreams#Public']; - } else if (note.visibility === 'followers') { - to = [`${attributedTo}/followers`]; - cc = []; - } else { - throw new Error('renderAnnounce: cannot render non-public note'); - } - - return { - id: `${this.config.url}/notes/${note.id}/activity`, - actor: this.userEntityService.genLocalUserUri(note.userId), - type: 'Announce', - published: this.idService.parse(note.id).date.toISOString(), - to, - cc, - object, - }; - } - - /** - * Renders a block into its ActivityPub representation. - * - * @param block The block to be rendered. The blockee relation must be loaded. - */ - @bindThis - public renderBlock(block: MiBlocking): IBlock { - if (block.blockee?.uri == null) { - throw new Error('renderBlock: missing blockee uri'); - } - - return { - type: 'Block', - id: `${this.config.url}/blocks/${block.id}`, - actor: this.userEntityService.genLocalUserUri(block.blockerId), - object: block.blockee.uri, - }; - } - - @bindThis - public renderCreate(object: IObject, note: MiNote): ICreate { - const activity: ICreate = { - id: `${this.config.url}/notes/${note.id}/activity`, - actor: this.userEntityService.genLocalUserUri(note.userId), - type: 'Create', - published: this.idService.parse(note.id).date.toISOString(), - object, - }; - - if (object.to) activity.to = object.to; - if (object.cc) activity.cc = object.cc; - - return activity; - } - - @bindThis - public renderDelete(object: IObject | string, user: { id: MiUser['id']; host: null }): IDelete { - return { - type: 'Delete', - actor: this.userEntityService.genLocalUserUri(user.id), - object, - published: new Date().toISOString(), - }; - } - - @bindThis - public renderDocument(file: MiDriveFile): IApDocument { - return { - type: 'Document', - mediaType: file.webpublicType ?? file.type, - url: this.driveFileEntityService.getPublicUrl(file), - name: file.comment, - summary: file.comment, - }; - } - - @bindThis - public renderEmoji(emoji: MiEmoji): IApEmoji { - return { - id: `${this.config.url}/emojis/${emoji.name}`, - type: 'Emoji', - name: `:${emoji.name}:`, - updated: emoji.updatedAt != null ? emoji.updatedAt.toISOString() : new Date().toISOString(), - icon: { - type: 'Image', - mediaType: emoji.type ?? 'image/png', - // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - url: emoji.publicUrl || emoji.originalUrl, - }, - }; - } - - // to anonymise reporters, the reporting actor must be a system user - @bindThis - public renderFlag(user: MiLocalUser, object: IObject | string, content: string): IFlag { - return { - type: 'Flag', - actor: this.userEntityService.genLocalUserUri(user.id), - content, - object, - }; - } - - @bindThis - public renderFollowRelay(relay: MiRelay, relayActor: MiLocalUser): IFollow { - return { - id: `${this.config.url}/activities/follow-relay/${relay.id}`, - type: 'Follow', - actor: this.userEntityService.genLocalUserUri(relayActor.id), - object: 'https://www.w3.org/ns/activitystreams#Public', - }; - } - - /** - * Convert (local|remote)(Follower|Followee)ID to URL - * @param id Follower|Followee ID - */ - @bindThis - public async renderFollowUser(id: MiUser['id']): Promise { - const user = await this.usersRepository.findOneByOrFail({ id: id }) as MiPartialLocalUser | MiPartialRemoteUser; - return this.userEntityService.getUserUri(user); - } - - @bindThis - public renderFollow( - follower: MiPartialLocalUser | MiPartialRemoteUser, - followee: MiPartialLocalUser | MiPartialRemoteUser, - requestId?: string, - ): IFollow { - return { - id: requestId ?? `${this.config.url}/follows/${follower.id}/${followee.id}`, - type: 'Follow', - actor: this.userEntityService.getUserUri(follower), - object: this.userEntityService.getUserUri(followee), - }; - } - - @bindThis - public renderHashtag(tag: string): IApHashtag { - return { - type: 'Hashtag', - href: `${this.config.url}/tags/${encodeURIComponent(tag)}`, - name: `#${tag}`, - }; - } - - @bindThis - public renderImage(file: MiDriveFile): IApImage { - return { - type: 'Image', - url: this.driveFileEntityService.getPublicUrl(file), - sensitive: file.isSensitive, - name: file.comment, - }; - } - - @bindThis - public renderKey(user: MiLocalUser, key: MiUserKeypair, postfix?: string): IKey { - return { - id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`, - type: 'Key', - owner: this.userEntityService.genLocalUserUri(user.id), - publicKeyPem: createPublicKey(key.publicKey).export({ - type: 'spki', - format: 'pem', - }), - }; - } - - @bindThis - public async renderLike(noteReaction: MiNoteReaction, note: { uri: string | null }): Promise { - const reaction = noteReaction.reaction; - const meta = await this.metaService.fetch(true); - let isMastodon = false; - - if (meta.defaultLike && reaction.replaceAll(':', '') === meta.defaultLike.replaceAll(':', '')) { - const note = await this.notesRepository.findOneBy({ id: noteReaction.noteId }); - - if (note && note.userHost) { - const instance = await this.instancesRepository.findOneBy({ host: note.userHost }); - - if (instance && instance.softwareName === 'mastodon') isMastodon = true; - if (instance && instance.softwareName === 'akkoma') isMastodon = true; - if (instance && instance.softwareName === 'pleroma') isMastodon = true; - } - } - - const object: ILike = { - type: 'Like', - id: `${this.config.url}/likes/${noteReaction.id}`, - actor: `${this.config.url}/users/${noteReaction.userId}`, - object: note.uri ? note.uri : `${this.config.url}/notes/${noteReaction.noteId}`, - content: isMastodon ? undefined : reaction, - _misskey_reaction: isMastodon ? undefined : reaction, - }; - - if (reaction.startsWith(':')) { - const name = reaction.replaceAll(':', ''); - const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name); - - if (emoji && !emoji.localOnly) object.tag = [this.renderEmoji(emoji)]; - } - - return object; - } - - @bindThis - public renderMention(mention: MiPartialLocalUser | MiPartialRemoteUser): IApMention { - return { - type: 'Mention', - href: this.userEntityService.getUserUri(mention), - name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as MiLocalUser).username}`, - }; - } - - @bindThis - public renderMove( - src: MiPartialLocalUser | MiPartialRemoteUser, - dst: MiPartialLocalUser | MiPartialRemoteUser, - ): IMove { - const actor = this.userEntityService.getUserUri(src); - const target = this.userEntityService.getUserUri(dst); - return { - id: `${this.config.url}/moves/${src.id}/${dst.id}`, - actor, - type: 'Move', - object: actor, - target, - }; - } - - @bindThis - public async renderNote(note: MiNote, dive = true): Promise { - const getPromisedFiles = async (ids: string[]): Promise => { - if (ids.length === 0) return []; - const items = await this.driveFilesRepository.findBy({ id: In(ids) }); - return ids.map(id => items.find(item => item.id === id)).filter((item): item is MiDriveFile => item != null); - }; - - let inReplyTo; - let inReplyToNote: MiNote | null; - - if (note.replyId) { - inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); - - if (inReplyToNote != null) { - const inReplyToUserExist = await this.usersRepository.exist({ where: { id: inReplyToNote.userId } }); - - if (inReplyToUserExist) { - if (inReplyToNote.uri) { - inReplyTo = inReplyToNote.uri; - } else { - if (dive) { - inReplyTo = await this.renderNote(inReplyToNote, false); - } else { - inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`; - } - } - } - } - } else { - inReplyTo = null; - } - - let quote; - - if (note.renoteId) { - const renote = await this.notesRepository.findOneBy({ id: note.renoteId }); - - if (renote) { - quote = renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`; - } - } - - const attributedTo = this.userEntityService.genLocalUserUri(note.userId); - - const mentions = note.mentionedRemoteUsers ? (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri) : []; - - let to: string[] = []; - let cc: string[] = []; - - if (note.visibility === 'public') { - to = ['https://www.w3.org/ns/activitystreams#Public']; - cc = [`${attributedTo}/followers`].concat(mentions); - } else if (note.visibility === 'home') { - to = [`${attributedTo}/followers`]; - cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions); - } else if (note.visibility === 'followers') { - to = [`${attributedTo}/followers`]; - cc = mentions; - } else { - to = mentions; - } - - const mentionedUsers = note.mentions && note.mentions.length > 0 ? await this.usersRepository.findBy({ - id: In(note.mentions), - }) : []; - - const hashtagTags = note.tags.map(tag => this.renderHashtag(tag)); - const mentionTags = mentionedUsers.map(u => this.renderMention(u as MiLocalUser | MiRemoteUser)); - - const files = await getPromisedFiles(note.fileIds); - - const text = note.text ?? ''; - let poll: MiPoll | null = null; - - if (note.hasPoll) { - poll = await this.pollsRepository.findOneBy({ noteId: note.id }); - } - - let apText = text; - - if (quote) { - apText += `\n\nRE: ${quote}`; - } - - const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; - - const content = this.apMfmService.getNoteHtml(Object.assign({}, note, { - text: apText, - })); - - const emojis = await this.getEmojis(note.emojis); - const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji)); - - const tag = [ - ...hashtagTags, - ...mentionTags, - ...apemojis, - ]; - - const asPoll = poll ? { - type: 'Question', - content: this.apMfmService.getNoteHtml(Object.assign({}, note, { - text: text, - })), - [poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt, - [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ - type: 'Note', - name: text, - replies: { - type: 'Collection', - totalItems: poll!.votes[i], - }, - })), - } as const : {}; - - return { - id: `${this.config.url}/notes/${note.id}`, - type: 'Note', - attributedTo, - summary: summary ?? undefined, - content: content ?? undefined, - _misskey_content: text, - source: { - content: text, - mediaType: 'text/x.misskeymarkdown', - }, - _misskey_quote: quote, - quoteUrl: quote, - quoteUri: quote, - published: this.idService.parse(note.id).date.toISOString(), - to, - cc, - inReplyTo, - attachment: files.map(x => this.renderDocument(x)), - sensitive: note.cw != null || files.some(file => file.isSensitive), - tag, - ...asPoll, - }; - } - - @bindThis - public async renderPerson(user: MiLocalUser) { - const id = this.userEntityService.genLocalUserUri(user.id); - const isSystem = user.username.includes('.'); - - const [avatar, banner, background, profile] = await Promise.all([ - user.avatarId ? this.driveFilesRepository.findOneBy({ id: user.avatarId }) : undefined, - user.bannerId ? this.driveFilesRepository.findOneBy({ id: user.bannerId }) : undefined, - user.backgroundId ? this.driveFilesRepository.findOneBy({ id: user.backgroundId }) : undefined, - this.userProfilesRepository.findOneByOrFail({ userId: user.id }), - ]); - - const attachment = profile.fields.map(field => ({ - type: 'PropertyValue', - name: field.name, - value: (field.value.startsWith('http://') || field.value.startsWith('https://')) - ? `${new URL(field.value).href}` - : field.value, - })); - - const emojis = await this.getEmojis(user.emojis); - const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji)); - - const hashtagTags = user.tags.map(tag => this.renderHashtag(tag)); - - const tag = [ - ...apemojis, - ...hashtagTags, - ]; - - const keypair = await this.userKeypairService.getUserKeypair(user.id); - - const person: any = { - type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person', - id, - inbox: `${id}/inbox`, - outbox: `${id}/outbox`, - followers: `${id}/followers`, - following: `${id}/following`, - featured: `${id}/collections/featured`, - sharedInbox: `${this.config.url}/inbox`, - endpoints: { sharedInbox: `${this.config.url}/inbox` }, - url: `${this.config.url}/@${user.username}`, - preferredUsername: user.username, - name: user.name, - summary: profile.description ? this.mfmService.toHtml(mfm.parse(profile.description)) : null, - _misskey_summary: profile.description, - icon: avatar ? this.renderImage(avatar) : null, - image: banner ? this.renderImage(banner) : null, - backgroundUrl: background ? this.renderImage(background) : null, - tag, - manuallyApprovesFollowers: user.isLocked, - discoverable: user.isExplorable, - publicKey: this.renderKey(user, keypair, '#main-key'), - isCat: user.isCat, - noindex: user.noindex, - speakAsCat: user.speakAsCat, - attachment: attachment.length ? attachment : undefined, - }; - - if (user.movedToUri) { - person.movedTo = user.movedToUri; - } - - if (user.alsoKnownAs) { - person.alsoKnownAs = user.alsoKnownAs; - } - - if (profile.birthday) { - person['vcard:bday'] = profile.birthday; - } - - if (profile.location) { - person['vcard:Address'] = profile.location; - } - - if (profile.listenbrainz) { - person.listenbrainz = profile.listenbrainz; - } - - return person; - } - - @bindThis - public renderQuestion(user: { id: MiUser['id'] }, note: MiNote, poll: MiPoll): IQuestion { - return { - type: 'Question', - id: `${this.config.url}/questions/${note.id}`, - actor: this.userEntityService.genLocalUserUri(user.id), - content: note.text ?? '', - [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ - name: text, - _misskey_votes: poll.votes[i], - replies: { - type: 'Collection', - totalItems: poll.votes[i], - }, - })), - }; - } - - @bindThis - public renderReject(object: string | IObject, user: { id: MiUser['id'] }): IReject { - return { - type: 'Reject', - actor: this.userEntityService.genLocalUserUri(user.id), - object, - }; - } - - @bindThis - public renderRemove(user: { id: MiUser['id'] }, target: string | IObject | undefined, object: string | IObject): IRemove { - return { - type: 'Remove', - actor: this.userEntityService.genLocalUserUri(user.id), - target, - object, - }; - } - - @bindThis - public renderTombstone(id: string): ITombstone { - return { - id, - type: 'Tombstone', - }; - } - - @bindThis - public renderUndo(object: string | IObject, user: { id: MiUser['id'] }): IUndo { - const id = typeof object !== 'string' && typeof object.id === 'string' && object.id.startsWith(this.config.url) ? `${object.id}/undo` : undefined; - - return { - type: 'Undo', - ...(id ? { id } : {}), - actor: this.userEntityService.genLocalUserUri(user.id), - object, - published: new Date().toISOString(), - }; - } - - @bindThis - public renderUpdate(object: string | IObject, user: { id: MiUser['id'] }): IUpdate { - return { - id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`, - actor: this.userEntityService.genLocalUserUri(user.id), - type: 'Update', - to: ['https://www.w3.org/ns/activitystreams#Public'], - object, - published: new Date().toISOString(), - }; - } - - @bindThis - public async renderUpNote(note: MiNote, dive = true): Promise { - const getPromisedFiles = async (ids: string[]): Promise => { - if (ids.length === 0) return []; - const items = await this.driveFilesRepository.findBy({ id: In(ids) }); - return ids.map(id => items.find(item => item.id === id)).filter((item): item is MiDriveFile => item != null); - }; - - let inReplyTo; - let inReplyToNote: MiNote | null; - - if (note.replyId) { - inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); - - if (inReplyToNote != null) { - const inReplyToUserExist = await this.usersRepository.exist({ where: { id: inReplyToNote.userId } }); - - if (inReplyToUserExist) { - if (inReplyToNote.uri) { - inReplyTo = inReplyToNote.uri; - } else { - if (dive) { - inReplyTo = await this.renderUpNote(inReplyToNote, false); - } else { - inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`; - } - } - } - } - } else { - inReplyTo = null; - } - - let quote; - - if (note.renoteId) { - const renote = await this.notesRepository.findOneBy({ id: note.renoteId }); - - if (renote) { - quote = renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`; - } - } - - const attributedTo = this.userEntityService.genLocalUserUri(note.userId); - - const mentions = note.mentionedRemoteUsers ? (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri) : []; - - let to: string[] = []; - let cc: string[] = []; - - if (note.visibility === 'public') { - to = ['https://www.w3.org/ns/activitystreams#Public']; - cc = [`${attributedTo}/followers`].concat(mentions); - } else if (note.visibility === 'home') { - to = [`${attributedTo}/followers`]; - cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions); - } else if (note.visibility === 'followers') { - to = [`${attributedTo}/followers`]; - cc = mentions; - } else { - to = mentions; - } - - const mentionedUsers = note.mentions && note.mentions.length > 0 ? await this.usersRepository.findBy({ - id: In(note.mentions), - }) : []; - - const hashtagTags = note.tags.map(tag => this.renderHashtag(tag)); - const mentionTags = mentionedUsers.map(u => this.renderMention(u as MiLocalUser | MiRemoteUser)); - - const files = await getPromisedFiles(note.fileIds); - - const text = note.text ?? ''; - let poll: MiPoll | null = null; - - if (note.hasPoll) { - poll = await this.pollsRepository.findOneBy({ noteId: note.id }); - } - - let apText = text; - - if (quote) { - apText += `\n\nRE: ${quote}`; - } - - const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; - - const content = this.apMfmService.getNoteHtml(Object.assign({}, note, { - text: apText, - })); - - const emojis = await this.getEmojis(note.emojis); - const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji)); - - const tag = [ - ...hashtagTags, - ...mentionTags, - ...apemojis, - ]; - - const asPoll = poll ? { - type: 'Question', - content: this.apMfmService.getNoteHtml(Object.assign({}, note, { - text: text, - })), - [poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt, - [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ - type: 'Note', - name: text, - replies: { - type: 'Collection', - totalItems: poll!.votes[i], - }, - })), - } as const : {}; - - return { - id: `${this.config.url}/notes/${note.id}`, - type: 'Note', - attributedTo, - summary: summary ?? undefined, - content: content ?? undefined, - updated: note.updatedAt?.toISOString(), - _misskey_content: text, - source: { - content: text, - mediaType: 'text/x.misskeymarkdown', - }, - _misskey_quote: quote, - quoteUrl: quote, - quoteUri: quote, - published: this.idService.parse(note.id).date.toISOString(), - to, - cc, - inReplyTo, - attachment: files.map(x => this.renderDocument(x)), - sensitive: note.cw != null || files.some(file => file.isSensitive), - tag, - ...asPoll, - }; - } - - @bindThis - public renderVote(user: { id: MiUser['id'] }, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate { - return { - id: `${this.config.url}/users/${user.id}#votes/${vote.id}/activity`, - actor: this.userEntityService.genLocalUserUri(user.id), - type: 'Create', - to: [pollOwner.uri], - published: new Date().toISOString(), - object: { - id: `${this.config.url}/users/${user.id}#votes/${vote.id}`, - type: 'Note', - attributedTo: this.userEntityService.genLocalUserUri(user.id), - to: [pollOwner.uri], - inReplyTo: note.uri, - name: poll.choices[vote.choice], - }, - }; - } - - @bindThis - public addContext(x: T): T & { '@context': any; id: string; } { - if (typeof x === 'object' && x.id == null) { - x.id = `${this.config.url}/${randomUUID()}`; - } - - return Object.assign({ - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', - { - // as non-standards - manuallyApprovesFollowers: 'as:manuallyApprovesFollowers', - sensitive: 'as:sensitive', - Hashtag: 'as:Hashtag', - quoteUrl: 'as:quoteUrl', - fedibird: 'http://fedibird.com/ns#', - quoteUri: 'fedibird:quoteUri', - // Mastodon - toot: 'http://joinmastodon.org/ns#', - Emoji: 'toot:Emoji', - featured: 'toot:featured', - discoverable: 'toot:discoverable', - // schema - schema: 'http://schema.org#', - PropertyValue: 'schema:PropertyValue', - value: 'schema:value', - // Misskey - misskey: 'https://misskey-hub.net/ns#', - '_misskey_content': 'misskey:_misskey_content', - '_misskey_quote': 'misskey:_misskey_quote', - '_misskey_reaction': 'misskey:_misskey_reaction', - '_misskey_votes': 'misskey:_misskey_votes', - '_misskey_summary': 'misskey:_misskey_summary', - 'isCat': 'misskey:isCat', - // Firefish - firefish: "https://joinfirefish.org/ns#", - speakAsCat: "firefish:speakAsCat", - // Sharkey - sharkey: "https://joinsharkey.org/ns#", - backgroundUrl: "sharkey:backgroundUrl", - listenbrainz: "sharkey:listenbrainz", - // vcard - vcard: 'http://www.w3.org/2006/vcard/ns#', - }, - ], - }, x as T & { id: string }); - } - - @bindThis - public async attachLdSignature(activity: any, user: { id: MiUser['id']; host: null; }): Promise { - const keypair = await this.userKeypairService.getUserKeypair(user.id); - - const ldSignature = this.ldSignatureService.use(); - ldSignature.debug = false; - activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); - - return activity; - } - - /** - * Render OrderedCollectionPage - * @param id URL of self - * @param totalItems Number of total items - * @param orderedItems Items - * @param partOf URL of base - * @param prev URL of prev page (optional) - * @param next URL of next page (optional) - */ - @bindThis - public renderOrderedCollectionPage(id: string, totalItems: any, orderedItems: any, partOf: string, prev?: string, next?: string) { - const page: any = { - id, - partOf, - type: 'OrderedCollectionPage', - totalItems, - orderedItems, - }; - - if (prev) page.prev = prev; - if (next) page.next = next; - - return page; - } - - /** - * Render OrderedCollection - * @param id URL of self - * @param totalItems Total number of items - * @param first URL of first page (optional) - * @param last URL of last page (optional) - * @param orderedItems attached objects (optional) - */ - @bindThis - public renderOrderedCollection(id: string, totalItems: number, first?: string, last?: string, orderedItems?: IObject[]) { - const page: any = { - id, - type: 'OrderedCollection', - totalItems, - }; - - if (first) page.first = first; - if (last) page.last = last; - if (orderedItems) page.orderedItems = orderedItems; - - return page; - } - - @bindThis - private async getEmojis(names: string[]): Promise { - if (names.length === 0) return []; - - const allEmojis = await this.customEmojiService.localEmojisCache.fetch(); - const emojis = names.map(name => allEmojis.get(name)).filter(isNotNull); - - return emojis; - } -} diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts deleted file mode 100644 index bd7b9bdf09..0000000000 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ /dev/null @@ -1,201 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as crypto from 'node:crypto'; -import { URL } from 'node:url'; -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import type { MiUser } from '@/models/User.js'; -import { UserKeypairService } from '@/core/UserKeypairService.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { LoggerService } from '@/core/LoggerService.js'; -import { bindThis } from '@/decorators.js'; -import type Logger from '@/logger.js'; -import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; - -type Request = { - url: string; - method: string; - headers: Record; -}; - -type Signed = { - request: Request; - signingString: string; - signature: string; - signatureHeader: string; -}; - -type PrivateKey = { - privateKeyPem: string; - keyId: string; -}; - -export class ApRequestCreator { - static createSignedPost(args: { key: PrivateKey, url: string, body: string, additionalHeaders: Record }): Signed { - const u = new URL(args.url); - const digestHeader = `SHA-256=${crypto.createHash('sha256').update(args.body).digest('base64')}`; - - const request: Request = { - url: u.href, - method: 'POST', - headers: this.#objectAssignWithLcKey({ - 'Date': new Date().toUTCString(), - 'Host': u.host, - 'Content-Type': 'application/activity+json', - 'Digest': digestHeader, - }, args.additionalHeaders), - }; - - const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']); - - return { - request, - signingString: result.signingString, - signature: result.signature, - signatureHeader: result.signatureHeader, - }; - } - - static createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record }): Signed { - const u = new URL(args.url); - - const request: Request = { - url: u.href, - method: 'GET', - headers: this.#objectAssignWithLcKey({ - 'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"', - 'Date': new Date().toUTCString(), - 'Host': new URL(args.url).host, - }, args.additionalHeaders), - }; - - const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']); - - return { - request, - signingString: result.signingString, - signature: result.signature, - signatureHeader: result.signatureHeader, - }; - } - - static #signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed { - const signingString = this.#genSigningString(request, includeHeaders); - const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64'); - const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`; - - request.headers = this.#objectAssignWithLcKey(request.headers, { - Signature: signatureHeader, - }); - // node-fetch will generate this for us. if we keep 'Host', it won't change with redirects! - delete request.headers['host']; - - return { - request, - signingString, - signature, - signatureHeader, - }; - } - - static #genSigningString(request: Request, includeHeaders: string[]): string { - request.headers = this.#lcObjectKey(request.headers); - - const results: string[] = []; - - for (const key of includeHeaders.map(x => x.toLowerCase())) { - if (key === '(request-target)') { - results.push(`(request-target): ${request.method.toLowerCase()} ${new URL(request.url).pathname}`); - } else { - results.push(`${key}: ${request.headers[key]}`); - } - } - - return results.join('\n'); - } - - static #lcObjectKey(src: Record): Record { - const dst: Record = {}; - for (const key of Object.keys(src).filter(x => x !== '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key]; - return dst; - } - - static #objectAssignWithLcKey(a: Record, b: Record): Record { - return Object.assign(this.#lcObjectKey(a), this.#lcObjectKey(b)); - } -} - -@Injectable() -export class ApRequestService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - private userKeypairService: UserKeypairService, - private httpRequestService: HttpRequestService, - private loggerService: LoggerService, - ) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる - } - - @bindThis - public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown): Promise { - const body = JSON.stringify(object); - - const keypair = await this.userKeypairService.getUserKeypair(user.id); - - const req = ApRequestCreator.createSignedPost({ - key: { - privateKeyPem: keypair.privateKey, - keyId: `${this.config.url}/users/${user.id}#main-key`, - }, - url, - body, - additionalHeaders: { - }, - }); - - await this.httpRequestService.send(url, { - method: req.request.method, - headers: req.request.headers, - body, - }); - } - - /** - * Get AP object with http-signature - * @param user http-signature user - * @param url URL to fetch - */ - @bindThis - public async signedGet(url: string, user: { id: MiUser['id'] }): Promise { - const keypair = await this.userKeypairService.getUserKeypair(user.id); - - const req = ApRequestCreator.createSignedGet({ - key: { - privateKeyPem: keypair.privateKey, - keyId: `${this.config.url}/users/${user.id}#main-key`, - }, - url, - additionalHeaders: { - }, - }); - - const res = await this.httpRequestService.send(url, { - method: req.request.method, - headers: req.request.headers, - }, { - throwErrorWhenResponseNotOk: true, - validators: [validateContentTypeSetAsActivityPub], - }); - - return await res.json(); - } -} diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts deleted file mode 100644 index 1e21647f5d..0000000000 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ /dev/null @@ -1,230 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, Not } from 'typeorm'; -import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; -import { InstanceActorService } from '@/core/InstanceActorService.js'; -import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository } from '@/models/_.js'; -import type { Config } from '@/config.js'; -import { MetaService } from '@/core/MetaService.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { DI } from '@/di-symbols.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; -import { LoggerService } from '@/core/LoggerService.js'; -import type Logger from '@/logger.js'; -import { isCollectionOrOrderedCollection } from './type.js'; -import { ApDbResolverService } from './ApDbResolverService.js'; -import { ApRendererService } from './ApRendererService.js'; -import { ApRequestService } from './ApRequestService.js'; -import type { IObject, ICollection, IOrderedCollection } from './type.js'; - -export class Resolver { - private history: Set; - private user?: MiLocalUser; - private logger: Logger; - - constructor( - private config: Config, - private usersRepository: UsersRepository, - private notesRepository: NotesRepository, - private pollsRepository: PollsRepository, - private noteReactionsRepository: NoteReactionsRepository, - private followRequestsRepository: FollowRequestsRepository, - private utilityService: UtilityService, - private instanceActorService: InstanceActorService, - private metaService: MetaService, - private apRequestService: ApRequestService, - private httpRequestService: HttpRequestService, - private apRendererService: ApRendererService, - private apDbResolverService: ApDbResolverService, - private loggerService: LoggerService, - private recursionLimit = 100, - ) { - this.history = new Set(); - this.logger = this.loggerService.getLogger('ap-resolve'); - } - - @bindThis - public getHistory(): string[] { - return Array.from(this.history); - } - - @bindThis - public async resolveCollection(value: string | IObject): Promise { - const collection = typeof value === 'string' - ? await this.resolve(value) - : value; - - if (isCollectionOrOrderedCollection(collection)) { - return collection; - } else { - throw new Error(`unrecognized collection type: ${collection.type}`); - } - } - - @bindThis - public async resolve(value: string | IObject): Promise { - if (typeof value !== 'string') { - return value; - } - - if (value.includes('#')) { - // URLs with fragment parts cannot be resolved correctly because - // the fragment part does not get transmitted over HTTP(S). - // Avoid strange behaviour by not trying to resolve these at all. - throw new Error(`cannot resolve URL with fragment: ${value}`); - } - - if (this.history.has(value)) { - throw new Error('cannot resolve already resolved one'); - } - - if (this.history.size > this.recursionLimit) { - throw new Error(`hit recursion limit: ${this.utilityService.extractDbHost(value)}`); - } - - this.history.add(value); - - const host = this.utilityService.extractDbHost(value); - if (this.utilityService.isSelfHost(host)) { - return await this.resolveLocal(value); - } - - const meta = await this.metaService.fetch(); - if (this.utilityService.isBlockedHost(meta.blockedHosts, host)) { - throw new Error('Instance is blocked'); - } - - if (meta.allowlistMode && !this.utilityService.isAllowedHost(meta.allowedHosts, host)) { - throw new Error('Instance is blocked'); - } - - if (this.config.signToActivityPubGet && !this.user) { - this.user = await this.instanceActorService.getInstanceActor(); - } - - const object = (this.user - ? await this.apRequestService.signedGet(value, this.user) as IObject - : await this.httpRequestService.getActivityJson(value)) as IObject; - - if ( - Array.isArray(object['@context']) ? - !(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') : - object['@context'] !== 'https://www.w3.org/ns/activitystreams' - ) { - throw new Error('invalid response'); - } - - return object; - } - - @bindThis - private resolveLocal(url: string): Promise { - const parsed = this.apDbResolverService.parseUri(url); - if (!parsed.local) throw new Error('resolveLocal: not local'); - - switch (parsed.type) { - case 'notes': - return this.notesRepository.findOneByOrFail({ id: parsed.id }) - .then(async note => { - if (parsed.rest === 'activity') { - // this refers to the create activity and not the note itself - return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note), note)); - } else { - return this.apRendererService.renderNote(note); - } - }); - case 'users': - return this.usersRepository.findOneByOrFail({ id: parsed.id }) - .then(user => this.apRendererService.renderPerson(user as MiLocalUser)); - case 'questions': - // Polls are indexed by the note they are attached to. - return Promise.all([ - this.notesRepository.findOneByOrFail({ id: parsed.id }), - this.pollsRepository.findOneByOrFail({ noteId: parsed.id }), - ]) - .then(([note, poll]) => this.apRendererService.renderQuestion({ id: note.userId }, note, poll)); - case 'likes': - return this.noteReactionsRepository.findOneByOrFail({ id: parsed.id }).then(async reaction => - this.apRendererService.addContext(await this.apRendererService.renderLike(reaction, { uri: null }))); - case 'follows': - return this.followRequestsRepository.findOneBy({ id: parsed.id }) - .then(async followRequest => { - if (followRequest == null) throw new Error('resolveLocal: invalid follow request ID'); - const [follower, followee] = await Promise.all([ - this.usersRepository.findOneBy({ - id: followRequest.followerId, - host: IsNull(), - }), - this.usersRepository.findOneBy({ - id: followRequest.followeeId, - host: Not(IsNull()), - }), - ]); - if (follower == null || followee == null) { - throw new Error('resolveLocal: follower or followee does not exist'); - } - return this.apRendererService.addContext(this.apRendererService.renderFollow(follower as MiLocalUser | MiRemoteUser, followee as MiLocalUser | MiRemoteUser, url)); - }); - default: - throw new Error(`resolveLocal: type ${parsed.type} unhandled`); - } - } -} - -@Injectable() -export class ApResolverService { - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - @Inject(DI.noteReactionsRepository) - private noteReactionsRepository: NoteReactionsRepository, - - @Inject(DI.followRequestsRepository) - private followRequestsRepository: FollowRequestsRepository, - - private utilityService: UtilityService, - private instanceActorService: InstanceActorService, - private metaService: MetaService, - private apRequestService: ApRequestService, - private httpRequestService: HttpRequestService, - private apRendererService: ApRendererService, - private apDbResolverService: ApDbResolverService, - private loggerService: LoggerService, - ) { - } - - @bindThis - public createResolver(): Resolver { - return new Resolver( - this.config, - this.usersRepository, - this.notesRepository, - this.pollsRepository, - this.noteReactionsRepository, - this.followRequestsRepository, - this.utilityService, - this.instanceActorService, - this.metaService, - this.apRequestService, - this.httpRequestService, - this.apRendererService, - this.apDbResolverService, - this.loggerService, - ); - } -} diff --git a/packages/backend/src/core/activitypub/LdSignatureService.ts b/packages/backend/src/core/activitypub/LdSignatureService.ts deleted file mode 100644 index d8464b3839..0000000000 --- a/packages/backend/src/core/activitypub/LdSignatureService.ts +++ /dev/null @@ -1,171 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as crypto from 'node:crypto'; -import { Injectable } from '@nestjs/common'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { bindThis } from '@/decorators.js'; -import { CONTEXTS } from './misc/contexts.js'; -import { validateContentTypeSetAsJsonLD } from './misc/validator.js'; -import type { JsonLdDocument } from 'jsonld'; -import type { JsonLd, RemoteDocument } from 'jsonld/jsonld-spec.js'; - -// RsaSignature2017 based from https://github.com/transmute-industries/RsaSignature2017 - -class LdSignature { - public debug = false; - public preLoad = true; - public loderTimeout = 5000; - - constructor( - private httpRequestService: HttpRequestService, - ) { - } - - @bindThis - public async signRsaSignature2017(data: any, privateKey: string, creator: string, domain?: string, created?: Date): Promise { - const options: { - type: string; - creator: string; - domain?: string; - nonce: string; - created: string; - } = { - type: 'RsaSignature2017', - creator, - nonce: crypto.randomBytes(16).toString('hex'), - created: (created ?? new Date()).toISOString(), - }; - - if (domain) { - options.domain = domain; - } - - const toBeSigned = await this.createVerifyData(data, options); - - const signer = crypto.createSign('sha256'); - signer.update(toBeSigned); - signer.end(); - - const signature = signer.sign(privateKey); - - return { - ...data, - signature: { - ...options, - signatureValue: signature.toString('base64'), - }, - }; - } - - @bindThis - public async verifyRsaSignature2017(data: any, publicKey: string): Promise { - const toBeSigned = await this.createVerifyData(data, data.signature); - const verifier = crypto.createVerify('sha256'); - verifier.update(toBeSigned); - return verifier.verify(publicKey, data.signature.signatureValue, 'base64'); - } - - @bindThis - public async createVerifyData(data: any, options: any): Promise { - const transformedOptions = { - ...options, - '@context': 'https://w3id.org/identity/v1', - }; - delete transformedOptions['type']; - delete transformedOptions['id']; - delete transformedOptions['signatureValue']; - const canonizedOptions = await this.normalize(transformedOptions); - const optionsHash = this.sha256(canonizedOptions.toString()); - const transformedData = { ...data }; - delete transformedData['signature']; - const cannonidedData = await this.normalize(transformedData); - if (this.debug) console.debug(`cannonidedData: ${cannonidedData}`); - const documentHash = this.sha256(cannonidedData.toString()); - const verifyData = `${optionsHash}${documentHash}`; - return verifyData; - } - - @bindThis - public async normalize(data: JsonLdDocument): Promise { - const customLoader = this.getLoader(); - // XXX: Importing jsonld dynamically since Jest frequently fails to import it statically - // https://github.com/misskey-dev/misskey/pull/9894#discussion_r1103753595 - return (await import('jsonld')).default.normalize(data, { - documentLoader: customLoader, - }); - } - - @bindThis - private getLoader() { - return async (url: string): Promise => { - if (!/^https?:\/\//.test(url)) throw new Error(`Invalid URL ${url}`); - - if (this.preLoad) { - if (url in CONTEXTS) { - if (this.debug) console.debug(`HIT: ${url}`); - return { - contextUrl: undefined, - document: CONTEXTS[url], - documentUrl: url, - }; - } - } - - if (this.debug) console.debug(`MISS: ${url}`); - const document = await this.fetchDocument(url); - return { - contextUrl: undefined, - document: document, - documentUrl: url, - }; - }; - } - - @bindThis - private async fetchDocument(url: string): Promise { - const json = await this.httpRequestService.send( - url, - { - headers: { - Accept: 'application/ld+json, application/json', - }, - timeout: this.loderTimeout, - }, - { - throwErrorWhenResponseNotOk: false, - validators: [validateContentTypeSetAsJsonLD], - }, - ).then(res => { - if (!res.ok) { - throw new Error(`${res.status} ${res.statusText}`); - } else { - return res.json(); - } - }); - - return json as JsonLd; - } - - @bindThis - public sha256(data: string): string { - const hash = crypto.createHash('sha256'); - hash.update(data); - return hash.digest('hex'); - } -} - -@Injectable() -export class LdSignatureService { - constructor( - private httpRequestService: HttpRequestService, - ) { - } - - @bindThis - public use(): LdSignature { - return new LdSignature(this.httpRequestService); - } -} diff --git a/packages/backend/src/core/activitypub/misc/validator.ts b/packages/backend/src/core/activitypub/misc/validator.ts deleted file mode 100644 index 690beeffef..0000000000 --- a/packages/backend/src/core/activitypub/misc/validator.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Response } from 'node-fetch'; - -export function validateContentTypeSetAsActivityPub(response: Response): void { - const contentType = (response.headers.get('content-type') ?? '').toLowerCase(); - - if (contentType === '') { - throw new Error('Validate content type of AP response: No content-type header'); - } - if ( - contentType.startsWith('application/activity+json') || - (contentType.startsWith('application/ld+json;') && contentType.includes('https://www.w3.org/ns/activitystreams')) - ) { - return; - } - throw new Error('Validate content type of AP response: Content type is not application/activity+json or application/ld+json'); -} - -const plusJsonSuffixRegex = /^\s*(application|text)\/[a-zA-Z0-9\.\-\+]+\+json\s*(;|$)/; - -export function validateContentTypeSetAsJsonLD(response: Response): void { - const contentType = (response.headers.get('content-type') ?? '').toLowerCase(); - - if (contentType === '') { - throw new Error('Validate content type of JSON LD: No content-type header'); - } - if ( - contentType.startsWith('application/ld+json') || - contentType.startsWith('application/json') || - plusJsonSuffixRegex.test(contentType) - ) { - return; - } - throw new Error('Validate content type of JSON LD: Content type is not application/ld+json or application/json'); -} diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts deleted file mode 100644 index 2eff7c64e0..0000000000 --- a/packages/backend/src/core/activitypub/models/ApImageService.ts +++ /dev/null @@ -1,107 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import type { MiRemoteUser } from '@/models/User.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import { MetaService } from '@/core/MetaService.js'; -import { truncate } from '@/misc/truncate.js'; -import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js'; -import { DriveService } from '@/core/DriveService.js'; -import type Logger from '@/logger.js'; -import { bindThis } from '@/decorators.js'; -import { checkHttps } from '@/misc/check-https.js'; -import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import { ApResolverService } from '../ApResolverService.js'; -import { ApLoggerService } from '../ApLoggerService.js'; -import type { IObject } from '../type.js'; - -@Injectable() -export class ApImageService { - private logger: Logger; - - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private metaService: MetaService, - private apResolverService: ApResolverService, - private driveService: DriveService, - private apLoggerService: ApLoggerService, - private federatedInstanceService: FederatedInstanceService, - ) { - this.logger = this.apLoggerService.logger; - } - - /** - * Imageを作成します。 - */ - @bindThis - public async createImage(actor: MiRemoteUser, value: string | IObject): Promise { - // 投稿者が凍結されていたらスキップ - if (actor.isSuspended) { - throw new Error('actor has been suspended'); - } - - const image = await this.apResolverService.createResolver().resolve(value); - - if (image.url == null) { - throw new Error('invalid image: url not provided'); - } - - if (typeof image.url !== 'string') { - throw new Error('invalid image: unexpected type of url: ' + JSON.stringify(image.url, null, 2)); - } - - if (!checkHttps(image.url)) { - throw new Error('invalid image: unexpected schema of url: ' + image.url); - } - - this.logger.info(`Creating the Image: ${image.url}`); - - const instance = await this.metaService.fetch(); - - // Cache if remote file cache is on AND either - // 1. remote sensitive file is also on - // 2. or the image is not sensitive - const shouldBeCached = instance.cacheRemoteFiles && (instance.cacheRemoteSensitiveFiles || !image.sensitive); - - await this.federatedInstanceService.fetch(actor.host).then(async i => { - if (i.isNSFW) { - image.sensitive = true; - } - }); - - const file = await this.driveService.uploadFromUrl({ - url: image.url, - user: actor, - uri: image.url, - sensitive: image.sensitive, - isLink: !shouldBeCached, - comment: truncate(image.name ?? undefined, DB_MAX_IMAGE_COMMENT_LENGTH), - }); - if (!file.isLink || file.url === image.url) return file; - - // URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、URLを更新する - await this.driveFilesRepository.update({ id: file.id }, { url: image.url, uri: image.url }); - return await this.driveFilesRepository.findOneByOrFail({ id: file.id }); - } - - /** - * Imageを解決します。 - * - * Misskeyに対象のImageが登録されていればそれを返し、そうでなければ - * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 - */ - @bindThis - public async resolveImage(actor: MiRemoteUser, value: string | IObject): Promise { - // TODO - - // リモートサーバーからフェッチしてきて登録 - return await this.createImage(actor, value); - } -} diff --git a/packages/backend/src/core/activitypub/models/ApMentionService.ts b/packages/backend/src/core/activitypub/models/ApMentionService.ts deleted file mode 100644 index 9aa8ba5ede..0000000000 --- a/packages/backend/src/core/activitypub/models/ApMentionService.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import promiseLimit from 'promise-limit'; -import type { MiUser } from '@/models/_.js'; -import { toArray, unique } from '@/misc/prelude/array.js'; -import { bindThis } from '@/decorators.js'; -import { isMention } from '../type.js'; -import { Resolver } from '../ApResolverService.js'; -import { ApPersonService } from './ApPersonService.js'; -import type { IObject, IApMention } from '../type.js'; - -@Injectable() -export class ApMentionService { - constructor( - private apPersonService: ApPersonService, - ) { - } - - @bindThis - public async extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver): Promise { - const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href)); - - const limit = promiseLimit(2); - const mentionedUsers = (await Promise.all( - hrefs.map(x => limit(() => this.apPersonService.resolvePerson(x, resolver).catch(() => null))), - )).filter((x): x is MiUser => x != null); - - return mentionedUsers; - } - - @bindThis - public extractApMentionObjects(tags: IObject | IObject[] | null | undefined): IApMention[] { - if (tags == null) return []; - return toArray(tags).filter(isMention); - } -} diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts deleted file mode 100644 index c28df27b63..0000000000 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ /dev/null @@ -1,622 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import promiseLimit from 'promise-limit'; -import { In } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { PollsRepository, EmojisRepository, NotesRepository } from '@/models/_.js'; -import type { Config } from '@/config.js'; -import type { MiRemoteUser } from '@/models/User.js'; -import type { MiNote } from '@/models/Note.js'; -import { toArray, toSingle, unique } from '@/misc/prelude/array.js'; -import type { MiEmoji } from '@/models/Emoji.js'; -import { MetaService } from '@/core/MetaService.js'; -import { AppLockService } from '@/core/AppLockService.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import { NoteCreateService } from '@/core/NoteCreateService.js'; -import { NoteEditService } from '@/core/NoteEditService.js'; -import type Logger from '@/logger.js'; -import { IdService } from '@/core/IdService.js'; -import { PollService } from '@/core/PollService.js'; -import { StatusError } from '@/misc/status-error.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; -import { checkHttps } from '@/misc/check-https.js'; -import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; -import { ApLoggerService } from '../ApLoggerService.js'; -import { ApMfmService } from '../ApMfmService.js'; -import { ApDbResolverService } from '../ApDbResolverService.js'; -import { ApResolverService } from '../ApResolverService.js'; -import { ApAudienceService } from '../ApAudienceService.js'; -import { ApPersonService } from './ApPersonService.js'; -import { extractApHashtags } from './tag.js'; -import { ApMentionService } from './ApMentionService.js'; -import { ApQuestionService } from './ApQuestionService.js'; -import { ApImageService } from './ApImageService.js'; -import type { Resolver } from '../ApResolverService.js'; -import type { IObject, IPost } from '../type.js'; - -@Injectable() -export class ApNoteService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private idService: IdService, - private apMfmService: ApMfmService, - private apResolverService: ApResolverService, - - // 循環参照のため / for circular dependency - @Inject(forwardRef(() => ApPersonService)) - private apPersonService: ApPersonService, - - private utilityService: UtilityService, - private apAudienceService: ApAudienceService, - private apMentionService: ApMentionService, - private apImageService: ApImageService, - private apQuestionService: ApQuestionService, - private metaService: MetaService, - private appLockService: AppLockService, - private pollService: PollService, - private noteCreateService: NoteCreateService, - private noteEditService: NoteEditService, - private apDbResolverService: ApDbResolverService, - private apLoggerService: ApLoggerService, - ) { - this.logger = this.apLoggerService.logger; - } - - @bindThis - public validateNote(object: IObject, uri: string): Error | null { - const expectHost = this.utilityService.extractDbHost(uri); - - if (!validPost.includes(getApType(object))) { - return new Error(`invalid Note: invalid object type ${getApType(object)}`); - } - - if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) { - return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`); - } - - const actualHost = object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)); - if (object.attributedTo && actualHost !== expectHost) { - return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`); - } - - if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) { - return new Error('invalid Note: published timestamp is malformed'); - } - - return null; - } - - /** - * Noteをフェッチします。 - * - * Misskeyに対象のNoteが登録されていればそれを返します。 - */ - @bindThis - public async fetchNote(object: string | IObject): Promise { - return await this.apDbResolverService.getNoteFromApId(object); - } - - /** - * Noteを作成します。 - */ - @bindThis - public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise { - // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); - - const object = await resolver.resolve(value); - - const entryUri = getApId(value); - const err = this.validateNote(object, entryUri); - if (err) { - this.logger.error(err.message, { - resolver: { history: resolver.getHistory() }, - value, - object, - }); - throw new Error('invalid note'); - } - - const note = object as IPost; - - this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); - - if (note.id && !checkHttps(note.id)) { - throw new Error('unexpected schema of note.id: ' + note.id); - } - - const url = getOneApHrefNullable(note.url); - - if (url && !checkHttps(url)) { - throw new Error('unexpected schema of note url: ' + url); - } - - this.logger.info(`Creating the Note: ${note.id}`); - - // 投稿者をフェッチ - if (note.attributedTo == null) { - throw new Error('invalid note.attributedTo: ' + note.attributedTo); - } - - const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as MiRemoteUser; - - // 投稿者が凍結されていたらスキップ - if (actor.isSuspended) { - throw new Error('actor has been suspended'); - } - - const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver); - let visibility = noteAudience.visibility; - const visibleUsers = noteAudience.visibleUsers; - - // Audience (to, cc) が指定されてなかった場合 - if (visibility === 'specified' && visibleUsers.length === 0) { - if (typeof value === 'string') { // 入力がstringならばresolverでGETが発生している - // こちらから匿名GET出来たものならばpublic - visibility = 'public'; - } - } - - const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver); - const apHashtags = extractApHashtags(note.tag); - - // 添付ファイル - // TODO: attachmentは必ずしもImageではない - // TODO: attachmentは必ずしも配列ではない - const limit = promiseLimit(2); - const files = (await Promise.all(toArray(note.attachment).map(attach => ( - limit(() => this.apImageService.resolveImage(actor, { - ...attach, - sensitive: note.sensitive, // Noteがsensitiveなら添付もsensitiveにする - })) - )))); - - // リプライ - const reply: MiNote | null = note.inReplyTo - ? await this.resolveNote(note.inReplyTo, { resolver }) - .then(x => { - if (x == null) { - this.logger.warn('Specified inReplyTo, but not found'); - throw new Error('inReplyTo not found'); - } - - return x; - }) - .catch(async err => { - this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`); - throw err; - }) - : null; - - // 引用 - let quote: MiNote | undefined | null = null; - - if (note._misskey_quote ?? note.quoteUrl ?? note.quoteUri) { - const tryResolveNote = async (uri: string): Promise< - | { status: 'ok'; res: MiNote } - | { status: 'permerror' | 'temperror' } - > => { - if (typeof uri !== 'string' || !/^https?:/.test(uri)) return { status: 'permerror' }; - try { - const res = await this.resolveNote(uri); - if (res == null) return { status: 'permerror' }; - return { status: 'ok', res }; - } catch (e) { - return { - status: (e instanceof StatusError && e.isClientError) ? 'permerror' : 'temperror', - }; - } - }; - - const uris = unique([note._misskey_quote, note.quoteUrl, note.quoteUri].filter((x): x is string => typeof x === 'string')); - const results = await Promise.all(uris.map(tryResolveNote)); - - quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0); - if (!quote) { - if (results.some(x => x.status === 'temperror')) { - throw new Error('quote resolve failed'); - } - } - } - - const cw = note.summary === '' ? null : note.summary; - - // テキストのパース - let text: string | null = null; - if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') { - text = note.source.content; - } else if (typeof note._misskey_content !== 'undefined') { - text = note._misskey_content; - } else if (typeof note.content === 'string') { - text = this.apMfmService.htmlToMfm(note.content, note.tag); - } - - // vote - if (reply && reply.hasPoll) { - const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id }); - - const tryCreateVote = async (name: string, index: number): Promise => { - if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) { - this.logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); - } else if (index >= 0) { - this.logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); - await this.pollService.vote(actor, reply, index); - - // リモートフォロワーにUpdate配信 - this.pollService.deliverQuestionUpdate(reply.id); - } - return null; - }; - - if (note.name) { - return await tryCreateVote(note.name, poll.choices.findIndex(x => x === note.name)); - } - } - - const emojis = await this.extractEmojis(note.tag ?? [], actor.host).catch(e => { - this.logger.info(`extractEmojis: ${e}`); - return []; - }); - - const apEmojis = emojis.map(emoji => emoji.name); - - const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined); - - try { - return await this.noteCreateService.create(actor, { - createdAt: note.published ? new Date(note.published) : null, - files, - reply, - renote: quote, - name: note.name, - cw, - text, - localOnly: false, - visibility, - visibleUsers, - apMentions, - apHashtags, - apEmojis, - poll, - uri: note.id, - url: url, - }, silent); - } catch (err: any) { - if (err.name !== 'duplicated') { - throw err; - } - this.logger.info('The note is already inserted while creating itself, reading again'); - const duplicate = await this.fetchNote(value); - if (!duplicate) { - throw new Error('The note creation failed with duplication error even when there is no duplication'); - } - return duplicate; - } - } - - /** - * Noteを作成します。 - */ - @bindThis - public async updateNote(value: string | IObject, resolver?: Resolver, silent = false): Promise { - const uri = typeof value === 'string' ? value : value.id; - if (uri == null) throw new Error('uri is null'); - - // URIがこのサーバーを指しているならスキップ - if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local'); - - //#region このサーバーに既に登録されているか - const UpdatedNote = await this.notesRepository.findOneBy({ uri }); - if (UpdatedNote == null) throw new Error('Note is not registered'); - - // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); - - const object = await resolver.resolve(value); - - const entryUri = getApId(value); - const err = this.validateNote(object, entryUri); - if (err) { - this.logger.error(err.message, { - resolver: { history: resolver.getHistory() }, - value, - object, - }); - throw new Error('invalid note'); - } - - const note = object as IPost; - - this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); - - if (note.id && !checkHttps(note.id)) { - throw new Error('unexpected schema of note.id: ' + note.id); - } - - const url = getOneApHrefNullable(note.url); - - if (url && !checkHttps(url)) { - throw new Error('unexpected schema of note url: ' + url); - } - - this.logger.info(`Creating the Note: ${note.id}`); - - // 投稿者をフェッチ - if (note.attributedTo == null) { - throw new Error('invalid note.attributedTo: ' + note.attributedTo); - } - - const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as MiRemoteUser; - - // 投稿者が凍結されていたらスキップ - if (actor.isSuspended) { - throw new Error('actor has been suspended'); - } - - const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver); - let visibility = noteAudience.visibility; - const visibleUsers = noteAudience.visibleUsers; - - // Audience (to, cc) が指定されてなかった場合 - if (visibility === 'specified' && visibleUsers.length === 0) { - if (typeof value === 'string') { // 入力がstringならばresolverでGETが発生している - // こちらから匿名GET出来たものならばpublic - visibility = 'public'; - } - } - - const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver); - const apHashtags = extractApHashtags(note.tag); - - // 添付ファイル - // TODO: attachmentは必ずしもImageではない - // TODO: attachmentは必ずしも配列ではない - const limit = promiseLimit(2); - const files = (await Promise.all(toArray(note.attachment).map(attach => ( - limit(() => this.apImageService.resolveImage(actor, { - ...attach, - sensitive: note.sensitive, // Noteがsensitiveなら添付もsensitiveにする - })) - )))); - - // リプライ - const reply: MiNote | null = note.inReplyTo - ? await this.resolveNote(note.inReplyTo, { resolver }) - .then(x => { - if (x == null) { - this.logger.warn('Specified inReplyTo, but not found'); - throw new Error('inReplyTo not found'); - } - - return x; - }) - .catch(async err => { - this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`); - throw err; - }) - : null; - - // 引用 - let quote: MiNote | undefined | null = null; - - if (note._misskey_quote ?? note.quoteUrl ?? note.quoteUri) { - const tryResolveNote = async (uri: string): Promise< - | { status: 'ok'; res: MiNote } - | { status: 'permerror' | 'temperror' } - > => { - if (!/^https?:/.test(uri)) return { status: 'permerror' }; - try { - const res = await this.resolveNote(uri); - if (res == null) return { status: 'permerror' }; - return { status: 'ok', res }; - } catch (e) { - return { - status: (e instanceof StatusError && e.isClientError) ? 'permerror' : 'temperror', - }; - } - }; - - const uris = unique([note._misskey_quote, note.quoteUrl, note.quoteUri].filter((x): x is string => typeof x === 'string')); - const results = await Promise.all(uris.map(tryResolveNote)); - - quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0); - if (!quote) { - if (results.some(x => x.status === 'temperror')) { - throw new Error('quote resolve failed'); - } - } - } - - const cw = note.summary === '' ? null : note.summary; - - // テキストのパース - let text: string | null = null; - if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') { - text = note.source.content; - } else if (typeof note._misskey_content !== 'undefined') { - text = note._misskey_content; - } else if (typeof note.content === 'string') { - text = this.apMfmService.htmlToMfm(note.content, note.tag); - } - - // vote - if (reply && reply.hasPoll) { - const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id }); - - const tryCreateVote = async (name: string, index: number): Promise => { - if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) { - this.logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); - } else if (index >= 0) { - this.logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); - await this.pollService.vote(actor, reply, index); - - // リモートフォロワーにUpdate配信 - this.pollService.deliverQuestionUpdate(reply.id); - } - return null; - }; - - if (note.name) { - return await tryCreateVote(note.name, poll.choices.findIndex(x => x === note.name)); - } - } - - const emojis = await this.extractEmojis(note.tag ?? [], actor.host).catch(e => { - this.logger.info(`extractEmojis: ${e}`); - return []; - }); - - const apEmojis = emojis.map(emoji => emoji.name); - - const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined); - - try { - return await this.noteEditService.edit(actor, UpdatedNote.id, { - createdAt: note.published ? new Date(note.published) : null, - files, - reply, - renote: quote, - name: note.name, - cw, - text, - localOnly: false, - visibility, - visibleUsers, - apMentions, - apHashtags, - apEmojis, - poll, - uri: note.id, - url: url, - }, silent); - } catch (err: any) { - if (err.name !== 'duplicated') { - throw err; - } - this.logger.info('The note is already inserted while creating itself, reading again'); - const duplicate = await this.fetchNote(value); - if (!duplicate) { - throw new Error('The note creation failed with duplication error even when there is no duplication'); - } - return duplicate; - } - } - - /** - * Noteを解決します。 - * - * Misskeyに対象のNoteが登録されていればそれを返し、そうでなければ - * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 - */ - @bindThis - public async resolveNote(value: string | IObject, options: { sentFrom?: URL, resolver?: Resolver } = {}): Promise { - const uri = getApId(value); - - // ブロックしていたら中断 - const meta = await this.metaService.fetch(); - const dbHost = this.utilityService.extractDbHost(uri); - if (this.utilityService.isBlockedHost(meta.blockedHosts, dbHost)) { - throw new StatusError('blocked host', 451); - } - - if (meta.allowlistMode && !this.utilityService.isAllowedHost(meta.allowedHosts, dbHost)) { - throw new StatusError('blocked host', 451); - } - - const unlock = await this.appLockService.getApLock(uri); - - try { - //#region このサーバーに既に登録されていたらそれを返す - const exist = await this.fetchNote(uri); - if (exist) return exist; - //#endregion - - if (uri.startsWith(this.config.url)) { - throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note'); - } - - // リモートサーバーからフェッチしてきて登録 - // ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが - // 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。 - const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri; - return await this.createNote(createFrom, options.resolver, true); - } finally { - unlock(); - } - } - - @bindThis - public async extractEmojis(tags: IObject | IObject[], host: string): Promise { - // eslint-disable-next-line no-param-reassign - host = this.utilityService.toPuny(host); - - const eomjiTags = toArray(tags).filter(isEmoji); - - const existingEmojis = await this.emojisRepository.findBy({ - host, - name: In(eomjiTags.map(tag => tag.name.replaceAll(':', ''))), - }); - - return await Promise.all(eomjiTags.map(async tag => { - const name = tag.name.replaceAll(':', ''); - tag.icon = toSingle(tag.icon); - - const exists = existingEmojis.find(x => x.name === name); - - if (exists) { - if ((exists.updatedAt == null) - || (tag.id != null && exists.uri == null) - || (new Date(tag.updated) > exists.updatedAt) - || (tag.icon.url !== exists.originalUrl) - ) { - await this.emojisRepository.update({ - host, - name, - }, { - uri: tag.id, - originalUrl: tag.icon.url, - publicUrl: tag.icon.url, - updatedAt: new Date(), - }); - - const emoji = await this.emojisRepository.findOneBy({ host, name }); - if (emoji == null) throw new Error('emoji update failed'); - return emoji; - } - - return exists; - } - - this.logger.info(`register emoji host=${host}, name=${name}`); - - return await this.emojisRepository.insert({ - id: this.idService.gen(), - host, - name, - uri: tag.id, - originalUrl: tag.icon.url, - publicUrl: tag.icon.url, - updatedAt: new Date(), - aliases: [], - }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); - })); - } -} diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts deleted file mode 100644 index 8507c6f949..0000000000 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ /dev/null @@ -1,702 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import promiseLimit from 'promise-limit'; -import { DataSource } from 'typeorm'; -import { ModuleRef } from '@nestjs/core'; -import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js'; -import type { Config } from '@/config.js'; -import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; -import { MiUser } from '@/models/User.js'; -import { truncate } from '@/misc/truncate.js'; -import type { CacheService } from '@/core/CacheService.js'; -import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import type Logger from '@/logger.js'; -import type { MiNote } from '@/models/Note.js'; -import type { IdService } from '@/core/IdService.js'; -import type { MfmService } from '@/core/MfmService.js'; -import { toArray } from '@/misc/prelude/array.js'; -import type { GlobalEventService } from '@/core/GlobalEventService.js'; -import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import type { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; -import { MiUserProfile } from '@/models/UserProfile.js'; -import { MiUserPublickey } from '@/models/UserPublickey.js'; -import type UsersChart from '@/core/chart/charts/users.js'; -import type InstanceChart from '@/core/chart/charts/instance.js'; -import type { HashtagService } from '@/core/HashtagService.js'; -import { MiUserNotePining } from '@/models/UserNotePining.js'; -import { StatusError } from '@/misc/status-error.js'; -import type { UtilityService } from '@/core/UtilityService.js'; -import type { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { bindThis } from '@/decorators.js'; -import { MetaService } from '@/core/MetaService.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import type { AccountMoveService } from '@/core/AccountMoveService.js'; -import { checkHttps } from '@/misc/check-https.js'; -import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; -import { extractApHashtags } from './tag.js'; -import type { OnModuleInit } from '@nestjs/common'; -import type { ApNoteService } from './ApNoteService.js'; -import type { ApMfmService } from '../ApMfmService.js'; -import type { ApResolverService, Resolver } from '../ApResolverService.js'; -import type { ApLoggerService } from '../ApLoggerService.js'; -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -import type { ApImageService } from './ApImageService.js'; -import type { IActor, IObject } from '../type.js'; - -const nameLength = 128; -const summaryLength = 2048; - -type Field = Record<'name' | 'value', string>; - -@Injectable() -export class ApPersonService implements OnModuleInit { - private utilityService: UtilityService; - private userEntityService: UserEntityService; - private driveFileEntityService: DriveFileEntityService; - private idService: IdService; - private globalEventService: GlobalEventService; - private metaService: MetaService; - private federatedInstanceService: FederatedInstanceService; - private fetchInstanceMetadataService: FetchInstanceMetadataService; - private cacheService: CacheService; - private apResolverService: ApResolverService; - private apNoteService: ApNoteService; - private apImageService: ApImageService; - private apMfmService: ApMfmService; - private mfmService: MfmService; - private hashtagService: HashtagService; - private usersChart: UsersChart; - private instanceChart: InstanceChart; - private apLoggerService: ApLoggerService; - private accountMoveService: AccountMoveService; - private logger: Logger; - - constructor( - private moduleRef: ModuleRef, - - @Inject(DI.config) - private config: Config, - - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.userPublickeysRepository) - private userPublickeysRepository: UserPublickeysRepository, - - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - ) { - } - - onModuleInit(): void { - this.utilityService = this.moduleRef.get('UtilityService'); - this.userEntityService = this.moduleRef.get('UserEntityService'); - this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); - this.idService = this.moduleRef.get('IdService'); - this.globalEventService = this.moduleRef.get('GlobalEventService'); - this.metaService = this.moduleRef.get('MetaService'); - this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); - this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService'); - this.cacheService = this.moduleRef.get('CacheService'); - this.apResolverService = this.moduleRef.get('ApResolverService'); - this.apNoteService = this.moduleRef.get('ApNoteService'); - this.apImageService = this.moduleRef.get('ApImageService'); - this.apMfmService = this.moduleRef.get('ApMfmService'); - this.mfmService = this.moduleRef.get('MfmService'); - this.hashtagService = this.moduleRef.get('HashtagService'); - this.usersChart = this.moduleRef.get('UsersChart'); - this.instanceChart = this.moduleRef.get('InstanceChart'); - this.apLoggerService = this.moduleRef.get('ApLoggerService'); - this.accountMoveService = this.moduleRef.get('AccountMoveService'); - this.logger = this.apLoggerService.logger; - } - - private punyHost(url: string): string { - const urlObj = new URL(url); - const host = `${this.utilityService.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`; - return host; - } - - /** - * Validate and convert to actor object - * @param x Fetched object - * @param uri Fetch target URI - */ - @bindThis - private validateActor(x: IObject, uri: string): IActor { - const expectHost = this.punyHost(uri); - - if (!isActor(x)) { - throw new Error(`invalid Actor type '${x.type}'`); - } - - if (!(typeof x.id === 'string' && x.id.length > 0)) { - throw new Error('invalid Actor: wrong id'); - } - - if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) { - throw new Error('invalid Actor: wrong inbox'); - } - - if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) { - throw new Error('invalid Actor: wrong username'); - } - - // These fields are only informational, and some AP software allows these - // fields to be very long. If they are too long, we cut them off. This way - // we can at least see these users and their activities. - if (x.name) { - if (!(typeof x.name === 'string' && x.name.length > 0)) { - throw new Error('invalid Actor: wrong name'); - } - x.name = truncate(x.name, nameLength); - } else if (x.name === '') { - // Mastodon emits empty string when the name is not set. - x.name = undefined; - } - if (x.summary) { - if (!(typeof x.summary === 'string' && x.summary.length > 0)) { - throw new Error('invalid Actor: wrong summary'); - } - x.summary = truncate(x.summary, summaryLength); - } - - const idHost = this.punyHost(x.id); - if (idHost !== expectHost) { - throw new Error('invalid Actor: id has different host'); - } - - if (x.publicKey) { - if (typeof x.publicKey.id !== 'string') { - throw new Error('invalid Actor: publicKey.id is not a string'); - } - - const publicKeyIdHost = this.punyHost(x.publicKey.id); - if (publicKeyIdHost !== expectHost) { - throw new Error('invalid Actor: publicKey.id has different host'); - } - } - - return x; - } - - /** - * uriからUser(Person)をフェッチします。 - * - * Misskeyに対象のPersonが登録されていればそれを返し、登録がなければnullを返します。 - */ - @bindThis - public async fetchPerson(uri: string): Promise { - const cached = this.cacheService.uriPersonCache.get(uri) as MiLocalUser | MiRemoteUser | null | undefined; - if (cached) return cached; - - // URIがこのサーバーを指しているならデータベースからフェッチ - if (uri.startsWith(`${this.config.url}/`)) { - const id = uri.split('/').pop(); - const u = await this.usersRepository.findOneBy({ id }) as MiLocalUser | null; - if (u) this.cacheService.uriPersonCache.set(uri, u); - return u; - } - - //#region このサーバーに既に登録されていたらそれを返す - const exist = await this.usersRepository.findOneBy({ uri }) as MiLocalUser | MiRemoteUser | null; - - if (exist) { - this.cacheService.uriPersonCache.set(uri, exist); - return exist; - } - //#endregion - - return null; - } - - private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any, bgimg: any): Promise> { - const [avatar, banner, background] = await Promise.all([icon, image, bgimg].map(img => { - if (img == null) return null; - if (user == null) throw new Error('failed to create user: user is null'); - return this.apImageService.resolveImage(user, img).catch(() => null); - })); - - return { - avatarId: avatar?.id ?? null, - bannerId: banner?.id ?? null, - backgroundId: background?.id ?? null, - avatarUrl: avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null, - bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null, - backgroundUrl: background ? this.driveFileEntityService.getPublicUrl(background) : null, - avatarBlurhash: avatar?.blurhash ?? null, - bannerBlurhash: banner?.blurhash ?? null, - backgroundBlurhash: background?.blurhash ?? null - }; - } - - /** - * Personを作成します。 - */ - @bindThis - public async createPerson(uri: string, resolver?: Resolver): Promise { - if (typeof uri !== 'string') throw new Error('uri is not string'); - - if (uri.startsWith(this.config.url)) { - throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); - } - - // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); - - const object = await resolver.resolve(uri); - if (object.id == null) throw new Error('invalid object.id: ' + object.id); - - const person = this.validateActor(object, uri); - - this.logger.info(`Creating the Person: ${person.id}`); - - const host = this.punyHost(object.id); - - const fields = this.analyzeAttachments(person.attachment ?? []); - - const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32); - - const isBot = getApType(object) === 'Service' || getApType(object) === 'Application'; - - const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); - - const url = getOneApHrefNullable(person.url); - - if (url && !checkHttps(url)) { - throw new Error('unexpected schema of person url: ' + url); - } - - // Create user - let user: MiRemoteUser | null = null; - - //#region カスタム絵文字取得 - const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host) - .then(_emojis => _emojis.map(emoji => emoji.name)) - .catch(err => { - this.logger.error('error occurred while fetching user emojis', { stack: err }); - return []; - }); - //#endregion - - //#region resolve counts - const _resolver = resolver ?? this.apResolverService.createResolver(); - const outboxcollection = await _resolver.resolveCollection(person.outbox).catch(() => { return null; }); - const followerscollection = await _resolver.resolveCollection(person.followers!).catch(() => { return null; }); - const followingcollection = await _resolver.resolveCollection(person.following!).catch(() => { return null; }); - - try { - // Start transaction - await this.db.transaction(async transactionalEntityManager => { - user = await transactionalEntityManager.save(new MiUser({ - id: this.idService.gen(), - avatarId: null, - bannerId: null, - backgroundId: null, - lastFetchedAt: new Date(), - name: truncate(person.name, nameLength), - noindex: (person as any).noindex ?? false, - isLocked: person.manuallyApprovesFollowers, - movedToUri: person.movedTo, - movedAt: person.movedTo ? new Date() : null, - alsoKnownAs: person.alsoKnownAs, - isExplorable: person.discoverable, - username: person.preferredUsername, - approved: true, - usernameLower: person.preferredUsername?.toLowerCase(), - host, - inbox: person.inbox, - sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox, - notesCount: outboxcollection?.totalItems ?? 0, - followersCount: followerscollection?.totalItems ?? 0, - followingCount: followingcollection?.totalItems ?? 0, - followersUri: person.followers ? getApId(person.followers) : undefined, - featured: person.featured ? getApId(person.featured) : undefined, - uri: person.id, - tags, - isBot, - isCat: (person as any).isCat === true, - speakAsCat: (person as any).speakAsCat != null ? (person as any).speakAsCat === true : (person as any).isCat === true, - emojis, - })) as MiRemoteUser; - - let _description: string | null = null; - - if (person._misskey_summary) { - _description = truncate(person._misskey_summary, summaryLength); - } else if (person.summary) { - _description = this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag); - } - - await transactionalEntityManager.save(new MiUserProfile({ - userId: user.id, - description: _description, - url, - fields, - birthday: bday?.[0] ?? null, - location: person['vcard:Address'] ?? null, - userHost: host, - listenbrainz: person.listenbrainz ?? null, - })); - - if (person.publicKey) { - await transactionalEntityManager.save(new MiUserPublickey({ - userId: user.id, - keyId: person.publicKey.id, - keyPem: person.publicKey.publicKeyPem, - })); - } - }); - } catch (e) { - // duplicate key error - if (isDuplicateKeyValueError(e)) { - // /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応 - const u = await this.usersRepository.findOneBy({ uri: person.id }); - if (u == null) throw new Error('already registered'); - - user = u as MiRemoteUser; - } else { - this.logger.error(e instanceof Error ? e : new Error(e as string)); - throw e; - } - } - - if (user == null) throw new Error('failed to create user: user is null'); - - // Register to the cache - this.cacheService.uriPersonCache.set(user.uri, user); - - // Register host - this.federatedInstanceService.fetch(host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); - this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.newUser(i.host); - } - }); - - this.usersChart.update(user, true); - - // ハッシュタグ更新 - this.hashtagService.updateUsertags(user, tags); - - //#region アバターとヘッダー画像をフェッチ - try { - const updates = await this.resolveAvatarAndBanner(user, person.icon, person.image, person.backgroundUrl); - await this.usersRepository.update(user.id, updates); - user = { ...user, ...updates }; - - // Register to the cache - this.cacheService.uriPersonCache.set(user.uri, user); - } catch (err) { - this.logger.error('error occurred while fetching user avatar/banner', { stack: err }); - } - //#endregion - - await this.updateFeatured(user.id, resolver).catch(err => this.logger.error(err)); - - return user; - } - - /** - * Personの情報を更新します。 - * Misskeyに対象のPersonが登録されていなければ無視します。 - * もしアカウントの移行が確認された場合、アカウント移行処理を行います。 - * - * @param uri URI of Person - * @param resolver Resolver - * @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します) - * @param movePreventUris ここに指定されたURIがPersonのmovedToに指定されていたり10回より多く回っている場合これ以上アカウント移行を行わない(無限ループ防止) - */ - @bindThis - public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise { - if (typeof uri !== 'string') throw new Error('uri is not string'); - - // URIがこのサーバーを指しているならスキップ - if (uri.startsWith(`${this.config.url}/`)) return; - - //#region このサーバーに既に登録されているか - const exist = await this.fetchPerson(uri) as MiRemoteUser | null; - if (exist === null) return; - //#endregion - - // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); - - const object = hint ?? await resolver.resolve(uri); - - const person = this.validateActor(object, uri); - - this.logger.info(`Updating the Person: ${person.id}`); - - // カスタム絵文字取得 - const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], exist.host).catch(e => { - this.logger.info(`extractEmojis: ${e}`); - return []; - }); - - const emojiNames = emojis.map(emoji => emoji.name); - - const fields = this.analyzeAttachments(person.attachment ?? []); - - const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32); - - const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); - - const url = getOneApHrefNullable(person.url); - - if (url && !checkHttps(url)) { - throw new Error('unexpected schema of person url: ' + url); - } - - const updates = { - lastFetchedAt: new Date(), - inbox: person.inbox, - sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox, - followersUri: person.followers ? getApId(person.followers) : undefined, - featured: person.featured, - emojis: emojiNames, - name: truncate(person.name, nameLength), - tags, - approved: true, - isBot: getApType(object) === 'Service' || getApType(object) === 'Application', - isCat: (person as any).isCat === true, - speakAsCat: (person as any).speakAsCat != null ? (person as any).speakAsCat === true : (person as any).isCat === true, - noindex: (person as any).noindex ?? false, - isLocked: person.manuallyApprovesFollowers, - movedToUri: person.movedTo ?? null, - alsoKnownAs: person.alsoKnownAs ?? null, - isExplorable: person.discoverable, - ...(await this.resolveAvatarAndBanner(exist, person.icon, person.image, person.backgroundUrl).catch(() => ({}))), - } as Partial & Pick; - - const moving = ((): boolean => { - // 移行先がない→ある - if ( - exist.movedToUri === null && - updates.movedToUri - ) return true; - - // 移行先がある→別のもの - if ( - exist.movedToUri !== null && - updates.movedToUri !== null && - exist.movedToUri !== updates.movedToUri - ) return true; - - // 移行先がある→ない、ない→ないは無視 - return false; - })(); - - if (moving) updates.movedAt = new Date(); - - // Update user - await this.usersRepository.update(exist.id, updates); - - if (person.publicKey) { - await this.userPublickeysRepository.update({ userId: exist.id }, { - keyId: person.publicKey.id, - keyPem: person.publicKey.publicKeyPem, - }); - } - - let _description: string | null = null; - - if (person._misskey_summary) { - _description = truncate(person._misskey_summary, summaryLength); - } else if (person.summary) { - _description = this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag); - } - - await this.userProfilesRepository.update({ userId: exist.id }, { - url, - fields, - description: _description, - birthday: bday?.[0] ?? null, - location: person['vcard:Address'] ?? null, - listenbrainz: person.listenbrainz ?? null, - }); - - this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: exist.id }); - - // ハッシュタグ更新 - this.hashtagService.updateUsertags(exist, tags); - - // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする - await this.followingsRepository.update( - { followerId: exist.id }, - { followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox }, - ); - - await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err)); - - const updated = { ...exist, ...updates }; - - this.cacheService.uriPersonCache.set(uri, updated); - - // 移行処理を行う - if (updated.movedAt && ( - // 初めて移行する場合はmovedAtがnullなので移行処理を許可 - exist.movedAt == null || - // 以前のmovingから14日以上経過した場合のみ移行処理を許可 - // (Mastodonのクールダウン期間は30日だが若干緩めに設定しておく) - exist.movedAt.getTime() + 1000 * 60 * 60 * 24 * 14 < updated.movedAt.getTime() - )) { - this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${uri})`); - return this.processRemoteMove(updated, movePreventUris) - .then(result => { - this.logger.info(`Processing Move Finished [${result}] @${updated.username}@${updated.host} (${uri})`); - return result; - }) - .catch(e => { - this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${uri})`, { stack: e }); - }); - } - - return 'skip'; - } - - /** - * Personを解決します。 - * - * Misskeyに対象のPersonが登録されていればそれを返し、そうでなければ - * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 - */ - @bindThis - public async resolvePerson(uri: string, resolver?: Resolver): Promise { - //#region このサーバーに既に登録されていたらそれを返す - const exist = await this.fetchPerson(uri); - if (exist) return exist; - //#endregion - - // リモートサーバーからフェッチしてきて登録 - // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); - return await this.createPerson(uri, resolver); - } - - @bindThis - // TODO: `attachments`が`IObject`だった場合、返り値が`[]`になるようだが構わないのか? - public analyzeAttachments(attachments: IObject | IObject[] | undefined): Field[] { - const fields: Field[] = []; - - if (Array.isArray(attachments)) { - for (const attachment of attachments.filter(isPropertyValue)) { - fields.push({ - name: attachment.name, - value: this.mfmService.fromHtml(attachment.value), - }); - } - } - - return fields; - } - - @bindThis - public async updateFeatured(userId: MiUser['id'], resolver?: Resolver): Promise { - const user = await this.usersRepository.findOneByOrFail({ id: userId }); - if (!this.userEntityService.isRemoteUser(user)) return; - if (!user.featured) return; - - this.logger.info(`Updating the featured: ${user.uri}`); - - const _resolver = resolver ?? this.apResolverService.createResolver(); - - // Resolve to (Ordered)Collection Object - const collection = await _resolver.resolveCollection(user.featured); - if (!isCollectionOrOrderedCollection(collection)) throw new Error('Object is not Collection or OrderedCollection'); - - // Resolve to Object(may be Note) arrays - const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems; - const items = await Promise.all(toArray(unresolvedItems).map(x => _resolver.resolve(x))); - - // Resolve and regist Notes - const limit = promiseLimit(2); - const featuredNotes = await Promise.all(items - .filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも - .slice(0, 5) - .map(item => limit(() => this.apNoteService.resolveNote(item, { - resolver: _resolver, - sentFrom: new URL(user.uri), - })))); - - await this.db.transaction(async transactionalEntityManager => { - await transactionalEntityManager.delete(MiUserNotePining, { userId: user.id }); - - // とりあえずidを別の時間で生成して順番を維持 - let td = 0; - for (const note of featuredNotes.filter((note): note is MiNote => note != null)) { - td -= 1000; - transactionalEntityManager.insert(MiUserNotePining, { - id: this.idService.gen(Date.now() + td), - userId: user.id, - noteId: note.id, - }); - } - }); - } - - /** - * リモート由来のアカウント移行処理を行います - * @param src 移行元アカウント(リモートかつupdatePerson後である必要がある、というかこれ自体がupdatePersonで呼ばれる前提) - * @param movePreventUris ここに列挙されたURIにsrc.movedToUriが含まれる場合、移行処理はしない(無限ループ防止) - */ - @bindThis - private async processRemoteMove(src: MiRemoteUser, movePreventUris: string[] = []): Promise { - if (!src.movedToUri) return 'skip: no movedToUri'; - if (src.uri === src.movedToUri) return 'skip: movedTo itself (src)'; // ??? - if (movePreventUris.length > 10) return 'skip: too many moves'; - - // まずサーバー内で検索して様子見 - let dst = await this.fetchPerson(src.movedToUri); - - if (dst && this.userEntityService.isLocalUser(dst)) { - // targetがローカルユーザーだった場合データベースから引っ張ってくる - dst = await this.usersRepository.findOneByOrFail({ uri: src.movedToUri }) as MiLocalUser; - } else if (dst) { - if (movePreventUris.includes(src.movedToUri)) return 'skip: circular move'; - - // targetを見つけたことがあるならtargetをupdatePersonする - await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]); - dst = await this.fetchPerson(src.movedToUri) ?? dst; - } else { - if (src.movedToUri.startsWith(`${this.config.url}/`)) { - // ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている - return 'failed: movedTo is local but not found'; - } - - // targetが知らない人だったらresolvePerson - // (uriが存在しなかったり応答がなかったりする場合resolvePersonはthrow Errorする) - dst = await this.resolvePerson(src.movedToUri); - } - - if (dst.movedToUri === dst.uri) return 'skip: movedTo itself (dst)'; // ??? - if (src.movedToUri !== dst.uri) return 'skip: missmatch uri'; // ??? - if (dst.movedToUri === src.uri) return 'skip: dst.movedToUri === src.uri'; - if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) { - return 'skip: dst.alsoKnownAs is empty'; - } - if (!dst.alsoKnownAs.includes(src.uri)) { - return 'skip: alsoKnownAs does not include from.uri'; - } - - await this.accountMoveService.postMoveProcess(src, dst); - - return 'ok'; - } -} diff --git a/packages/backend/src/core/activitypub/models/ApQuestionService.ts b/packages/backend/src/core/activitypub/models/ApQuestionService.ts deleted file mode 100644 index 27bd62268b..0000000000 --- a/packages/backend/src/core/activitypub/models/ApQuestionService.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { NotesRepository, PollsRepository } from '@/models/_.js'; -import type { Config } from '@/config.js'; -import type { IPoll } from '@/models/Poll.js'; -import type Logger from '@/logger.js'; -import { bindThis } from '@/decorators.js'; -import { isQuestion } from '../type.js'; -import { ApLoggerService } from '../ApLoggerService.js'; -import { ApResolverService } from '../ApResolverService.js'; -import type { Resolver } from '../ApResolverService.js'; -import type { IObject, IQuestion } from '../type.js'; - -@Injectable() -export class ApQuestionService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - private apResolverService: ApResolverService, - private apLoggerService: ApLoggerService, - ) { - this.logger = this.apLoggerService.logger; - } - - @bindThis - public async extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise { - // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); - - const question = await resolver.resolve(source); - if (!isQuestion(question)) throw new Error('invalid type'); - - const multiple = question.oneOf === undefined; - if (multiple && question.anyOf === undefined) throw new Error('invalid question'); - - const expiresAt = question.endTime ? new Date(question.endTime) : question.closed ? new Date(question.closed) : null; - - const choices = question[multiple ? 'anyOf' : 'oneOf'] - ?.map((x) => x.name) - .filter((x): x is string => typeof x === 'string') - ?? []; - - const votes = question[multiple ? 'anyOf' : 'oneOf']?.map((x) => x.replies?.totalItems ?? x._misskey_votes ?? 0); - - return { choices, votes, multiple, expiresAt }; - } - - /** - * Update votes of Question - * @param uri URI of AP Question object - * @returns true if updated - */ - @bindThis - public async updateQuestion(value: string | IObject, resolver?: Resolver): Promise { - const uri = typeof value === 'string' ? value : value.id; - if (uri == null) throw new Error('uri is null'); - - // URIがこのサーバーを指しているならスキップ - if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local'); - - //#region このサーバーに既に登録されているか - const note = await this.notesRepository.findOneBy({ uri }); - if (note == null) throw new Error('Question is not registed'); - - const poll = await this.pollsRepository.findOneBy({ noteId: note.id }); - if (poll == null) throw new Error('Question is not registed'); - //#endregion - - // resolve new Question object - // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); - const question = await resolver.resolve(value) as IQuestion; - this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`); - - if (question.type !== 'Question') throw new Error('object is not a Question'); - - const apChoices = question.oneOf ?? question.anyOf; - if (apChoices == null) throw new Error('invalid apChoices: ' + apChoices); - - let changed = false; - - for (const choice of poll.choices) { - const oldCount = poll.votes[poll.choices.indexOf(choice)]; - const newCount = apChoices.filter(ap => ap.name === choice).at(0)?.replies?.totalItems; - if (newCount == null) throw new Error('invalid newCount: ' + newCount); - - if (oldCount !== newCount) { - changed = true; - poll.votes[poll.choices.indexOf(choice)] = newCount; - } - } - - await this.pollsRepository.update({ noteId: note.id }, { votes: poll.votes }); - - return changed; - } -} diff --git a/packages/backend/src/core/activitypub/models/icon.ts b/packages/backend/src/core/activitypub/models/icon.ts deleted file mode 100644 index 9fed78020d..0000000000 --- a/packages/backend/src/core/activitypub/models/icon.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export type IIcon = { - type: string; - mediaType?: string; - url?: string; -}; diff --git a/packages/backend/src/core/activitypub/models/identifier.ts b/packages/backend/src/core/activitypub/models/identifier.ts deleted file mode 100644 index 22a7b0a76e..0000000000 --- a/packages/backend/src/core/activitypub/models/identifier.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export type IIdentifier = { - type: string; - name: string; - value: string; -}; diff --git a/packages/backend/src/core/activitypub/models/tag.ts b/packages/backend/src/core/activitypub/models/tag.ts deleted file mode 100644 index 772ea11864..0000000000 --- a/packages/backend/src/core/activitypub/models/tag.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { toArray } from '@/misc/prelude/array.js'; -import { isHashtag } from '../type.js'; -import type { IObject, IApHashtag } from '../type.js'; - -export function extractApHashtags(tags: IObject | IObject[] | null | undefined): string[] { - if (tags == null) return []; - - const hashtags = extractApHashtagObjects(tags); - - return hashtags.map(tag => { - const m = tag.name.match(/^#(.+)/); - return m ? m[1] : null; - }).filter((x): x is string => x != null); -} - -export function extractApHashtagObjects(tags: IObject | IObject[] | null | undefined): IApHashtag[] { - if (tags == null) return []; - return toArray(tags).filter(isHashtag); -} diff --git a/packages/backend/src/core/chart/ChartLoggerService.ts b/packages/backend/src/core/chart/ChartLoggerService.ts deleted file mode 100644 index bd90efec64..0000000000 --- a/packages/backend/src/core/chart/ChartLoggerService.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import type Logger from '@/logger.js'; -import { LoggerService } from '@/core/LoggerService.js'; - -@Injectable() -export class ChartLoggerService { - public logger: Logger; - - constructor( - private loggerService: LoggerService, - ) { - this.logger = this.loggerService.getLogger('chart', 'white', process.env.NODE_ENV !== 'test'); - } -} diff --git a/packages/backend/src/core/chart/ChartManagementService.ts b/packages/backend/src/core/chart/ChartManagementService.ts deleted file mode 100644 index f751a68cb4..0000000000 --- a/packages/backend/src/core/chart/ChartManagementService.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; - -import { bindThis } from '@/decorators.js'; -import FederationChart from './charts/federation.js'; -import NotesChart from './charts/notes.js'; -import UsersChart from './charts/users.js'; -import ActiveUsersChart from './charts/active-users.js'; -import InstanceChart from './charts/instance.js'; -import PerUserNotesChart from './charts/per-user-notes.js'; -import PerUserPvChart from './charts/per-user-pv.js'; -import DriveChart from './charts/drive.js'; -import PerUserReactionsChart from './charts/per-user-reactions.js'; -import PerUserFollowingChart from './charts/per-user-following.js'; -import PerUserDriveChart from './charts/per-user-drive.js'; -import ApRequestChart from './charts/ap-request.js'; -import type { OnApplicationShutdown } from '@nestjs/common'; - -@Injectable() -export class ChartManagementService implements OnApplicationShutdown { - private charts; - private saveIntervalId: NodeJS.Timeout; - - constructor( - private federationChart: FederationChart, - private notesChart: NotesChart, - private usersChart: UsersChart, - private activeUsersChart: ActiveUsersChart, - private instanceChart: InstanceChart, - private perUserNotesChart: PerUserNotesChart, - private perUserPvChart: PerUserPvChart, - private driveChart: DriveChart, - private perUserReactionsChart: PerUserReactionsChart, - private perUserFollowingChart: PerUserFollowingChart, - private perUserDriveChart: PerUserDriveChart, - private apRequestChart: ApRequestChart, - ) { - this.charts = [ - this.federationChart, - this.notesChart, - this.usersChart, - this.activeUsersChart, - this.instanceChart, - this.perUserNotesChart, - this.perUserPvChart, - this.driveChart, - this.perUserReactionsChart, - this.perUserFollowingChart, - this.perUserDriveChart, - this.apRequestChart, - ]; - } - - @bindThis - public async start() { - // 20分おきにメモリ情報をDBに書き込み - this.saveIntervalId = setInterval(() => { - for (const chart of this.charts) { - chart.save(); - } - }, 1000 * 60 * 20); - } - - @bindThis - public async dispose(): Promise { - clearInterval(this.saveIntervalId); - if (process.env.NODE_ENV !== 'test') { - await Promise.all( - this.charts.map(chart => chart.save()), - ); - } - } - - @bindThis - async onApplicationShutdown(signal: string): Promise { - await this.dispose(); - } -} diff --git a/packages/backend/src/core/chart/charts/active-users.ts b/packages/backend/src/core/chart/charts/active-users.ts deleted file mode 100644 index f0918e059c..0000000000 --- a/packages/backend/src/core/chart/charts/active-users.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable, Inject } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; -import type { MiUser } from '@/models/User.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import Chart from '../core.js'; -import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/active-users.js'; -import type { KVs } from '../core.js'; - -const week = 1000 * 60 * 60 * 24 * 7; -const month = 1000 * 60 * 60 * 24 * 30; -const year = 1000 * 60 * 60 * 24 * 365; - -/** - * アクティブユーザーに関するチャート - */ -@Injectable() -export default class ActiveUsersChart extends Chart { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.db) - private db: DataSource, - - private appLockService: AppLockService, - private chartLoggerService: ChartLoggerService, - private idService: IdService, - ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); - } - - protected async tickMajor(): Promise>> { - return {}; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - @bindThis - public async read(user: { id: MiUser['id'], host: null }): Promise { - const createdAt = this.idService.parse(user.id).date; - await this.commit({ - 'read': [user.id], - 'registeredWithinWeek': (Date.now() - createdAt.getTime() < week) ? [user.id] : [], - 'registeredWithinMonth': (Date.now() - createdAt.getTime() < month) ? [user.id] : [], - 'registeredWithinYear': (Date.now() - createdAt.getTime() < year) ? [user.id] : [], - 'registeredOutsideWeek': (Date.now() - createdAt.getTime() > week) ? [user.id] : [], - 'registeredOutsideMonth': (Date.now() - createdAt.getTime() > month) ? [user.id] : [], - 'registeredOutsideYear': (Date.now() - createdAt.getTime() > year) ? [user.id] : [], - }); - } - - @bindThis - public async write(user: { id: MiUser['id'], host: null }): Promise { - await this.commit({ - 'write': [user.id], - }); - } -} diff --git a/packages/backend/src/core/chart/charts/ap-request.ts b/packages/backend/src/core/chart/charts/ap-request.ts deleted file mode 100644 index 03c9b42be1..0000000000 --- a/packages/backend/src/core/chart/charts/ap-request.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable, Inject } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; -import Chart from '../core.js'; -import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/ap-request.js'; -import type { KVs } from '../core.js'; - -/** - * Chart about ActivityPub requests - */ -@Injectable() -export default class ApRequestChart extends Chart { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.db) - private db: DataSource, - - private appLockService: AppLockService, - private chartLoggerService: ChartLoggerService, - ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); - } - - protected async tickMajor(): Promise>> { - return {}; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - @bindThis - public async deliverSucc(): Promise { - await this.commit({ - 'deliverSucceeded': 1, - }); - } - - @bindThis - public async deliverFail(): Promise { - await this.commit({ - 'deliverFailed': 1, - }); - } - - @bindThis - public async inbox(): Promise { - await this.commit({ - 'inboxReceived': 1, - }); - } -} diff --git a/packages/backend/src/core/chart/charts/drive.ts b/packages/backend/src/core/chart/charts/drive.ts deleted file mode 100644 index bbcbf1a955..0000000000 --- a/packages/backend/src/core/chart/charts/drive.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable, Inject } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import { AppLockService } from '@/core/AppLockService.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; -import Chart from '../core.js'; -import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/drive.js'; -import type { KVs } from '../core.js'; - -/** - * ドライブに関するチャート - */ -@Injectable() -export default class DriveChart extends Chart { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.db) - private db: DataSource, - - private appLockService: AppLockService, - private chartLoggerService: ChartLoggerService, - ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); - } - - protected async tickMajor(): Promise>> { - return {}; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - @bindThis - public async update(file: MiDriveFile, isAdditional: boolean): Promise { - const fileSizeKb = file.size / 1000; - await this.commit(file.userHost === null ? { - 'local.incCount': isAdditional ? 1 : 0, - 'local.incSize': isAdditional ? fileSizeKb : 0, - 'local.decCount': isAdditional ? 0 : 1, - 'local.decSize': isAdditional ? 0 : fileSizeKb, - } : { - 'remote.incCount': isAdditional ? 1 : 0, - 'remote.incSize': isAdditional ? fileSizeKb : 0, - 'remote.decCount': isAdditional ? 0 : 1, - 'remote.decSize': isAdditional ? 0 : fileSizeKb, - }); - } -} diff --git a/packages/backend/src/core/chart/charts/entities/active-users.ts b/packages/backend/src/core/chart/charts/entities/active-users.ts deleted file mode 100644 index e68022ef29..0000000000 --- a/packages/backend/src/core/chart/charts/entities/active-users.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import Chart from '../../core.js'; - -export const name = 'activeUsers'; - -export const schema = { - 'readWrite': { intersection: ['read', 'write'] }, - 'read': { uniqueIncrement: true }, - 'write': { uniqueIncrement: true }, - 'registeredWithinWeek': { uniqueIncrement: true }, - 'registeredWithinMonth': { uniqueIncrement: true }, - 'registeredWithinYear': { uniqueIncrement: true }, - 'registeredOutsideWeek': { uniqueIncrement: true }, - 'registeredOutsideMonth': { uniqueIncrement: true }, - 'registeredOutsideYear': { uniqueIncrement: true }, -} as const; - -export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/entities/ap-request.ts b/packages/backend/src/core/chart/charts/entities/ap-request.ts deleted file mode 100644 index a824515255..0000000000 --- a/packages/backend/src/core/chart/charts/entities/ap-request.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import Chart from '../../core.js'; - -export const name = 'apRequest'; - -export const schema = { - 'deliverFailed': { }, - 'deliverSucceeded': { }, - 'inboxReceived': { }, -} as const; - -export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/entities/drive.ts b/packages/backend/src/core/chart/charts/entities/drive.ts deleted file mode 100644 index 4a56bd45c5..0000000000 --- a/packages/backend/src/core/chart/charts/entities/drive.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import Chart from '../../core.js'; - -export const name = 'drive'; - -export const schema = { - 'local.incCount': {}, - 'local.incSize': {}, // in kilobyte - 'local.decCount': {}, - 'local.decSize': {}, // in kilobyte - 'remote.incCount': {}, - 'remote.incSize': {}, // in kilobyte - 'remote.decCount': {}, - 'remote.decSize': {}, // in kilobyte -} as const; - -export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/entities/federation.ts b/packages/backend/src/core/chart/charts/entities/federation.ts deleted file mode 100644 index e067c71a7f..0000000000 --- a/packages/backend/src/core/chart/charts/entities/federation.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import Chart from '../../core.js'; - -export const name = 'federation'; - -export const schema = { - 'deliveredInstances': { uniqueIncrement: true, range: 'small' }, - 'inboxInstances': { uniqueIncrement: true, range: 'small' }, - 'stalled': { uniqueIncrement: true, range: 'small' }, - 'sub': { accumulate: true, range: 'small' }, - 'pub': { accumulate: true, range: 'small' }, - 'pubsub': { accumulate: true, range: 'small' }, - 'subActive': { accumulate: true, range: 'small' }, - 'pubActive': { accumulate: true, range: 'small' }, -} as const; - -export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/entities/instance.ts b/packages/backend/src/core/chart/charts/entities/instance.ts deleted file mode 100644 index 4ea10d56d1..0000000000 --- a/packages/backend/src/core/chart/charts/entities/instance.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import Chart from '../../core.js'; - -export const name = 'instance'; - -export const schema = { - 'requests.failed': { range: 'small' }, - 'requests.succeeded': { range: 'small' }, - 'requests.received': { range: 'small' }, - 'notes.total': { accumulate: true }, - 'notes.inc': {}, - 'notes.dec': {}, - 'notes.diffs.normal': {}, - 'notes.diffs.reply': {}, - 'notes.diffs.renote': {}, - 'notes.diffs.withFile': {}, - 'users.total': { accumulate: true }, - 'users.inc': { range: 'small' }, - 'users.dec': { range: 'small' }, - 'following.total': { accumulate: true }, - 'following.inc': { range: 'small' }, - 'following.dec': { range: 'small' }, - 'followers.total': { accumulate: true }, - 'followers.inc': { range: 'small' }, - 'followers.dec': { range: 'small' }, - 'drive.totalFiles': { accumulate: true }, - 'drive.incFiles': {}, - 'drive.decFiles': {}, - 'drive.incUsage': {}, // in kilobyte - 'drive.decUsage': {}, // in kilobyte -} as const; - -export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/core/chart/charts/entities/notes.ts b/packages/backend/src/core/chart/charts/entities/notes.ts deleted file mode 100644 index 26e2529b17..0000000000 --- a/packages/backend/src/core/chart/charts/entities/notes.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import Chart from '../../core.js'; - -export const name = 'notes'; - -export const schema = { - 'local.total': { accumulate: true }, - 'local.inc': {}, - 'local.dec': {}, - 'local.diffs.normal': {}, - 'local.diffs.reply': {}, - 'local.diffs.renote': {}, - 'local.diffs.withFile': {}, - 'remote.total': { accumulate: true }, - 'remote.inc': {}, - 'remote.dec': {}, - 'remote.diffs.normal': {}, - 'remote.diffs.reply': {}, - 'remote.diffs.renote': {}, - 'remote.diffs.withFile': {}, -} as const; - -export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/entities/per-user-drive.ts b/packages/backend/src/core/chart/charts/entities/per-user-drive.ts deleted file mode 100644 index aec3dd5140..0000000000 --- a/packages/backend/src/core/chart/charts/entities/per-user-drive.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import Chart from '../../core.js'; - -export const name = 'perUserDrive'; - -export const schema = { - 'totalCount': { accumulate: true }, - 'totalSize': { accumulate: true }, // in kilobyte - 'incCount': { range: 'small' }, - 'incSize': {}, // in kilobyte - 'decCount': { range: 'small' }, - 'decSize': {}, // in kilobyte -} as const; - -export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/core/chart/charts/entities/per-user-following.ts b/packages/backend/src/core/chart/charts/entities/per-user-following.ts deleted file mode 100644 index afb5813058..0000000000 --- a/packages/backend/src/core/chart/charts/entities/per-user-following.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import Chart from '../../core.js'; - -export const name = 'perUserFollowing'; - -export const schema = { - 'local.followings.total': { accumulate: true }, - 'local.followings.inc': { range: 'small' }, - 'local.followings.dec': { range: 'small' }, - 'local.followers.total': { accumulate: true }, - 'local.followers.inc': { range: 'small' }, - 'local.followers.dec': { range: 'small' }, - 'remote.followings.total': { accumulate: true }, - 'remote.followings.inc': { range: 'small' }, - 'remote.followings.dec': { range: 'small' }, - 'remote.followers.total': { accumulate: true }, - 'remote.followers.inc': { range: 'small' }, - 'remote.followers.dec': { range: 'small' }, -} as const; - -export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/core/chart/charts/entities/per-user-notes.ts b/packages/backend/src/core/chart/charts/entities/per-user-notes.ts deleted file mode 100644 index 60a0b01c8e..0000000000 --- a/packages/backend/src/core/chart/charts/entities/per-user-notes.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import Chart from '../../core.js'; - -export const name = 'perUserNotes'; - -export const schema = { - 'total': { accumulate: true }, - 'inc': { range: 'small' }, - 'dec': { range: 'small' }, - 'diffs.normal': { range: 'small' }, - 'diffs.reply': { range: 'small' }, - 'diffs.renote': { range: 'small' }, - 'diffs.withFile': { range: 'small' }, -} as const; - -export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/core/chart/charts/entities/per-user-pv.ts b/packages/backend/src/core/chart/charts/entities/per-user-pv.ts deleted file mode 100644 index 78d4464d7e..0000000000 --- a/packages/backend/src/core/chart/charts/entities/per-user-pv.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import Chart from '../../core.js'; - -export const name = 'perUserPv'; - -export const schema = { - 'upv.user': { uniqueIncrement: true, range: 'small' }, - 'pv.user': { range: 'small' }, - 'upv.visitor': { uniqueIncrement: true, range: 'small' }, - 'pv.visitor': { range: 'small' }, -} as const; - -export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/core/chart/charts/entities/per-user-reactions.ts b/packages/backend/src/core/chart/charts/entities/per-user-reactions.ts deleted file mode 100644 index 761101d479..0000000000 --- a/packages/backend/src/core/chart/charts/entities/per-user-reactions.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import Chart from '../../core.js'; - -export const name = 'perUserReaction'; - -export const schema = { - 'local.count': { range: 'small' }, - 'remote.count': { range: 'small' }, -} as const; - -export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/core/chart/charts/entities/test-grouped.ts b/packages/backend/src/core/chart/charts/entities/test-grouped.ts deleted file mode 100644 index 15eb1fd1f8..0000000000 --- a/packages/backend/src/core/chart/charts/entities/test-grouped.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import Chart from '../../core.js'; - -export const name = 'testGrouped'; - -export const schema = { - 'foo.total': { accumulate: true }, - 'foo.inc': {}, - 'foo.dec': {}, -} as const; - -export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/core/chart/charts/entities/test-intersection.ts b/packages/backend/src/core/chart/charts/entities/test-intersection.ts deleted file mode 100644 index 2ef63977a5..0000000000 --- a/packages/backend/src/core/chart/charts/entities/test-intersection.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import Chart from '../../core.js'; - -export const name = 'testIntersection'; - -export const schema = { - 'a': { uniqueIncrement: true }, - 'b': { uniqueIncrement: true }, - 'aAndB': { intersection: ['a', 'b'] }, -} as const; - -export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/entities/test-unique.ts b/packages/backend/src/core/chart/charts/entities/test-unique.ts deleted file mode 100644 index 56233585db..0000000000 --- a/packages/backend/src/core/chart/charts/entities/test-unique.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import Chart from '../../core.js'; - -export const name = 'testUnique'; - -export const schema = { - 'foo': { uniqueIncrement: true }, -} as const; - -export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/entities/test.ts b/packages/backend/src/core/chart/charts/entities/test.ts deleted file mode 100644 index 163db4e79f..0000000000 --- a/packages/backend/src/core/chart/charts/entities/test.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import Chart from '../../core.js'; - -export const name = 'test'; - -export const schema = { - 'foo.total': { accumulate: true }, - 'foo.inc': {}, - 'foo.dec': {}, -} as const; - -export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/entities/users.ts b/packages/backend/src/core/chart/charts/entities/users.ts deleted file mode 100644 index c7bffd3fd4..0000000000 --- a/packages/backend/src/core/chart/charts/entities/users.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import Chart from '../../core.js'; - -export const name = 'users'; - -export const schema = { - 'local.total': { accumulate: true }, - 'local.inc': { range: 'small' }, - 'local.dec': { range: 'small' }, - 'remote.total': { accumulate: true }, - 'remote.inc': { range: 'small' }, - 'remote.dec': { range: 'small' }, -} as const; - -export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/federation.ts b/packages/backend/src/core/chart/charts/federation.ts deleted file mode 100644 index 0d3f1b3f2c..0000000000 --- a/packages/backend/src/core/chart/charts/federation.ts +++ /dev/null @@ -1,136 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable, Inject } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import type { FollowingsRepository, InstancesRepository } from '@/models/_.js'; -import { AppLockService } from '@/core/AppLockService.js'; -import { DI } from '@/di-symbols.js'; -import { MetaService } from '@/core/MetaService.js'; -import { bindThis } from '@/decorators.js'; -import Chart from '../core.js'; -import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/federation.js'; -import type { KVs } from '../core.js'; - -/** - * フェデレーションに関するチャート - */ -@Injectable() -export default class FederationChart extends Chart { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, - - private metaService: MetaService, - private appLockService: AppLockService, - private chartLoggerService: ChartLoggerService, - ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); - } - - protected async tickMajor(): Promise>> { - return { - }; - } - - protected async tickMinor(): Promise>> { - const meta = await this.metaService.fetch(); - - const suspendedInstancesQuery = this.instancesRepository.createQueryBuilder('instance') - .select('instance.host') - .where('instance.isSuspended = true'); - - const pubsubSubQuery = this.followingsRepository.createQueryBuilder('f') - .select('f.followerHost') - .where('f.followerHost IS NOT NULL'); - - const subInstancesQuery = this.followingsRepository.createQueryBuilder('f') - .select('f.followeeHost') - .where('f.followeeHost IS NOT NULL'); - - const pubInstancesQuery = this.followingsRepository.createQueryBuilder('f') - .select('f.followerHost') - .where('f.followerHost IS NOT NULL'); - - const skipAllowlistQuery = !meta.allowlistMode || meta.allowedHosts.length === 0; - const [sub, pub, pubsub, subActive, pubActive] = await Promise.all([ - 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(skipAllowlistQuery ? '1=1' : 'following.followeeHost ILIKE ANY(ARRAY[:...allowed])', { allowed: meta.allowedHosts.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(skipAllowlistQuery ? '1=1' : 'following.followerHost ILIKE ANY(ARRAY[:...allowed])', { allowed: meta.allowedHosts.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(skipAllowlistQuery ? '1=1' : 'following.followeeHost ILIKE ANY(ARRAY[:...allowed])', { allowed: meta.allowedHosts.flatMap(x => [x, `%.${x}`]) }) - .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) - .andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`) - .setParameters(pubsubSubQuery.getParameters()) - .getRawOne() - .then(x => parseInt(x.count, 10)), - 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(skipAllowlistQuery ? '1=1' : 'instance.host ILIKE ANY(ARRAY[:...allowed])', { allowed: meta.allowedHosts.flatMap(x => [x, `%.${x}`]) }) - .andWhere('instance.isSuspended = false') - .andWhere('instance.isNotResponding = false') - .getRawOne() - .then(x => parseInt(x.count, 10)), - 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(skipAllowlistQuery ? '1=1' : 'instance.host ILIKE ANY(ARRAY[:...allowed])', { allowed: meta.allowedHosts.flatMap(x => [x, `%.${x}`]) }) - .andWhere('instance.isSuspended = false') - .andWhere('instance.isNotResponding = false') - .getRawOne() - .then(x => parseInt(x.count, 10)), - ]); - - return { - 'sub': sub, - 'pub': pub, - 'pubsub': pubsub, - 'subActive': subActive, - 'pubActive': pubActive, - }; - } - - @bindThis - public async deliverd(host: string, succeeded: boolean): Promise { - await this.commit(succeeded ? { - 'deliveredInstances': [host], - } : { - 'stalled': [host], - }); - } - - @bindThis - public async inbox(host: string): Promise { - await this.commit({ - 'inboxInstances': [host], - }); - } -} diff --git a/packages/backend/src/core/chart/charts/instance.ts b/packages/backend/src/core/chart/charts/instance.ts deleted file mode 100644 index 9df0afb02e..0000000000 --- a/packages/backend/src/core/chart/charts/instance.ts +++ /dev/null @@ -1,141 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable, Inject } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import type { DriveFilesRepository, FollowingsRepository, UsersRepository, NotesRepository } from '@/models/_.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiNote } from '@/models/Note.js'; -import { AppLockService } from '@/core/AppLockService.js'; -import { DI } from '@/di-symbols.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; -import Chart from '../core.js'; -import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/instance.js'; -import type { KVs } from '../core.js'; - -/** - * インスタンスごとのチャート - */ -@Injectable() -export default class InstanceChart extends Chart { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - private utilityService: UtilityService, - private appLockService: AppLockService, - private chartLoggerService: ChartLoggerService, - ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); - } - - protected async tickMajor(group: string): Promise>> { - const [ - notesCount, - usersCount, - followingCount, - followersCount, - driveFiles, - ] = await Promise.all([ - this.notesRepository.countBy({ userHost: group }), - this.usersRepository.countBy({ host: group }), - this.followingsRepository.countBy({ followerHost: group }), - this.followingsRepository.countBy({ followeeHost: group }), - this.driveFilesRepository.countBy({ userHost: group }), - ]); - - return { - 'notes.total': notesCount, - 'users.total': usersCount, - 'following.total': followingCount, - 'followers.total': followersCount, - 'drive.totalFiles': driveFiles, - }; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - @bindThis - public async requestReceived(host: string): Promise { - await this.commit({ - 'requests.received': 1, - }, this.utilityService.toPuny(host)); - } - - @bindThis - public async requestSent(host: string, isSucceeded: boolean): Promise { - await this.commit({ - 'requests.succeeded': isSucceeded ? 1 : 0, - 'requests.failed': isSucceeded ? 0 : 1, - }, this.utilityService.toPuny(host)); - } - - @bindThis - public async newUser(host: string): Promise { - await this.commit({ - 'users.total': 1, - 'users.inc': 1, - }, this.utilityService.toPuny(host)); - } - - @bindThis - public async updateNote(host: string, note: MiNote, isAdditional: boolean): Promise { - await this.commit({ - 'notes.total': isAdditional ? 1 : -1, - 'notes.inc': isAdditional ? 1 : 0, - 'notes.dec': isAdditional ? 0 : 1, - 'notes.diffs.normal': note.replyId == null && note.renoteId == null ? (isAdditional ? 1 : -1) : 0, - 'notes.diffs.renote': note.renoteId != null ? (isAdditional ? 1 : -1) : 0, - 'notes.diffs.reply': note.replyId != null ? (isAdditional ? 1 : -1) : 0, - 'notes.diffs.withFile': note.fileIds.length > 0 ? (isAdditional ? 1 : -1) : 0, - }, this.utilityService.toPuny(host)); - } - - @bindThis - public async updateFollowing(host: string, isAdditional: boolean): Promise { - await this.commit({ - 'following.total': isAdditional ? 1 : -1, - 'following.inc': isAdditional ? 1 : 0, - 'following.dec': isAdditional ? 0 : 1, - }, this.utilityService.toPuny(host)); - } - - @bindThis - public async updateFollowers(host: string, isAdditional: boolean): Promise { - await this.commit({ - 'followers.total': isAdditional ? 1 : -1, - 'followers.inc': isAdditional ? 1 : 0, - 'followers.dec': isAdditional ? 0 : 1, - }, this.utilityService.toPuny(host)); - } - - @bindThis - public async updateDrive(file: MiDriveFile, isAdditional: boolean): Promise { - const fileSizeKb = file.size / 1000; - await this.commit({ - 'drive.totalFiles': isAdditional ? 1 : -1, - 'drive.incFiles': isAdditional ? 1 : 0, - 'drive.incUsage': isAdditional ? fileSizeKb : 0, - 'drive.decFiles': isAdditional ? 1 : 0, - 'drive.decUsage': isAdditional ? fileSizeKb : 0, - }, file.userHost); - } -} diff --git a/packages/backend/src/core/chart/charts/notes.ts b/packages/backend/src/core/chart/charts/notes.ts deleted file mode 100644 index df3295dbac..0000000000 --- a/packages/backend/src/core/chart/charts/notes.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable, Inject } from '@nestjs/common'; -import { Not, IsNull, DataSource } from 'typeorm'; -import type { NotesRepository } from '@/models/_.js'; -import type { MiNote } from '@/models/Note.js'; -import { AppLockService } from '@/core/AppLockService.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; -import Chart from '../core.js'; -import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/notes.js'; -import type { KVs } from '../core.js'; - -/** - * ノートに関するチャート - */ -@Injectable() -export default class NotesChart extends Chart { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - private appLockService: AppLockService, - private chartLoggerService: ChartLoggerService, - ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); - } - - protected async tickMajor(): Promise>> { - const [localCount, remoteCount] = await Promise.all([ - this.notesRepository.countBy({ userHost: IsNull() }), - this.notesRepository.countBy({ userHost: Not(IsNull()) }), - ]); - - return { - 'local.total': localCount, - 'remote.total': remoteCount, - }; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - @bindThis - public async update(note: MiNote, isAdditional: boolean): Promise { - const prefix = note.userHost === null ? 'local' : 'remote'; - - await this.commit({ - [`${prefix}.total`]: isAdditional ? 1 : -1, - [`${prefix}.inc`]: isAdditional ? 1 : 0, - [`${prefix}.dec`]: isAdditional ? 0 : 1, - [`${prefix}.diffs.normal`]: note.replyId == null && note.renoteId == null ? (isAdditional ? 1 : -1) : 0, - [`${prefix}.diffs.renote`]: note.renoteId != null ? (isAdditional ? 1 : -1) : 0, - [`${prefix}.diffs.reply`]: note.replyId != null ? (isAdditional ? 1 : -1) : 0, - [`${prefix}.diffs.withFile`]: note.fileIds.length > 0 ? (isAdditional ? 1 : -1) : 0, - }); - } -} diff --git a/packages/backend/src/core/chart/charts/per-user-drive.ts b/packages/backend/src/core/chart/charts/per-user-drive.ts deleted file mode 100644 index 18354359c8..0000000000 --- a/packages/backend/src/core/chart/charts/per-user-drive.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable, Inject } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import type { DriveFilesRepository } from '@/models/_.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import { AppLockService } from '@/core/AppLockService.js'; -import { DI } from '@/di-symbols.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { bindThis } from '@/decorators.js'; -import Chart from '../core.js'; -import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/per-user-drive.js'; -import type { KVs } from '../core.js'; - -/** - * ユーザーごとのドライブに関するチャート - */ -@Injectable() -export default class PerUserDriveChart extends Chart { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private appLockService: AppLockService, - private driveFileEntityService: DriveFileEntityService, - private chartLoggerService: ChartLoggerService, - ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); - } - - protected async tickMajor(group: string): Promise>> { - const [count, size] = await Promise.all([ - this.driveFilesRepository.countBy({ userId: group }), - this.driveFileEntityService.calcDriveUsageOf(group), - ]); - - return { - 'totalCount': count, - 'totalSize': size, - }; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - @bindThis - public async update(file: MiDriveFile, isAdditional: boolean): Promise { - const fileSizeKb = file.size / 1000; - await this.commit({ - 'totalCount': isAdditional ? 1 : -1, - 'totalSize': isAdditional ? fileSizeKb : -fileSizeKb, - 'incCount': isAdditional ? 1 : 0, - 'incSize': isAdditional ? fileSizeKb : 0, - 'decCount': isAdditional ? 0 : 1, - 'decSize': isAdditional ? 0 : fileSizeKb, - }, file.userId); - } -} diff --git a/packages/backend/src/core/chart/charts/per-user-following.ts b/packages/backend/src/core/chart/charts/per-user-following.ts deleted file mode 100644 index 79bff2cb66..0000000000 --- a/packages/backend/src/core/chart/charts/per-user-following.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable, Inject } from '@nestjs/common'; -import { Not, IsNull, DataSource } from 'typeorm'; -import type { MiUser } from '@/models/User.js'; -import { AppLockService } from '@/core/AppLockService.js'; -import { DI } from '@/di-symbols.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import type { FollowingsRepository } from '@/models/_.js'; -import { bindThis } from '@/decorators.js'; -import Chart from '../core.js'; -import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/per-user-following.js'; -import type { KVs } from '../core.js'; - -/** - * ユーザーごとのフォローに関するチャート - */ -@Injectable() -export default class PerUserFollowingChart extends Chart { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - private appLockService: AppLockService, - private userEntityService: UserEntityService, - private chartLoggerService: ChartLoggerService, - ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); - } - - protected async tickMajor(group: string): Promise>> { - const [ - localFollowingsCount, - localFollowersCount, - remoteFollowingsCount, - remoteFollowersCount, - ] = await Promise.all([ - this.followingsRepository.countBy({ followerId: group, followeeHost: IsNull() }), - this.followingsRepository.countBy({ followeeId: group, followerHost: IsNull() }), - this.followingsRepository.countBy({ followerId: group, followeeHost: Not(IsNull()) }), - this.followingsRepository.countBy({ followeeId: group, followerHost: Not(IsNull()) }), - ]); - - return { - 'local.followings.total': localFollowingsCount, - 'local.followers.total': localFollowersCount, - 'remote.followings.total': remoteFollowingsCount, - 'remote.followers.total': remoteFollowersCount, - }; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - @bindThis - public async update(follower: { id: MiUser['id']; host: MiUser['host']; }, followee: { id: MiUser['id']; host: MiUser['host']; }, isFollow: boolean): Promise { - const prefixFollower = this.userEntityService.isLocalUser(follower) ? 'local' : 'remote'; - const prefixFollowee = this.userEntityService.isLocalUser(followee) ? 'local' : 'remote'; - - this.commit({ - [`${prefixFollower}.followings.total`]: isFollow ? 1 : -1, - [`${prefixFollower}.followings.inc`]: isFollow ? 1 : 0, - [`${prefixFollower}.followings.dec`]: isFollow ? 0 : 1, - }, follower.id); - this.commit({ - [`${prefixFollowee}.followers.total`]: isFollow ? 1 : -1, - [`${prefixFollowee}.followers.inc`]: isFollow ? 1 : 0, - [`${prefixFollowee}.followers.dec`]: isFollow ? 0 : 1, - }, followee.id); - } -} diff --git a/packages/backend/src/core/chart/charts/per-user-notes.ts b/packages/backend/src/core/chart/charts/per-user-notes.ts deleted file mode 100644 index 0db0e6f07f..0000000000 --- a/packages/backend/src/core/chart/charts/per-user-notes.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable, Inject } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import type { MiUser } from '@/models/User.js'; -import type { MiNote } from '@/models/Note.js'; -import { AppLockService } from '@/core/AppLockService.js'; -import { DI } from '@/di-symbols.js'; -import type { NotesRepository } from '@/models/_.js'; -import { bindThis } from '@/decorators.js'; -import Chart from '../core.js'; -import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/per-user-notes.js'; -import type { KVs } from '../core.js'; - -/** - * ユーザーごとのノートに関するチャート - */ -@Injectable() -export default class PerUserNotesChart extends Chart { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - private appLockService: AppLockService, - private chartLoggerService: ChartLoggerService, - ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); - } - - protected async tickMajor(group: string): Promise>> { - const [count] = await Promise.all([ - this.notesRepository.countBy({ userId: group }), - ]); - - return { - total: count, - }; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - @bindThis - public update(user: { id: MiUser['id'] }, note: MiNote, isAdditional: boolean): void { - this.commit({ - 'total': isAdditional ? 1 : -1, - 'inc': isAdditional ? 1 : 0, - 'dec': isAdditional ? 0 : 1, - 'diffs.normal': note.replyId == null && note.renoteId == null ? (isAdditional ? 1 : -1) : 0, - 'diffs.renote': note.renoteId != null ? (isAdditional ? 1 : -1) : 0, - 'diffs.reply': note.replyId != null ? (isAdditional ? 1 : -1) : 0, - 'diffs.withFile': note.fileIds.length > 0 ? (isAdditional ? 1 : -1) : 0, - }, user.id); - } -} diff --git a/packages/backend/src/core/chart/charts/per-user-pv.ts b/packages/backend/src/core/chart/charts/per-user-pv.ts deleted file mode 100644 index cf1b4c71f6..0000000000 --- a/packages/backend/src/core/chart/charts/per-user-pv.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable, Inject } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import type { MiUser } from '@/models/User.js'; -import { AppLockService } from '@/core/AppLockService.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; -import Chart from '../core.js'; -import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/per-user-pv.js'; -import type { KVs } from '../core.js'; - -/** - * ユーザーごとのプロフィール被閲覧数に関するチャート - */ -@Injectable() -export default class PerUserPvChart extends Chart { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.db) - private db: DataSource, - - private appLockService: AppLockService, - private chartLoggerService: ChartLoggerService, - ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); - } - - protected async tickMajor(): Promise>> { - return {}; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - @bindThis - public async commitByUser(user: { id: MiUser['id'] }, key: string): Promise { - await this.commit({ - 'upv.user': [key], - 'pv.user': 1, - }, user.id); - } - - @bindThis - public async commitByVisitor(user: { id: MiUser['id'] }, key: string): Promise { - await this.commit({ - 'upv.visitor': [key], - 'pv.visitor': 1, - }, user.id); - } -} diff --git a/packages/backend/src/core/chart/charts/per-user-reactions.ts b/packages/backend/src/core/chart/charts/per-user-reactions.ts deleted file mode 100644 index 9f4f6e9651..0000000000 --- a/packages/backend/src/core/chart/charts/per-user-reactions.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable, Inject } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import type { MiUser } from '@/models/User.js'; -import type { MiNote } from '@/models/Note.js'; -import { AppLockService } from '@/core/AppLockService.js'; -import { DI } from '@/di-symbols.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { bindThis } from '@/decorators.js'; -import Chart from '../core.js'; -import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/per-user-reactions.js'; -import type { KVs } from '../core.js'; - -/** - * ユーザーごとのリアクションに関するチャート - */ -@Injectable() -export default class PerUserReactionsChart extends Chart { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.db) - private db: DataSource, - - private appLockService: AppLockService, - private userEntityService: UserEntityService, - private chartLoggerService: ChartLoggerService, - ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); - } - - protected async tickMajor(group: string): Promise>> { - return {}; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - @bindThis - public async update(user: { id: MiUser['id'], host: MiUser['host'] }, note: MiNote): Promise { - const prefix = this.userEntityService.isLocalUser(user) ? 'local' : 'remote'; - this.commit({ - [`${prefix}.count`]: 1, - }, note.userId); - } -} diff --git a/packages/backend/src/core/chart/charts/test-grouped.ts b/packages/backend/src/core/chart/charts/test-grouped.ts deleted file mode 100644 index 00fb872237..0000000000 --- a/packages/backend/src/core/chart/charts/test-grouped.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable, Inject } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; -import { DI } from '@/di-symbols.js'; -import Logger from '@/logger.js'; -import { bindThis } from '@/decorators.js'; -import Chart from '../core.js'; -import { name, schema } from './entities/test-grouped.js'; -import type { KVs } from '../core.js'; - -/** - * For testing - */ -@Injectable() -export default class TestGroupedChart extends Chart { // eslint-disable-line import/no-default-export - private total = {} as Record; - - constructor( - @Inject(DI.db) - private db: DataSource, - - private appLockService: AppLockService, - logger: Logger, - ) { - super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema, true); - } - - protected async tickMajor(group: string): Promise>> { - return { - 'foo.total': this.total[group], - }; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - @bindThis - public async increment(group: string): Promise { - if (this.total[group] == null) this.total[group] = 0; - - this.total[group]++; - - await this.commit({ - 'foo.total': 1, - 'foo.inc': 1, - }, group); - } -} diff --git a/packages/backend/src/core/chart/charts/test-intersection.ts b/packages/backend/src/core/chart/charts/test-intersection.ts deleted file mode 100644 index 45a7e805c5..0000000000 --- a/packages/backend/src/core/chart/charts/test-intersection.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable, Inject } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; -import { DI } from '@/di-symbols.js'; -import Logger from '@/logger.js'; -import { bindThis } from '@/decorators.js'; -import Chart from '../core.js'; -import { name, schema } from './entities/test-intersection.js'; -import type { KVs } from '../core.js'; - -/** - * For testing - */ -@Injectable() -export default class TestIntersectionChart extends Chart { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.db) - private db: DataSource, - - private appLockService: AppLockService, - logger: Logger, - ) { - super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema); - } - - protected async tickMajor(): Promise>> { - return {}; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - @bindThis - public async addA(key: string): Promise { - await this.commit({ - a: [key], - }); - } - - @bindThis - public async addB(key: string): Promise { - await this.commit({ - b: [key], - }); - } -} diff --git a/packages/backend/src/core/chart/charts/test-unique.ts b/packages/backend/src/core/chart/charts/test-unique.ts deleted file mode 100644 index e9d38eaf13..0000000000 --- a/packages/backend/src/core/chart/charts/test-unique.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable, Inject } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; -import { DI } from '@/di-symbols.js'; -import Logger from '@/logger.js'; -import { bindThis } from '@/decorators.js'; -import Chart from '../core.js'; -import { name, schema } from './entities/test-unique.js'; -import type { KVs } from '../core.js'; - -/** - * For testing - */ -@Injectable() -export default class TestUniqueChart extends Chart { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.db) - private db: DataSource, - - private appLockService: AppLockService, - logger: Logger, - ) { - super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema); - } - - protected async tickMajor(): Promise>> { - return {}; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - @bindThis - public async uniqueIncrement(key: string): Promise { - await this.commit({ - foo: [key], - }); - } -} diff --git a/packages/backend/src/core/chart/charts/test.ts b/packages/backend/src/core/chart/charts/test.ts deleted file mode 100644 index 4dd6063b5b..0000000000 --- a/packages/backend/src/core/chart/charts/test.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable, Inject } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; -import { DI } from '@/di-symbols.js'; -import Logger from '@/logger.js'; -import { bindThis } from '@/decorators.js'; -import Chart from '../core.js'; -import { name, schema } from './entities/test.js'; -import type { KVs } from '../core.js'; - -/** - * For testing - */ -@Injectable() -export default class TestChart extends Chart { // eslint-disable-line import/no-default-export - public total = 0; // publicにするのはテストのため - - constructor( - @Inject(DI.db) - private db: DataSource, - - private appLockService: AppLockService, - logger: Logger, - ) { - super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema); - } - - protected async tickMajor(): Promise>> { - return { - 'foo.total': this.total, - }; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - @bindThis - public async increment(): Promise { - this.total++; - - await this.commit({ - 'foo.total': 1, - 'foo.inc': 1, - }); - } - - @bindThis - public async decrement(): Promise { - this.total--; - - await this.commit({ - 'foo.total': -1, - 'foo.dec': 1, - }); - } -} diff --git a/packages/backend/src/core/chart/charts/users.ts b/packages/backend/src/core/chart/charts/users.ts deleted file mode 100644 index c2026c2aea..0000000000 --- a/packages/backend/src/core/chart/charts/users.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable, Inject } from '@nestjs/common'; -import { Not, IsNull, DataSource } from 'typeorm'; -import type { MiUser } from '@/models/User.js'; -import { AppLockService } from '@/core/AppLockService.js'; -import { DI } from '@/di-symbols.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import type { UsersRepository } from '@/models/_.js'; -import { bindThis } from '@/decorators.js'; -import Chart from '../core.js'; -import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/users.js'; -import type { KVs } from '../core.js'; - -/** - * ユーザー数に関するチャート - */ -@Injectable() -export default class UsersChart extends Chart { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private appLockService: AppLockService, - private userEntityService: UserEntityService, - private chartLoggerService: ChartLoggerService, - ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); - } - - protected async tickMajor(): Promise>> { - const [localCount, remoteCount] = await Promise.all([ - this.usersRepository.countBy({ host: IsNull() }), - this.usersRepository.countBy({ host: Not(IsNull()) }), - ]); - - return { - 'local.total': localCount, - 'remote.total': remoteCount, - }; - } - - protected async tickMinor(): Promise>> { - return {}; - } - - @bindThis - public async update(user: { id: MiUser['id'], host: MiUser['host'] }, isAdditional: boolean): Promise { - const prefix = this.userEntityService.isLocalUser(user) ? 'local' : 'remote'; - - await this.commit({ - [`${prefix}.total`]: isAdditional ? 1 : -1, - [`${prefix}.inc`]: isAdditional ? 1 : 0, - [`${prefix}.dec`]: isAdditional ? 0 : 1, - }); - } -} diff --git a/packages/backend/src/core/chart/entities.ts b/packages/backend/src/core/chart/entities.ts deleted file mode 100644 index b6a1299a2f..0000000000 --- a/packages/backend/src/core/chart/entities.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { entity as FederationChart } from './charts/entities/federation.js'; -import { entity as NotesChart } from './charts/entities/notes.js'; -import { entity as UsersChart } from './charts/entities/users.js'; -import { entity as ActiveUsersChart } from './charts/entities/active-users.js'; -import { entity as InstanceChart } from './charts/entities/instance.js'; -import { entity as PerUserNotesChart } from './charts/entities/per-user-notes.js'; -import { entity as PerUserPvChart } from './charts/entities/per-user-pv.js'; -import { entity as DriveChart } from './charts/entities/drive.js'; -import { entity as PerUserReactionsChart } from './charts/entities/per-user-reactions.js'; -import { entity as PerUserFollowingChart } from './charts/entities/per-user-following.js'; -import { entity as PerUserDriveChart } from './charts/entities/per-user-drive.js'; -import { entity as ApRequestChart } from './charts/entities/ap-request.js'; - -import { entity as TestChart } from './charts/entities/test.js'; -import { entity as TestGroupedChart } from './charts/entities/test-grouped.js'; -import { entity as TestUniqueChart } from './charts/entities/test-unique.js'; -import { entity as TestIntersectionChart } from './charts/entities/test-intersection.js'; - -export const entities = [ - FederationChart.hour, FederationChart.day, - NotesChart.hour, NotesChart.day, - UsersChart.hour, UsersChart.day, - ActiveUsersChart.hour, ActiveUsersChart.day, - InstanceChart.hour, InstanceChart.day, - PerUserNotesChart.hour, PerUserNotesChart.day, - PerUserPvChart.hour, PerUserPvChart.day, - DriveChart.hour, DriveChart.day, - PerUserReactionsChart.hour, PerUserReactionsChart.day, - PerUserFollowingChart.hour, PerUserFollowingChart.day, - PerUserDriveChart.hour, PerUserDriveChart.day, - ApRequestChart.hour, ApRequestChart.day, - - ...(process.env.NODE_ENV === 'test' ? [ - TestChart.hour, TestChart.day, - TestGroupedChart.hour, TestGroupedChart.day, - TestUniqueChart.hour, TestUniqueChart.day, - TestIntersectionChart.hour, TestIntersectionChart.day, - ] : []), -]; diff --git a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts deleted file mode 100644 index 97de891ece..0000000000 --- a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { AbuseUserReportsRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import { UserEntityService } from './UserEntityService.js'; - -@Injectable() -export class AbuseUserReportEntityService { - constructor( - @Inject(DI.abuseUserReportsRepository) - private abuseUserReportsRepository: AbuseUserReportsRepository, - - private userEntityService: UserEntityService, - private idService: IdService, - ) { - } - - @bindThis - public async pack( - src: MiAbuseUserReport['id'] | MiAbuseUserReport, - ) { - const report = typeof src === 'object' ? src : await this.abuseUserReportsRepository.findOneByOrFail({ id: src }); - - return await awaitAll({ - id: report.id, - createdAt: this.idService.parse(report.id).date.toISOString(), - comment: report.comment, - resolved: report.resolved, - reporterId: report.reporterId, - targetUserId: report.targetUserId, - assigneeId: report.assigneeId, - reporter: this.userEntityService.pack(report.reporter ?? report.reporterId, null, { - detail: true, - }), - targetUser: this.userEntityService.pack(report.targetUser ?? report.targetUserId, null, { - detail: true, - }), - assignee: report.assigneeId ? this.userEntityService.pack(report.assignee ?? report.assigneeId, null, { - detail: true, - }) : null, - forwarded: report.forwarded, - }); - } - - @bindThis - public packMany( - reports: any[], - ) { - return Promise.all(reports.map(x => this.pack(x))); - } -} diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts deleted file mode 100644 index 265a61e8ad..0000000000 --- a/packages/backend/src/core/entities/AntennaEntityService.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { AntennasRepository } from '@/models/_.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { MiAntenna } from '@/models/Antenna.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; - -@Injectable() -export class AntennaEntityService { - constructor( - @Inject(DI.antennasRepository) - private antennasRepository: AntennasRepository, - - private idService: IdService, - ) { - } - - @bindThis - public async pack( - src: MiAntenna['id'] | MiAntenna, - ): Promise> { - const antenna = typeof src === 'object' ? src : await this.antennasRepository.findOneByOrFail({ id: src }); - - return { - id: antenna.id, - createdAt: this.idService.parse(antenna.id).date.toISOString(), - name: antenna.name, - keywords: antenna.keywords, - excludeKeywords: antenna.excludeKeywords, - src: antenna.src, - userListId: antenna.userListId, - users: antenna.users, - caseSensitive: antenna.caseSensitive, - localOnly: antenna.localOnly, - notify: antenna.notify, - withReplies: antenna.withReplies, - withFile: antenna.withFile, - isActive: antenna.isActive, - hasUnreadNote: false, // TODO - }; - } -} diff --git a/packages/backend/src/core/entities/AppEntityService.ts b/packages/backend/src/core/entities/AppEntityService.ts deleted file mode 100644 index 14a93cda5b..0000000000 --- a/packages/backend/src/core/entities/AppEntityService.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { AccessTokensRepository, AppsRepository } from '@/models/_.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { MiApp } from '@/models/App.js'; -import type { MiUser } from '@/models/User.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class AppEntityService { - constructor( - @Inject(DI.appsRepository) - private appsRepository: AppsRepository, - - @Inject(DI.accessTokensRepository) - private accessTokensRepository: AccessTokensRepository, - ) { - } - - @bindThis - public async pack( - src: MiApp['id'] | MiApp, - me?: { id: MiUser['id'] } | null | undefined, - options?: { - detail?: boolean, - includeSecret?: boolean, - includeProfileImageIds?: boolean - }, - ): Promise> { - const opts = Object.assign({ - detail: false, - includeSecret: false, - includeProfileImageIds: false, - }, options); - - const app = typeof src === 'object' ? src : await this.appsRepository.findOneByOrFail({ id: src }); - - return { - id: app.id, - name: app.name, - callbackUrl: app.callbackUrl, - permission: app.permission, - ...(opts.includeSecret ? { secret: app.secret } : {}), - ...(me ? { - isAuthorized: await this.accessTokensRepository.countBy({ - appId: app.id, - userId: me.id, - }).then(count => count > 0), - } : {}), - }; - } -} diff --git a/packages/backend/src/core/entities/AuthSessionEntityService.ts b/packages/backend/src/core/entities/AuthSessionEntityService.ts deleted file mode 100644 index fd356cc89d..0000000000 --- a/packages/backend/src/core/entities/AuthSessionEntityService.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { AuthSessionsRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { MiAuthSession } from '@/models/AuthSession.js'; -import type { MiUser } from '@/models/User.js'; -import { bindThis } from '@/decorators.js'; -import { AppEntityService } from './AppEntityService.js'; - -@Injectable() -export class AuthSessionEntityService { - constructor( - @Inject(DI.authSessionsRepository) - private authSessionsRepository: AuthSessionsRepository, - - private appEntityService: AppEntityService, - ) { - } - - @bindThis - public async pack( - src: MiAuthSession['id'] | MiAuthSession, - me?: { id: MiUser['id'] } | null | undefined, - ) { - const session = typeof src === 'object' ? src : await this.authSessionsRepository.findOneByOrFail({ id: src }); - - return await awaitAll({ - id: session.id, - app: this.appEntityService.pack(session.appId, me), - token: session.token, - }); - } -} diff --git a/packages/backend/src/core/entities/BlockingEntityService.ts b/packages/backend/src/core/entities/BlockingEntityService.ts deleted file mode 100644 index b4760346b7..0000000000 --- a/packages/backend/src/core/entities/BlockingEntityService.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { BlockingsRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { MiBlocking } from '@/models/Blocking.js'; -import type { MiUser } from '@/models/User.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import { UserEntityService } from './UserEntityService.js'; - -@Injectable() -export class BlockingEntityService { - constructor( - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - - private userEntityService: UserEntityService, - private idService: IdService, - ) { - } - - @bindThis - public async pack( - src: MiBlocking['id'] | MiBlocking, - me?: { id: MiUser['id'] } | null | undefined, - ): Promise> { - const blocking = typeof src === 'object' ? src : await this.blockingsRepository.findOneByOrFail({ id: src }); - - return await awaitAll({ - id: blocking.id, - createdAt: this.idService.parse(blocking.id).date.toISOString(), - blockeeId: blocking.blockeeId, - blockee: this.userEntityService.pack(blocking.blockeeId, me, { - detail: true, - }), - }); - } - - @bindThis - public packMany( - blockings: any[], - me: { id: MiUser['id'] }, - ) { - return Promise.all(blockings.map(x => this.pack(x, me))); - } -} diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts deleted file mode 100644 index 305946b8a6..0000000000 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NotesRepository } from '@/models/_.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiChannel } from '@/models/Channel.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import { DriveFileEntityService } from './DriveFileEntityService.js'; -import { NoteEntityService } from './NoteEntityService.js'; -import { In } from 'typeorm'; - -@Injectable() -export class ChannelEntityService { - constructor( - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - - @Inject(DI.channelFavoritesRepository) - private channelFavoritesRepository: ChannelFavoritesRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private noteEntityService: NoteEntityService, - private driveFileEntityService: DriveFileEntityService, - private idService: IdService, - ) { - } - - @bindThis - public async pack( - src: MiChannel['id'] | MiChannel, - me?: { id: MiUser['id'] } | null | undefined, - detailed?: boolean, - ): Promise> { - const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src }); - const meId = me ? me.id : null; - - const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null; - - const isFollowing = meId ? await this.channelFollowingsRepository.exist({ - where: { - followerId: meId, - followeeId: channel.id, - }, - }) : false; - - const isFavorited = meId ? await this.channelFavoritesRepository.exist({ - where: { - userId: meId, - channelId: channel.id, - }, - }) : false; - - const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({ - where: { - id: In(channel.pinnedNoteIds), - }, - }) : []; - - return { - id: channel.id, - createdAt: this.idService.parse(channel.id).date.toISOString(), - lastNotedAt: channel.lastNotedAt ? channel.lastNotedAt.toISOString() : null, - name: channel.name, - description: channel.description, - userId: channel.userId, - bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null, - pinnedNoteIds: channel.pinnedNoteIds, - color: channel.color, - isArchived: channel.isArchived, - usersCount: channel.usersCount, - notesCount: channel.notesCount, - isSensitive: channel.isSensitive, - allowRenoteToExternal: channel.allowRenoteToExternal, - - ...(me ? { - isFollowing, - isFavorited, - hasUnreadNote: false, // 後方互換性のため - } : {}), - - ...(detailed ? { - pinnedNotes: (await this.noteEntityService.packMany(pinnedNotes, me)).sort((a, b) => channel.pinnedNoteIds.indexOf(a.id) - channel.pinnedNoteIds.indexOf(b.id)), - } : {}), - }; - } -} - diff --git a/packages/backend/src/core/entities/ClipEntityService.ts b/packages/backend/src/core/entities/ClipEntityService.ts deleted file mode 100644 index 96422894fd..0000000000 --- a/packages/backend/src/core/entities/ClipEntityService.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { ClipFavoritesRepository, ClipsRepository, MiUser } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; -import type { MiClip } from '@/models/Clip.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import { UserEntityService } from './UserEntityService.js'; - -@Injectable() -export class ClipEntityService { - constructor( - @Inject(DI.clipsRepository) - private clipsRepository: ClipsRepository, - - @Inject(DI.clipFavoritesRepository) - private clipFavoritesRepository: ClipFavoritesRepository, - - private userEntityService: UserEntityService, - private idService: IdService, - ) { - } - - @bindThis - public async pack( - src: MiClip['id'] | MiClip, - me?: { id: MiUser['id'] } | null | undefined, - ): Promise> { - const meId = me ? me.id : null; - const clip = typeof src === 'object' ? src : await this.clipsRepository.findOneByOrFail({ id: src }); - - return await awaitAll({ - id: clip.id, - createdAt: this.idService.parse(clip.id).date.toISOString(), - lastClippedAt: clip.lastClippedAt ? clip.lastClippedAt.toISOString() : null, - userId: clip.userId, - user: this.userEntityService.pack(clip.user ?? clip.userId), - name: clip.name, - description: clip.description, - isPublic: clip.isPublic, - favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }), - isFavorited: meId ? await this.clipFavoritesRepository.exist({ where: { clipId: clip.id, userId: meId } }) : undefined, - }); - } - - @bindThis - public packMany( - clips: MiClip[], - me?: { id: MiUser['id'] } | null | undefined, - ) { - return Promise.all(clips.map(x => this.pack(x, me))); - } -} - diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts deleted file mode 100644 index 14be000367..0000000000 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ /dev/null @@ -1,289 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import type { Config } from '@/config.js'; -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 { MiDriveFile } from '@/models/DriveFile.js'; -import { appendQuery, query } from '@/misc/prelude/url.js'; -import { deepClone } from '@/misc/clone.js'; -import { bindThis } from '@/decorators.js'; -import { isMimeImage } from '@/misc/is-mime-image.js'; -import { isNotNull } from '@/misc/is-not-null.js'; -import { IdService } from '@/core/IdService.js'; -import { UtilityService } from '../UtilityService.js'; -import { VideoProcessingService } from '../VideoProcessingService.js'; -import { UserEntityService } from './UserEntityService.js'; -import { DriveFolderEntityService } from './DriveFolderEntityService.js'; - -type PackOptions = { - detail?: boolean, - self?: boolean, - withUser?: boolean, -}; - -@Injectable() -export class DriveFileEntityService { - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - // 循環参照のため / for circular dependency - @Inject(forwardRef(() => UserEntityService)) - private userEntityService: UserEntityService, - - private utilityService: UtilityService, - private driveFolderEntityService: DriveFolderEntityService, - private videoProcessingService: VideoProcessingService, - private idService: IdService, - ) { - } - - @bindThis - public validateFileName(name: string): boolean { - return ( - (name.trim().length > 0) && - (name.length <= 200) && - (name.indexOf('\\') === -1) && - (name.indexOf('/') === -1) && - (name.indexOf('..') === -1) - ); - } - - @bindThis - public getPublicProperties(file: MiDriveFile): MiDriveFile['properties'] { - if (file.properties.orientation != null) { - const properties = deepClone(file.properties); - if (file.properties.orientation >= 5) { - [properties.width, properties.height] = [properties.height, properties.width]; - } - properties.orientation = undefined; - return properties; - } - - return file.properties; - } - - @bindThis - private getProxiedUrl(url: string, mode?: 'static' | 'avatar'): string { - return appendQuery( - `${this.config.mediaProxy}/${mode ?? 'image'}.webp`, - query({ - url, - ...(mode ? { [mode]: '1' } : {}), - }), - ); - } - - @bindThis - public getThumbnailUrl(file: MiDriveFile): string | null { - if (file.type.startsWith('video')) { - if (file.thumbnailUrl) return file.thumbnailUrl; - - return this.videoProcessingService.getExternalVideoThumbnailUrl(file.webpublicUrl ?? file.url); - } else if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) { - // 動画ではなくリモートかつメディアプロキシ - return this.getProxiedUrl(file.uri, 'static'); - } - - if (file.uri != null && file.isLink && this.config.proxyRemoteFiles) { - // リモートかつ期限切れはローカルプロキシを試みる - // 従来は/files/${thumbnailAccessKey}にアクセスしていたが、 - // /filesはメディアプロキシにリダイレクトするようにしたため直接メディアプロキシを指定する - return this.getProxiedUrl(file.uri, 'static'); - } - - const url = file.webpublicUrl ?? file.url; - - return file.thumbnailUrl ?? (isMimeImage(file.type, 'sharp-convertible-image') ? url : null); - } - - @bindThis - public getPublicUrl(file: MiDriveFile, mode?: 'avatar'): string { // static = thumbnail - // リモートかつメディアプロキシ - if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) { - return this.getProxiedUrl(file.uri, mode); - } - - // リモートかつ期限切れはローカルプロキシを試みる - if (file.uri != null && file.isLink && this.config.proxyRemoteFiles) { - const key = file.webpublicAccessKey; - - if (key && !key.match('/')) { // 古いものはここにオブジェクトストレージキーが入ってるので除外 - const url = `${this.config.url}/files/${key}`; - if (mode === 'avatar') return this.getProxiedUrl(file.uri, 'avatar'); - return url; - } - } - - const url = file.webpublicUrl ?? file.url; - - if (mode === 'avatar') { - return this.getProxiedUrl(url, 'avatar'); - } - return url; - } - - @bindThis - public async calcDriveUsageOf(user: MiUser['id'] | { id: MiUser['id'] }): Promise { - const id = typeof user === 'object' ? user.id : user; - - const { sum } = await this.driveFilesRepository - .createQueryBuilder('file') - .where('file.userId = :id', { id: id }) - .andWhere('file.isLink = FALSE') - .select('SUM(file.size)', 'sum') - .getRawOne(); - - return parseInt(sum, 10) || 0; - } - - @bindThis - public async calcDriveUsageOfHost(host: string): Promise { - const { sum } = await this.driveFilesRepository - .createQueryBuilder('file') - .where('file.userHost = :host', { host: this.utilityService.toPuny(host) }) - .andWhere('file.isLink = FALSE') - .select('SUM(file.size)', 'sum') - .getRawOne(); - - return parseInt(sum, 10) || 0; - } - - @bindThis - public async calcDriveUsageOfLocal(): Promise { - const { sum } = await this.driveFilesRepository - .createQueryBuilder('file') - .where('file.userHost IS NULL') - .andWhere('file.isLink = FALSE') - .select('SUM(file.size)', 'sum') - .getRawOne(); - - return parseInt(sum, 10) || 0; - } - - @bindThis - public async calcDriveUsageOfRemote(): Promise { - const { sum } = await this.driveFilesRepository - .createQueryBuilder('file') - .where('file.userHost IS NOT NULL') - .andWhere('file.isLink = FALSE') - .select('SUM(file.size)', 'sum') - .getRawOne(); - - return parseInt(sum, 10) || 0; - } - - @bindThis - public async pack( - src: MiDriveFile['id'] | MiDriveFile, - options?: PackOptions, - ): Promise> { - const opts = Object.assign({ - detail: false, - self: false, - }, options); - - const file = typeof src === 'object' ? src : await this.driveFilesRepository.findOneByOrFail({ id: src }); - - return await awaitAll>({ - id: file.id, - createdAt: this.idService.parse(file.id).date.toISOString(), - name: file.name, - type: file.type, - md5: file.md5, - size: file.size, - isSensitive: file.isSensitive, - blurhash: file.blurhash, - properties: opts.self ? file.properties : this.getPublicProperties(file), - url: opts.self ? file.url : this.getPublicUrl(file), - thumbnailUrl: this.getThumbnailUrl(file), - comment: file.comment, - folderId: file.folderId, - folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, { - detail: true, - }) : null, - userId: opts.withUser ? file.userId : null, - user: (opts.withUser && file.userId) ? this.userEntityService.pack(file.userId) : null, - }); - } - - @bindThis - public async packNullable( - src: MiDriveFile['id'] | MiDriveFile, - options?: PackOptions, - ): Promise | null> { - const opts = Object.assign({ - detail: false, - self: false, - }, options); - - const file = typeof src === 'object' ? src : await this.driveFilesRepository.findOneBy({ id: src }); - if (file == null) return null; - - return await awaitAll>({ - id: file.id, - createdAt: this.idService.parse(file.id).date.toISOString(), - name: file.name, - type: file.type, - md5: file.md5, - size: file.size, - isSensitive: file.isSensitive, - blurhash: file.blurhash, - properties: opts.self ? file.properties : this.getPublicProperties(file), - url: opts.self ? file.url : this.getPublicUrl(file), - thumbnailUrl: this.getThumbnailUrl(file), - comment: file.comment, - folderId: file.folderId, - folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, { - detail: true, - }) : null, - userId: opts.withUser ? file.userId : null, - user: (opts.withUser && file.userId) ? this.userEntityService.pack(file.userId) : null, - }); - } - - @bindThis - public async packMany( - files: MiDriveFile[], - options?: PackOptions, - ): Promise[]> { - const items = await Promise.all(files.map(f => this.packNullable(f, options))); - return items.filter((x): x is Packed<'DriveFile'> => x != null); - } - - @bindThis - public async packManyByIdsMap( - fileIds: MiDriveFile['id'][], - options?: PackOptions, - ): Promise['id'], Packed<'DriveFile'> | null>> { - if (fileIds.length === 0) return new Map(); - const files = await this.driveFilesRepository.findBy({ id: In(fileIds) }); - const packedFiles = await this.packMany(files, options); - const map = new Map['id'], Packed<'DriveFile'> | null>(packedFiles.map(f => [f.id, f])); - for (const id of fileIds) { - if (!map.has(id)) map.set(id, null); - } - return map; - } - - @bindThis - public async packManyByIds( - fileIds: MiDriveFile['id'][], - options?: PackOptions, - ): Promise[]> { - if (fileIds.length === 0) return []; - const filesMap = await this.packManyByIdsMap(fileIds, options); - return fileIds.map(id => filesMap.get(id)).filter(isNotNull); - } -} diff --git a/packages/backend/src/core/entities/DriveFolderEntityService.ts b/packages/backend/src/core/entities/DriveFolderEntityService.ts deleted file mode 100644 index 8fa78154b9..0000000000 --- a/packages/backend/src/core/entities/DriveFolderEntityService.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository, DriveFoldersRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; -import type { MiDriveFolder } from '@/models/DriveFolder.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; - -@Injectable() -export class DriveFolderEntityService { - constructor( - @Inject(DI.driveFoldersRepository) - private driveFoldersRepository: DriveFoldersRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private idService: IdService, - ) { - } - - @bindThis - public async pack( - src: MiDriveFolder['id'] | MiDriveFolder, - options?: { - detail: boolean - }, - ): Promise> { - const opts = Object.assign({ - detail: false, - }, options); - - const folder = typeof src === 'object' ? src : await this.driveFoldersRepository.findOneByOrFail({ id: src }); - - return await awaitAll({ - id: folder.id, - createdAt: this.idService.parse(folder.id).date.toISOString(), - name: folder.name, - parentId: folder.parentId, - - ...(opts.detail ? { - foldersCount: this.driveFoldersRepository.countBy({ - parentId: folder.id, - }), - filesCount: this.driveFilesRepository.countBy({ - folderId: folder.id, - }), - - ...(folder.parentId ? { - parent: this.pack(folder.parentId, { - detail: true, - }), - } : {}), - } : {}), - }); - } -} - diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts deleted file mode 100644 index 5b97cfad5e..0000000000 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { EmojisRepository } from '@/models/_.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; -import type { MiEmoji } from '@/models/Emoji.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class EmojiEntityService { - constructor( - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - ) { - } - - @bindThis - public async packSimple( - src: MiEmoji['id'] | MiEmoji, - ): Promise> { - const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src }); - - return { - aliases: emoji.aliases, - name: emoji.name, - category: emoji.category, - // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - url: emoji.publicUrl || emoji.originalUrl, - isSensitive: emoji.isSensitive ? true : undefined, - roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined, - }; - } - - @bindThis - public packSimpleMany( - emojis: any[], - ) { - return Promise.all(emojis.map(x => this.packSimple(x))); - } - - @bindThis - public async packDetailed( - src: MiEmoji['id'] | MiEmoji, - ): Promise> { - const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src }); - - return { - id: emoji.id, - aliases: emoji.aliases, - name: emoji.name, - category: emoji.category, - host: emoji.host, - // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - url: emoji.publicUrl || emoji.originalUrl, - license: emoji.license, - isSensitive: emoji.isSensitive, - localOnly: emoji.localOnly, - roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction, - }; - } - - @bindThis - public packDetailedMany( - emojis: any[], - ) { - return Promise.all(emojis.map(x => this.packDetailed(x))); - } -} - diff --git a/packages/backend/src/core/entities/FlashEntityService.ts b/packages/backend/src/core/entities/FlashEntityService.ts deleted file mode 100644 index dc335d9975..0000000000 --- a/packages/backend/src/core/entities/FlashEntityService.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { FlashsRepository, FlashLikesRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiFlash } from '@/models/Flash.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import { UserEntityService } from './UserEntityService.js'; - -@Injectable() -export class FlashEntityService { - constructor( - @Inject(DI.flashsRepository) - private flashsRepository: FlashsRepository, - - @Inject(DI.flashLikesRepository) - private flashLikesRepository: FlashLikesRepository, - - private userEntityService: UserEntityService, - private idService: IdService, - ) { - } - - @bindThis - public async pack( - src: MiFlash['id'] | MiFlash, - me?: { id: MiUser['id'] } | null | undefined, - ): Promise> { - const meId = me ? me.id : null; - const flash = typeof src === 'object' ? src : await this.flashsRepository.findOneByOrFail({ id: src }); - - return await awaitAll({ - id: flash.id, - createdAt: this.idService.parse(flash.id).date.toISOString(), - updatedAt: flash.updatedAt.toISOString(), - userId: flash.userId, - user: this.userEntityService.pack(flash.user ?? flash.userId, me), // { detail: true } すると無限ループするので注意 - title: flash.title, - summary: flash.summary, - script: flash.script, - likedCount: flash.likedCount, - isLiked: meId ? await this.flashLikesRepository.exist({ where: { flashId: flash.id, userId: meId } }) : undefined, - }); - } - - @bindThis - public packMany( - flashs: MiFlash[], - me?: { id: MiUser['id'] } | null | undefined, - ) { - return Promise.all(flashs.map(x => this.pack(x, me))); - } -} - diff --git a/packages/backend/src/core/entities/FlashLikeEntityService.ts b/packages/backend/src/core/entities/FlashLikeEntityService.ts deleted file mode 100644 index 2eff86217a..0000000000 --- a/packages/backend/src/core/entities/FlashLikeEntityService.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { FlashLikesRepository } from '@/models/_.js'; -import type { } from '@/models/Blocking.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiFlashLike } from '@/models/FlashLike.js'; -import { bindThis } from '@/decorators.js'; -import { FlashEntityService } from './FlashEntityService.js'; - -@Injectable() -export class FlashLikeEntityService { - constructor( - @Inject(DI.flashLikesRepository) - private flashLikesRepository: FlashLikesRepository, - - private flashEntityService: FlashEntityService, - ) { - } - - @bindThis - public async pack( - src: MiFlashLike['id'] | MiFlashLike, - me?: { id: MiUser['id'] } | null | undefined, - ) { - const like = typeof src === 'object' ? src : await this.flashLikesRepository.findOneByOrFail({ id: src }); - - return { - id: like.id, - flash: await this.flashEntityService.pack(like.flash ?? like.flashId, me), - }; - } - - @bindThis - public packMany( - likes: any[], - me: { id: MiUser['id'] }, - ) { - return Promise.all(likes.map(x => this.pack(x, me))); - } -} - diff --git a/packages/backend/src/core/entities/FollowRequestEntityService.ts b/packages/backend/src/core/entities/FollowRequestEntityService.ts deleted file mode 100644 index 0e0fec9f46..0000000000 --- a/packages/backend/src/core/entities/FollowRequestEntityService.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { FollowRequestsRepository } from '@/models/_.js'; -import type { } from '@/models/Blocking.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiFollowRequest } from '@/models/FollowRequest.js'; -import { bindThis } from '@/decorators.js'; -import { UserEntityService } from './UserEntityService.js'; - -@Injectable() -export class FollowRequestEntityService { - constructor( - @Inject(DI.followRequestsRepository) - private followRequestsRepository: FollowRequestsRepository, - - private userEntityService: UserEntityService, - ) { - } - - @bindThis - public async pack( - src: MiFollowRequest['id'] | MiFollowRequest, - me?: { id: MiUser['id'] } | null | undefined, - ) { - const request = typeof src === 'object' ? src : await this.followRequestsRepository.findOneByOrFail({ id: src }); - - return { - id: request.id, - follower: await this.userEntityService.pack(request.followerId, me), - followee: await this.userEntityService.pack(request.followeeId, me), - }; - } -} - diff --git a/packages/backend/src/core/entities/FollowingEntityService.ts b/packages/backend/src/core/entities/FollowingEntityService.ts deleted file mode 100644 index 52aa979677..0000000000 --- a/packages/backend/src/core/entities/FollowingEntityService.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiFollowing } from '@/models/Following.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import { UserEntityService } from './UserEntityService.js'; - -type LocalFollowerFollowing = MiFollowing & { - followerHost: null; - followerInbox: null; - followerSharedInbox: null; -}; - -type RemoteFollowerFollowing = MiFollowing & { - followerHost: string; - followerInbox: string; - followerSharedInbox: string; -}; - -type LocalFolloweeFollowing = MiFollowing & { - followeeHost: null; - followeeInbox: null; - followeeSharedInbox: null; -}; - -type RemoteFolloweeFollowing = MiFollowing & { - followeeHost: string; - followeeInbox: string; - followeeSharedInbox: string; -}; - -@Injectable() -export class FollowingEntityService { - constructor( - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - private userEntityService: UserEntityService, - private idService: IdService, - ) { - } - - @bindThis - public isLocalFollower(following: MiFollowing): following is LocalFollowerFollowing { - return following.followerHost == null; - } - - @bindThis - public isRemoteFollower(following: MiFollowing): following is RemoteFollowerFollowing { - return following.followerHost != null; - } - - @bindThis - public isLocalFollowee(following: MiFollowing): following is LocalFolloweeFollowing { - return following.followeeHost == null; - } - - @bindThis - public isRemoteFollowee(following: MiFollowing): following is RemoteFolloweeFollowing { - return following.followeeHost != null; - } - - @bindThis - public async pack( - src: MiFollowing['id'] | MiFollowing, - me?: { id: MiUser['id'] } | null | undefined, - opts?: { - populateFollowee?: boolean; - populateFollower?: boolean; - }, - ): Promise> { - const following = typeof src === 'object' ? src : await this.followingsRepository.findOneByOrFail({ id: src }); - - if (opts == null) opts = {}; - - return await awaitAll({ - id: following.id, - createdAt: this.idService.parse(following.id).date.toISOString(), - followeeId: following.followeeId, - followerId: following.followerId, - followee: opts.populateFollowee ? this.userEntityService.pack(following.followee ?? following.followeeId, me, { - detail: true, - }) : undefined, - follower: opts.populateFollower ? this.userEntityService.pack(following.follower ?? following.followerId, me, { - detail: true, - }) : undefined, - }); - } - - @bindThis - public packMany( - followings: any[], - me?: { id: MiUser['id'] } | null | undefined, - opts?: { - populateFollowee?: boolean; - populateFollower?: boolean; - }, - ) { - return Promise.all(followings.map(x => this.pack(x, me, opts))); - } -} - diff --git a/packages/backend/src/core/entities/GalleryLikeEntityService.ts b/packages/backend/src/core/entities/GalleryLikeEntityService.ts deleted file mode 100644 index e740701888..0000000000 --- a/packages/backend/src/core/entities/GalleryLikeEntityService.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { GalleryLikesRepository } from '@/models/_.js'; -import type { } from '@/models/Blocking.js'; -import type { MiGalleryLike } from '@/models/GalleryLike.js'; -import { bindThis } from '@/decorators.js'; -import { GalleryPostEntityService } from './GalleryPostEntityService.js'; - -@Injectable() -export class GalleryLikeEntityService { - constructor( - @Inject(DI.galleryLikesRepository) - private galleryLikesRepository: GalleryLikesRepository, - - private galleryPostEntityService: GalleryPostEntityService, - ) { - } - - @bindThis - public async pack( - src: MiGalleryLike['id'] | MiGalleryLike, - me?: any, - ) { - const like = typeof src === 'object' ? src : await this.galleryLikesRepository.findOneByOrFail({ id: src }); - - return { - id: like.id, - post: await this.galleryPostEntityService.pack(like.post ?? like.postId, me), - }; - } - - @bindThis - public packMany( - likes: any[], - me: any, - ) { - return Promise.all(likes.map(x => this.pack(x, me))); - } -} - diff --git a/packages/backend/src/core/entities/GalleryPostEntityService.ts b/packages/backend/src/core/entities/GalleryPostEntityService.ts deleted file mode 100644 index d7b960e0d9..0000000000 --- a/packages/backend/src/core/entities/GalleryPostEntityService.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { GalleryLikesRepository, GalleryPostsRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiGalleryPost } from '@/models/GalleryPost.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import { UserEntityService } from './UserEntityService.js'; -import { DriveFileEntityService } from './DriveFileEntityService.js'; - -@Injectable() -export class GalleryPostEntityService { - constructor( - @Inject(DI.galleryPostsRepository) - private galleryPostsRepository: GalleryPostsRepository, - - @Inject(DI.galleryLikesRepository) - private galleryLikesRepository: GalleryLikesRepository, - - private userEntityService: UserEntityService, - private driveFileEntityService: DriveFileEntityService, - private idService: IdService, - ) { - } - - @bindThis - public async pack( - src: MiGalleryPost['id'] | MiGalleryPost, - me?: { id: MiUser['id'] } | null | undefined, - ): Promise> { - const meId = me ? me.id : null; - const post = typeof src === 'object' ? src : await this.galleryPostsRepository.findOneByOrFail({ id: src }); - - return await awaitAll({ - id: post.id, - createdAt: this.idService.parse(post.id).date.toISOString(), - updatedAt: post.updatedAt.toISOString(), - userId: post.userId, - user: this.userEntityService.pack(post.user ?? post.userId, me), - title: post.title, - description: post.description, - fileIds: post.fileIds, - // TODO: packMany causes N+1 queries - files: this.driveFileEntityService.packManyByIds(post.fileIds), - tags: post.tags.length > 0 ? post.tags : undefined, - isSensitive: post.isSensitive, - likedCount: post.likedCount, - isLiked: meId ? await this.galleryLikesRepository.exist({ where: { postId: post.id, userId: meId } }) : undefined, - }); - } - - @bindThis - public packMany( - posts: MiGalleryPost[], - me?: { id: MiUser['id'] } | null | undefined, - ) { - return Promise.all(posts.map(x => this.pack(x, me))); - } -} - diff --git a/packages/backend/src/core/entities/HashtagEntityService.ts b/packages/backend/src/core/entities/HashtagEntityService.ts deleted file mode 100644 index 006e267b12..0000000000 --- a/packages/backend/src/core/entities/HashtagEntityService.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; -import type { MiHashtag } from '@/models/Hashtag.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class HashtagEntityService { - constructor( - ) { - } - - @bindThis - public async pack( - src: MiHashtag, - ): Promise> { - return { - tag: src.name, - mentionedUsersCount: src.mentionedUsersCount, - mentionedLocalUsersCount: src.mentionedLocalUsersCount, - mentionedRemoteUsersCount: src.mentionedRemoteUsersCount, - attachedUsersCount: src.attachedUsersCount, - attachedLocalUsersCount: src.attachedLocalUsersCount, - attachedRemoteUsersCount: src.attachedRemoteUsersCount, - }; - } - - @bindThis - public packMany( - hashtags: MiHashtag[], - ) { - return Promise.all(hashtags.map(x => this.pack(x))); - } -} - diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts deleted file mode 100644 index 64dfd3b5d6..0000000000 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import type { Packed } from '@/misc/json-schema.js'; -import type { MiInstance } from '@/models/Instance.js'; -import { MetaService } from '@/core/MetaService.js'; -import { bindThis } from '@/decorators.js'; -import { UtilityService } from '../UtilityService.js'; - -@Injectable() -export class InstanceEntityService { - constructor( - private metaService: MetaService, - - private utilityService: UtilityService, - ) { - } - - @bindThis - public async pack( - instance: MiInstance, - ): Promise> { - const meta = await this.metaService.fetch(); - return { - id: instance.id, - firstRetrievedAt: instance.firstRetrievedAt.toISOString(), - host: instance.host, - usersCount: instance.usersCount, - notesCount: instance.notesCount, - followingCount: instance.followingCount, - followersCount: instance.followersCount, - isNotResponding: instance.isNotResponding, - isSuspended: instance.isSuspended, - isBlocked: this.utilityService.isBlockedHost(meta.blockedHosts, instance.host), - isAllowed: meta.allowlistMode ? this.utilityService.isAllowedHost(meta.allowedHosts, instance.host) : null, - softwareName: instance.softwareName, - softwareVersion: instance.softwareVersion, - openRegistrations: instance.openRegistrations, - name: instance.name, - description: instance.description, - maintainerName: instance.maintainerName, - maintainerEmail: instance.maintainerEmail, - isSilenced: this.utilityService.isSilencedHost(meta.silencedHosts, instance.host), - iconUrl: instance.iconUrl, - faviconUrl: instance.faviconUrl, - themeColor: instance.themeColor, - infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null, - latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null, - isNSFW: instance.isNSFW, - }; - } - - @bindThis - public packMany( - instances: MiInstance[], - ) { - return Promise.all(instances.map(x => this.pack(x))); - } -} - diff --git a/packages/backend/src/core/entities/InviteCodeEntityService.ts b/packages/backend/src/core/entities/InviteCodeEntityService.ts deleted file mode 100644 index 0f15fb5ab2..0000000000 --- a/packages/backend/src/core/entities/InviteCodeEntityService.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { RegistrationTicketsRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiRegistrationTicket } from '@/models/RegistrationTicket.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import { UserEntityService } from './UserEntityService.js'; - -@Injectable() -export class InviteCodeEntityService { - constructor( - @Inject(DI.registrationTicketsRepository) - private registrationTicketsRepository: RegistrationTicketsRepository, - - private userEntityService: UserEntityService, - private idService: IdService, - ) { - } - - @bindThis - public async pack( - src: MiRegistrationTicket['id'] | MiRegistrationTicket, - me?: { id: MiUser['id'] } | null | undefined, - ): Promise> { - const target = typeof src === 'object' ? src : await this.registrationTicketsRepository.findOneOrFail({ - where: { - id: src, - }, - relations: ['createdBy', 'usedBy'], - }); - - return await awaitAll({ - id: target.id, - code: target.code, - expiresAt: target.expiresAt ? target.expiresAt.toISOString() : null, - createdAt: this.idService.parse(target.id).date.toISOString(), - createdBy: target.createdBy ? await this.userEntityService.pack(target.createdBy, me) : null, - usedBy: target.usedBy ? await this.userEntityService.pack(target.usedBy, me) : null, - usedAt: target.usedAt ? target.usedAt.toISOString() : null, - used: !!target.usedAt, - }); - } - - @bindThis - public packMany( - targets: any[], - me: { id: MiUser['id'] }, - ) { - return Promise.all(targets.map(x => this.pack(x, me))); - } -} diff --git a/packages/backend/src/core/entities/ModerationLogEntityService.ts b/packages/backend/src/core/entities/ModerationLogEntityService.ts deleted file mode 100644 index 6729ca2671..0000000000 --- a/packages/backend/src/core/entities/ModerationLogEntityService.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { ModerationLogsRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { } from '@/models/Blocking.js'; -import type { MiModerationLog } from '@/models/ModerationLog.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import { UserEntityService } from './UserEntityService.js'; - -@Injectable() -export class ModerationLogEntityService { - constructor( - @Inject(DI.moderationLogsRepository) - private moderationLogsRepository: ModerationLogsRepository, - - private userEntityService: UserEntityService, - private idService: IdService, - ) { - } - - @bindThis - public async pack( - src: MiModerationLog['id'] | MiModerationLog, - ) { - const log = typeof src === 'object' ? src : await this.moderationLogsRepository.findOneByOrFail({ id: src }); - - return await awaitAll({ - id: log.id, - createdAt: this.idService.parse(log.id).date.toISOString(), - type: log.type, - info: log.info, - userId: log.userId, - user: this.userEntityService.pack(log.user ?? log.userId, null, { - detail: true, - }), - }); - } - - @bindThis - public packMany( - reports: any[], - ) { - return Promise.all(reports.map(x => this.pack(x))); - } -} - diff --git a/packages/backend/src/core/entities/MutingEntityService.ts b/packages/backend/src/core/entities/MutingEntityService.ts deleted file mode 100644 index 9d672169ba..0000000000 --- a/packages/backend/src/core/entities/MutingEntityService.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { MutingsRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiMuting } from '@/models/Muting.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import { UserEntityService } from './UserEntityService.js'; - -@Injectable() -export class MutingEntityService { - constructor( - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - private userEntityService: UserEntityService, - private idService: IdService, - ) { - } - - @bindThis - public async pack( - src: MiMuting['id'] | MiMuting, - me?: { id: MiUser['id'] } | null | undefined, - ): Promise> { - const muting = typeof src === 'object' ? src : await this.mutingsRepository.findOneByOrFail({ id: src }); - - return await awaitAll({ - id: muting.id, - createdAt: this.idService.parse(muting.id).date.toISOString(), - expiresAt: muting.expiresAt ? muting.expiresAt.toISOString() : null, - muteeId: muting.muteeId, - mutee: this.userEntityService.pack(muting.muteeId, me, { - detail: true, - }), - }); - } - - @bindThis - public packMany( - mutings: any[], - me: { id: MiUser['id'] }, - ) { - return Promise.all(mutings.map(x => this.pack(x, me))); - } -} - diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts deleted file mode 100644 index fee96bb80d..0000000000 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ /dev/null @@ -1,509 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; -import { ModuleRef } from '@nestjs/core'; -import { DI } from '@/di-symbols.js'; -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 { isNotNull } from '@/misc/is-not-null.js'; -import { DebounceLoader } from '@/misc/loader.js'; -import { IdService } from '@/core/IdService.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'; -import type { Config } from '@/config.js'; - -@Injectable() -export class NoteEntityService implements OnModuleInit { - private userEntityService: UserEntityService; - private driveFileEntityService: DriveFileEntityService; - private customEmojiService: CustomEmojiService; - private reactionService: ReactionService; - private idService: IdService; - private noteLoader = new DebounceLoader(this.findNoteOrFail); - - constructor( - private moduleRef: ModuleRef, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.config) - private config: Config, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - @Inject(DI.pollVotesRepository) - private pollVotesRepository: PollVotesRepository, - - @Inject(DI.noteReactionsRepository) - private noteReactionsRepository: NoteReactionsRepository, - - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - - //private userEntityService: UserEntityService, - //private driveFileEntityService: DriveFileEntityService, - //private customEmojiService: CustomEmojiService, - //private reactionService: ReactionService, - ) { - } - - onModuleInit() { - this.userEntityService = this.moduleRef.get('UserEntityService'); - this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); - this.customEmojiService = this.moduleRef.get('CustomEmojiService'); - this.reactionService = this.moduleRef.get('ReactionService'); - this.idService = this.moduleRef.get('IdService'); - } - - @bindThis - private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null) { - // TODO: isVisibleForMe を使うようにしても良さそう(型違うけど) - let hide = false; - - // visibility が specified かつ自分が指定されていなかったら非表示 - if (packedNote.visibility === 'specified') { - if (meId == null) { - hide = true; - } else if (meId === packedNote.userId) { - hide = false; - } else { - // 指定されているかどうか - const specified = packedNote.visibleUserIds!.some((id: any) => meId === id); - - if (specified) { - hide = false; - } else { - hide = true; - } - } - } - - // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 - if (packedNote.visibility === 'followers') { - if (meId == null) { - hide = true; - } else if (meId === packedNote.userId) { - hide = false; - } else if (packedNote.reply && (meId === packedNote.reply.userId)) { - // 自分の投稿に対するリプライ - hide = false; - } else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) { - // 自分へのメンション - hide = false; - } else if (packedNote.renote && (meId === packedNote.renote.userId)) { - hide = false; - } else { - if (packedNote.renote) { - const isFollowing = await this.followingsRepository.exist({ - where: { - followeeId: packedNote.renote.userId, - followerId: meId, - }, - }); - - hide = !isFollowing; - } else { - // フォロワーかどうか - const isFollowing = await this.followingsRepository.exist({ - where: { - followeeId: packedNote.userId, - followerId: meId, - }, - }); - - hide = !isFollowing; - } - } - } - - if (hide) { - packedNote.visibleUserIds = undefined; - packedNote.fileIds = []; - packedNote.files = []; - packedNote.text = null; - packedNote.poll = undefined; - packedNote.cw = null; - packedNote.isHidden = true; - } - } - - @bindThis - private async populatePoll(note: MiNote, meId: MiUser['id'] | null) { - const poll = await this.pollsRepository.findOneByOrFail({ noteId: note.id }); - const choices = poll.choices.map(c => ({ - text: c, - votes: poll.votes[poll.choices.indexOf(c)], - isVoted: false, - })); - - if (meId) { - if (poll.multiple) { - const votes = await this.pollVotesRepository.findBy({ - userId: meId, - noteId: note.id, - }); - - const myChoices = votes.map(v => v.choice); - for (const myChoice of myChoices) { - choices[myChoice].isVoted = true; - } - } else { - const vote = await this.pollVotesRepository.findOneBy({ - userId: meId, - noteId: note.id, - }); - - if (vote) { - choices[vote.choice].isVoted = true; - } - } - } - - return { - multiple: poll.multiple, - expiresAt: poll.expiresAt, - choices, - }; - } - - @bindThis - public async populateMyReaction(note: { id: MiNote['id']; reactions: MiNote['reactions']; reactionAndUserPairCache?: MiNote['reactionAndUserPairCache']; }, meId: MiUser['id'], _hint_?: { - myReactions: Map; - }) { - if (_hint_?.myReactions) { - const reaction = _hint_.myReactions.get(note.id); - if (reaction) { - return this.reactionService.convertLegacyReaction(reaction); - } else { - return undefined; - } - } - - const reactionsCount = Object.values(note.reactions).reduce((a, b) => a + b, 0); - if (reactionsCount === 0) return undefined; - if (note.reactionAndUserPairCache && reactionsCount <= note.reactionAndUserPairCache.length) { - const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId)); - if (pair) { - return this.reactionService.convertLegacyReaction(pair.split('/')[1]); - } else { - return undefined; - } - } - - // パフォーマンスのためノートが作成されてから2秒以上経っていない場合はリアクションを取得しない - if (this.idService.parse(note.id).date.getTime() + 2000 > Date.now()) { - return undefined; - } - - const reaction = await this.noteReactionsRepository.findOneBy({ - userId: meId, - noteId: note.id, - }); - - if (reaction) { - return this.reactionService.convertLegacyReaction(reaction.reaction); - } - - return undefined; - } - - @bindThis - public async isVisibleForMe(note: MiNote, meId: MiUser['id'] | null): Promise { - // This code must always be synchronized with the checks in generateVisibilityQuery. - // visibility が specified かつ自分が指定されていなかったら非表示 - if (note.visibility === 'specified') { - if (meId == null) { - return false; - } else if (meId === note.userId) { - return true; - } else { - // 指定されているかどうか - return note.visibleUserIds.some((id: any) => meId === id); - } - } - - // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 - if (note.visibility === 'followers') { - if (meId == null) { - return false; - } else if (meId === note.userId) { - return true; - } else if (note.reply && (meId === note.reply.userId)) { - // 自分の投稿に対するリプライ - return true; - } else if (note.mentions && note.mentions.some(id => meId === id)) { - // 自分へのメンション - return true; - } else { - // フォロワーかどうか - const [following, user] = await Promise.all([ - this.followingsRepository.count({ - where: { - followeeId: note.userId, - followerId: meId, - }, - take: 1, - }), - this.usersRepository.findOneByOrFail({ id: meId }), - ]); - - /* If we know the following, everyhting is fine. - - But if we do not know the following, it might be that both the - author of the note and the author of the like are remote users, - in which case we can never know the following. Instead we have - to assume that the users are following each other. - */ - return following > 0 || (note.userHost != null && user.host != null); - } - } - - return true; - } - - @bindThis - public async packAttachedFiles(fileIds: MiNote['fileIds'], packedFiles: Map | null>): Promise[]> { - const missingIds = []; - for (const id of fileIds) { - if (!packedFiles.has(id)) missingIds.push(id); - } - if (missingIds.length) { - const additionalMap = await this.driveFileEntityService.packManyByIdsMap(missingIds); - for (const [k, v] of additionalMap) { - packedFiles.set(k, v); - } - } - return fileIds.map(id => packedFiles.get(id)).filter(isNotNull); - } - - @bindThis - public async pack( - src: MiNote['id'] | MiNote, - me?: { id: MiUser['id'] } | null | undefined, - options?: { - detail?: boolean; - skipHide?: boolean; - withReactionAndUserPairCache?: boolean; - _hint_?: { - myReactions: Map; - packedFiles: Map | null>; - }; - }, - ): Promise> { - const opts = Object.assign({ - detail: true, - skipHide: false, - withReactionAndUserPairCache: false, - }, options); - - const meId = me ? me.id : null; - const note = typeof src === 'object' ? src : await this.noteLoader.load(src); - const host = note.userHost; - - let text = note.text; - - if (note.name && (note.url ?? note.uri)) { - text = `【${note.name}】\n${(note.text ?? '').trim()}\n\n${note.url ?? note.uri}`; - } - - const channel = note.channelId - ? note.channel - ? note.channel - : await this.channelsRepository.findOneBy({ id: note.channelId }) - : null; - - const reactionEmojiNames = Object.keys(note.reactions) - .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ - .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); - const packedFiles = options?._hint_?.packedFiles; - - const packed: Packed<'Note'> = await awaitAll({ - id: note.id, - createdAt: this.idService.parse(note.id).date.toISOString(), - updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined, - userId: note.userId, - user: this.userEntityService.pack(note.user ?? note.userId, me, { - detail: false, - }), - text: text, - cw: note.cw, - visibility: note.visibility, - localOnly: note.localOnly, - reactionAcceptance: note.reactionAcceptance, - visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, - renoteCount: note.renoteCount, - repliesCount: note.repliesCount, - reactions: this.reactionService.convertLegacyReactions(note.reactions), - reactionEmojis: this.customEmojiService.populateEmojis(reactionEmojiNames, host), - reactionAndUserPairCache: opts.withReactionAndUserPairCache ? note.reactionAndUserPairCache : undefined, - emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined, - tags: note.tags.length > 0 ? note.tags : undefined, - fileIds: note.fileIds, - files: packedFiles != null ? this.packAttachedFiles(note.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(note.fileIds), - replyId: note.replyId, - renoteId: note.renoteId, - channelId: note.channelId ?? undefined, - channel: channel ? { - id: channel.id, - name: channel.name, - color: channel.color, - isSensitive: channel.isSensitive, - allowRenoteToExternal: channel.allowRenoteToExternal, - } : undefined, - mentions: note.mentions && note.mentions.length > 0 ? note.mentions : undefined, - uri: note.uri ?? undefined, - url: note.url ?? undefined, - poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, - ...(meId && Object.keys(note.reactions).length > 0 ? { - myReaction: this.populateMyReaction(note, meId, options?._hint_), - } : {}), - - ...(opts.detail ? { - clippedCount: note.clippedCount, - - reply: note.replyId ? this.pack(note.reply ?? note.replyId, me, { - detail: false, - skipHide: opts.skipHide, - withReactionAndUserPairCache: opts.withReactionAndUserPairCache, - _hint_: options?._hint_, - }) : undefined, - - renote: note.renoteId ? this.pack(note.renote ?? note.renoteId, me, { - detail: true, - skipHide: opts.skipHide, - withReactionAndUserPairCache: opts.withReactionAndUserPairCache, - _hint_: options?._hint_, - }) : undefined, - } : {}), - }); - - if (!opts.skipHide) { - await this.hideNote(packed, meId); - } - - return packed; - } - - @bindThis - public async packMany( - notes: MiNote[], - me?: { id: MiUser['id'] } | null | undefined, - options?: { - detail?: boolean; - skipHide?: boolean; - }, - ) { - if (notes.length === 0) return []; - - const meId = me ? me.id : null; - const myReactionsMap = new Map(); - if (meId) { - const idsNeedFetchMyReaction = new Set(); - - // パフォーマンスのためノートが作成されてから2秒以上経っていない場合はリアクションを取得しない - const oldId = this.idService.gen(Date.now() - 2000); - - 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); - 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 { - idsNeedFetchMyReaction.add(note.renote.id); - } - } else { - if (note.id < oldId) { - const reactionsCount = Object.values(note.reactions).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 { - idsNeedFetchMyReaction.add(note.id); - } - } else { - myReactionsMap.set(note.id, null); - } - } - } - - const myReactions = idsNeedFetchMyReaction.size > 0 ? await this.noteReactionsRepository.findBy({ - userId: meId, - noteId: In(Array.from(idsNeedFetchMyReaction)), - }) : []; - - for (const id of idsNeedFetchMyReaction) { - myReactionsMap.set(id, myReactions.find(reaction => reaction.noteId === id)?.reaction ?? null); - } - } - - await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes)); - // TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく - const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull); - const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map(); - - return await Promise.all(notes.map(n => this.pack(n, me, { - ...options, - _hint_: { - myReactions: myReactionsMap, - packedFiles, - }, - }))); - } - - @bindThis - public aggregateNoteEmojis(notes: MiNote[]) { - let emojis: { name: string | null; host: string | null; }[] = []; - for (const note of notes) { - emojis = emojis.concat(note.emojis - .map(e => this.customEmojiService.parseEmojiStr(e, note.userHost))); - if (note.renote) { - emojis = emojis.concat(note.renote.emojis - .map(e => this.customEmojiService.parseEmojiStr(e, note.renote!.userHost))); - if (note.renote.user) { - emojis = emojis.concat(note.renote.user.emojis - .map(e => this.customEmojiService.parseEmojiStr(e, note.renote!.userHost))); - } - } - const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis; - emojis = emojis.concat(customReactions); - if (note.user) { - emojis = emojis.concat(note.user.emojis - .map(e => this.customEmojiService.parseEmojiStr(e, note.userHost))); - } - } - return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[]; - } - - @bindThis - private findNoteOrFail(id: string): Promise { - return this.notesRepository.findOneOrFail({ - where: { id }, - relations: ['user'], - }); - } -} diff --git a/packages/backend/src/core/entities/NoteFavoriteEntityService.ts b/packages/backend/src/core/entities/NoteFavoriteEntityService.ts deleted file mode 100644 index 1c9aed413f..0000000000 --- a/packages/backend/src/core/entities/NoteFavoriteEntityService.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { NoteFavoritesRepository } from '@/models/_.js'; -import type { } from '@/models/Blocking.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiNoteFavorite } from '@/models/NoteFavorite.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import { NoteEntityService } from './NoteEntityService.js'; - -@Injectable() -export class NoteFavoriteEntityService { - constructor( - @Inject(DI.noteFavoritesRepository) - private noteFavoritesRepository: NoteFavoritesRepository, - - private noteEntityService: NoteEntityService, - private idService: IdService, - ) { - } - - @bindThis - public async pack( - src: MiNoteFavorite['id'] | MiNoteFavorite, - me?: { id: MiUser['id'] } | null | undefined, - ) { - const favorite = typeof src === 'object' ? src : await this.noteFavoritesRepository.findOneByOrFail({ id: src }); - - return { - id: favorite.id, - createdAt: this.idService.parse(favorite.id).date.toISOString(), - noteId: favorite.noteId, - note: await this.noteEntityService.pack(favorite.note ?? favorite.noteId, me), - }; - } - - @bindThis - public packMany( - favorites: any[], - me: { id: MiUser['id'] }, - ) { - return Promise.all(favorites.map(x => this.pack(x, me))); - } -} diff --git a/packages/backend/src/core/entities/NoteReactionEntityService.ts b/packages/backend/src/core/entities/NoteReactionEntityService.ts deleted file mode 100644 index f4aba3e543..0000000000 --- a/packages/backend/src/core/entities/NoteReactionEntityService.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { NoteReactionsRepository } from '@/models/_.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import type { OnModuleInit } from '@nestjs/common'; -import type { } from '@/models/Blocking.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiNoteReaction } from '@/models/NoteReaction.js'; -import type { ReactionService } from '../ReactionService.js'; -import type { UserEntityService } from './UserEntityService.js'; -import type { NoteEntityService } from './NoteEntityService.js'; -import { ModuleRef } from '@nestjs/core'; - -@Injectable() -export class NoteReactionEntityService implements OnModuleInit { - private userEntityService: UserEntityService; - private noteEntityService: NoteEntityService; - private reactionService: ReactionService; - private idService: IdService; - - constructor( - private moduleRef: ModuleRef, - - @Inject(DI.noteReactionsRepository) - private noteReactionsRepository: NoteReactionsRepository, - - //private userEntityService: UserEntityService, - //private noteEntityService: NoteEntityService, - //private reactionService: ReactionService, - //private idService: IdService, - ) { - } - - onModuleInit() { - this.userEntityService = this.moduleRef.get('UserEntityService'); - this.noteEntityService = this.moduleRef.get('NoteEntityService'); - this.reactionService = this.moduleRef.get('ReactionService'); - this.idService = this.moduleRef.get('IdService'); - } - - @bindThis - public async pack( - src: MiNoteReaction['id'] | MiNoteReaction, - me?: { id: MiUser['id'] } | null | undefined, - options?: { - withNote: boolean; - }, - ): Promise> { - const opts = Object.assign({ - withNote: false, - }, options); - - const reaction = typeof src === 'object' ? src : await this.noteReactionsRepository.findOneByOrFail({ id: src }); - - return { - id: reaction.id, - createdAt: this.idService.parse(reaction.id).date.toISOString(), - user: await this.userEntityService.pack(reaction.user ?? reaction.userId, me), - type: this.reactionService.convertLegacyReaction(reaction.reaction), - ...(opts.withNote ? { - note: await this.noteEntityService.pack(reaction.note ?? reaction.noteId, me), - } : {}), - }; - } -} diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts deleted file mode 100644 index 704081ed00..0000000000 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ /dev/null @@ -1,297 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; -import { In } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { FollowRequestsRepository, NotesRepository, MiUser, UsersRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { MiGroupedNotification, MiNotification } from '@/models/Notification.js'; -import type { MiNote } from '@/models/Note.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { bindThis } from '@/decorators.js'; -import { isNotNull } from '@/misc/is-not-null.js'; -import { FilterUnionByProperty, notificationTypes } from '@/types.js'; -import { RoleEntityService } from './RoleEntityService.js'; -import type { OnModuleInit } from '@nestjs/common'; -import type { UserEntityService } from './UserEntityService.js'; -import type { NoteEntityService } from './NoteEntityService.js'; - -const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded'] as (typeof notificationTypes[number])[]); -const NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded']); - -@Injectable() -export class NotificationEntityService implements OnModuleInit { - private userEntityService: UserEntityService; - private noteEntityService: NoteEntityService; - private roleEntityService: RoleEntityService; - - constructor( - private moduleRef: ModuleRef, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.followRequestsRepository) - private followRequestsRepository: FollowRequestsRepository, - - //private userEntityService: UserEntityService, - //private noteEntityService: NoteEntityService, - ) { - } - - onModuleInit() { - this.userEntityService = this.moduleRef.get('UserEntityService'); - this.noteEntityService = this.moduleRef.get('NoteEntityService'); - this.roleEntityService = this.moduleRef.get('RoleEntityService'); - } - - @bindThis - public async pack( - src: MiNotification, - meId: MiUser['id'], - // eslint-disable-next-line @typescript-eslint/ban-types - options: { - - }, - hint?: { - packedNotes: Map>; - packedUsers: Map>; - }, - ): Promise> { - const notification = src; - const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification ? ( - hint?.packedNotes != null - ? hint.packedNotes.get(notification.noteId) - : this.noteEntityService.pack(notification.noteId, { id: meId }, { - detail: true, - }) - ) : undefined; - const userIfNeed = 'notifierId' in notification ? ( - hint?.packedUsers != null - ? hint.packedUsers.get(notification.notifierId) - : this.userEntityService.pack(notification.notifierId, { id: meId }, { - detail: false, - }) - ) : undefined; - const role = notification.type === 'roleAssigned' ? await this.roleEntityService.pack(notification.roleId) : undefined; - - return await awaitAll({ - id: notification.id, - createdAt: new Date(notification.createdAt).toISOString(), - type: notification.type, - userId: 'notifierId' in notification ? notification.notifierId : undefined, - ...(userIfNeed != null ? { user: userIfNeed } : {}), - ...(noteIfNeed != null ? { note: noteIfNeed } : {}), - ...(notification.type === 'reaction' ? { - reaction: notification.reaction, - } : {}), - ...(notification.type === 'roleAssigned' ? { - role: role, - } : {}), - ...(notification.type === 'achievementEarned' ? { - achievement: notification.achievement, - } : {}), - ...(notification.type === 'app' ? { - body: notification.customBody, - header: notification.customHeader, - icon: notification.customIcon, - } : {}), - }); - } - - @bindThis - public async packMany( - notifications: MiNotification[], - meId: MiUser['id'], - ) { - if (notifications.length === 0) return []; - - let validNotifications = notifications; - - const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull); - const notes = noteIds.length > 0 ? await this.notesRepository.find({ - where: { id: In(noteIds) }, - relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'], - }) : []; - const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, { - detail: true, - }); - const packedNotes = new Map(packedNotesArray.map(p => [p.id, p])); - - validNotifications = validNotifications.filter(x => !('noteId' in x) || packedNotes.has(x.noteId)); - - const userIds = validNotifications.map(x => 'notifierId' in x ? x.notifierId : null).filter(isNotNull); - const users = userIds.length > 0 ? await this.usersRepository.find({ - where: { id: In(userIds) }, - }) : []; - const packedUsersArray = await this.userEntityService.packMany(users, { id: meId }, { - detail: false, - }); - const packedUsers = new Map(packedUsersArray.map(p => [p.id, p])); - - // 既に解決されたフォローリクエストの通知を除外 - const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty => x.type === 'receiveFollowRequest'); - if (followRequestNotifications.length > 0) { - const reqs = await this.followRequestsRepository.find({ - where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) }, - }); - validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId)); - } - - return await Promise.all(validNotifications.map(x => this.pack(x, meId, {}, { - packedNotes, - packedUsers, - }))); - } - - @bindThis - public async packGrouped( - src: MiGroupedNotification, - meId: MiUser['id'], - // eslint-disable-next-line @typescript-eslint/ban-types - options: { - - }, - hint?: { - packedNotes: Map>; - packedUsers: Map>; - }, - ): Promise> { - const notification = src; - const noteIfNeed = NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification ? ( - hint?.packedNotes != null - ? hint.packedNotes.get(notification.noteId) - : this.noteEntityService.pack(notification.noteId, { id: meId }, { - detail: true, - }) - ) : undefined; - const userIfNeed = 'notifierId' in notification ? ( - hint?.packedUsers != null - ? hint.packedUsers.get(notification.notifierId) - : this.userEntityService.pack(notification.notifierId, { id: meId }, { - detail: false, - }) - ) : undefined; - - if (notification.type === 'reaction:grouped') { - const reactions = await Promise.all(notification.reactions.map(async reaction => { - const user = hint?.packedUsers != null - ? hint.packedUsers.get(reaction.userId)! - : await this.userEntityService.pack(reaction.userId, { id: meId }, { - detail: false, - }); - return { - user, - reaction: reaction.reaction, - }; - })); - return await awaitAll({ - id: notification.id, - createdAt: new Date(notification.createdAt).toISOString(), - type: notification.type, - note: noteIfNeed, - reactions, - }); - } else if (notification.type === 'renote:grouped') { - const users = await Promise.all(notification.userIds.map(userId => { - const packedUser = hint?.packedUsers != null ? hint.packedUsers.get(userId) : null; - if (packedUser) { - return packedUser; - } - - return this.userEntityService.pack(userId, { id: meId }, { - detail: false, - }); - })); - return await awaitAll({ - id: notification.id, - createdAt: new Date(notification.createdAt).toISOString(), - type: notification.type, - note: noteIfNeed, - users, - }); - } - - const role = notification.type === 'roleAssigned' ? await this.roleEntityService.pack(notification.roleId) : undefined; - - return await awaitAll({ - id: notification.id, - createdAt: new Date(notification.createdAt).toISOString(), - type: notification.type, - userId: 'notifierId' in notification ? notification.notifierId : undefined, - ...(userIfNeed != null ? { user: userIfNeed } : {}), - ...(noteIfNeed != null ? { note: noteIfNeed } : {}), - ...(notification.type === 'reaction' ? { - reaction: notification.reaction, - } : {}), - ...(notification.type === 'roleAssigned' ? { - role: role, - } : {}), - ...(notification.type === 'achievementEarned' ? { - achievement: notification.achievement, - } : {}), - ...(notification.type === 'app' ? { - body: notification.customBody, - header: notification.customHeader, - icon: notification.customIcon, - } : {}), - }); - } - - @bindThis - public async packGroupedMany( - notifications: MiGroupedNotification[], - meId: MiUser['id'], - ) { - if (notifications.length === 0) return []; - - let validNotifications = notifications; - - const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull); - const notes = noteIds.length > 0 ? await this.notesRepository.find({ - where: { id: In(noteIds) }, - relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'], - }) : []; - const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, { - detail: true, - }); - const packedNotes = new Map(packedNotesArray.map(p => [p.id, p])); - - validNotifications = validNotifications.filter(x => !('noteId' in x) || packedNotes.has(x.noteId)); - - const userIds = []; - for (const notification of validNotifications) { - if ('notifierId' in notification) userIds.push(notification.notifierId); - if (notification.type === 'reaction:grouped') userIds.push(...notification.reactions.map(x => x.userId)); - if (notification.type === 'renote:grouped') userIds.push(...notification.userIds); - } - const users = userIds.length > 0 ? await this.usersRepository.find({ - where: { id: In(userIds) }, - }) : []; - const packedUsersArray = await this.userEntityService.packMany(users, { id: meId }, { - detail: false, - }); - const packedUsers = new Map(packedUsersArray.map(p => [p.id, p])); - - // 既に解決されたフォローリクエストの通知を除外 - const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty => x.type === 'receiveFollowRequest'); - if (followRequestNotifications.length > 0) { - const reqs = await this.followRequestsRepository.find({ - where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) }, - }); - validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId)); - } - - return await Promise.all(validNotifications.map(x => this.packGrouped(x, meId, {}, { - packedNotes, - packedUsers, - }))); - } -} diff --git a/packages/backend/src/core/entities/PageEntityService.ts b/packages/backend/src/core/entities/PageEntityService.ts deleted file mode 100644 index f39ef949db..0000000000 --- a/packages/backend/src/core/entities/PageEntityService.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository, PagesRepository, PageLikesRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiPage } from '@/models/Page.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import { UserEntityService } from './UserEntityService.js'; -import { DriveFileEntityService } from './DriveFileEntityService.js'; - -@Injectable() -export class PageEntityService { - constructor( - @Inject(DI.pagesRepository) - private pagesRepository: PagesRepository, - - @Inject(DI.pageLikesRepository) - private pageLikesRepository: PageLikesRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private userEntityService: UserEntityService, - private driveFileEntityService: DriveFileEntityService, - private idService: IdService, - ) { - } - - @bindThis - public async pack( - src: MiPage['id'] | MiPage, - me?: { id: MiUser['id'] } | null | undefined, - ): Promise> { - const meId = me ? me.id : null; - const page = typeof src === 'object' ? src : await this.pagesRepository.findOneByOrFail({ id: src }); - - const attachedFiles: Promise[] = []; - const collectFile = (xs: any[]) => { - for (const x of xs) { - if (x.type === 'image') { - attachedFiles.push(this.driveFilesRepository.findOneBy({ - id: x.fileId, - userId: page.userId, - })); - } - if (x.children) { - collectFile(x.children); - } - } - }; - collectFile(page.content); - - // 後方互換性のため - let migrated = false; - const migrate = (xs: any[]) => { - for (const x of xs) { - if (x.type === 'input') { - if (x.inputType === 'text') { - x.type = 'textInput'; - } - if (x.inputType === 'number') { - x.type = 'numberInput'; - if (x.default) x.default = parseInt(x.default, 10); - } - migrated = true; - } - if (x.children) { - migrate(x.children); - } - } - }; - migrate(page.content); - if (migrated) { - this.pagesRepository.update(page.id, { - content: page.content, - }); - } - - return await awaitAll({ - id: page.id, - createdAt: this.idService.parse(page.id).date.toISOString(), - updatedAt: page.updatedAt.toISOString(), - userId: page.userId, - user: this.userEntityService.pack(page.user ?? page.userId, me), // { detail: true } すると無限ループするので注意 - content: page.content, - variables: page.variables, - title: page.title, - name: page.name, - summary: page.summary, - hideTitleWhenPinned: page.hideTitleWhenPinned, - alignCenter: page.alignCenter, - font: page.font, - script: page.script, - eyeCatchingImageId: page.eyeCatchingImageId, - eyeCatchingImage: page.eyeCatchingImageId ? await this.driveFileEntityService.pack(page.eyeCatchingImageId) : null, - attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter((x): x is MiDriveFile => x != null)), - likedCount: page.likedCount, - isLiked: meId ? await this.pageLikesRepository.exist({ where: { pageId: page.id, userId: meId } }) : undefined, - }); - } - - @bindThis - public packMany( - pages: MiPage[], - me?: { id: MiUser['id'] } | null | undefined, - ) { - return Promise.all(pages.map(x => this.pack(x, me))); - } -} - diff --git a/packages/backend/src/core/entities/PageLikeEntityService.ts b/packages/backend/src/core/entities/PageLikeEntityService.ts deleted file mode 100644 index 4dc691ab93..0000000000 --- a/packages/backend/src/core/entities/PageLikeEntityService.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { PageLikesRepository } from '@/models/_.js'; -import type { } from '@/models/Blocking.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiPageLike } from '@/models/PageLike.js'; -import { bindThis } from '@/decorators.js'; -import { PageEntityService } from './PageEntityService.js'; - -@Injectable() -export class PageLikeEntityService { - constructor( - @Inject(DI.pageLikesRepository) - private pageLikesRepository: PageLikesRepository, - - private pageEntityService: PageEntityService, - ) { - } - - @bindThis - public async pack( - src: MiPageLike['id'] | MiPageLike, - me?: { id: MiUser['id'] } | null | undefined, - ) { - const like = typeof src === 'object' ? src : await this.pageLikesRepository.findOneByOrFail({ id: src }); - - return { - id: like.id, - page: await this.pageEntityService.pack(like.page ?? like.pageId, me), - }; - } - - @bindThis - public packMany( - likes: any[], - me: { id: MiUser['id'] }, - ) { - return Promise.all(likes.map(x => this.pack(x, me))); - } -} - diff --git a/packages/backend/src/core/entities/RenoteMutingEntityService.ts b/packages/backend/src/core/entities/RenoteMutingEntityService.ts deleted file mode 100644 index 3f9dc9180a..0000000000 --- a/packages/backend/src/core/entities/RenoteMutingEntityService.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { RenoteMutingsRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiRenoteMuting } from '@/models/RenoteMuting.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import { UserEntityService } from './UserEntityService.js'; - -@Injectable() -export class RenoteMutingEntityService { - constructor( - @Inject(DI.renoteMutingsRepository) - private renoteMutingsRepository: RenoteMutingsRepository, - - private userEntityService: UserEntityService, - private idService: IdService, - ) { - } - - @bindThis - public async pack( - src: MiRenoteMuting['id'] | MiRenoteMuting, - me?: { id: MiUser['id'] } | null | undefined, - ): Promise> { - const muting = typeof src === 'object' ? src : await this.renoteMutingsRepository.findOneByOrFail({ id: src }); - - return await awaitAll({ - id: muting.id, - createdAt: this.idService.parse(muting.id).date.toISOString(), - muteeId: muting.muteeId, - mutee: this.userEntityService.pack(muting.muteeId, me, { - detail: true, - }), - }); - } - - @bindThis - public packMany( - mutings: any[], - me: { id: MiUser['id'] }, - ) { - return Promise.all(mutings.map(x => this.pack(x, me))); - } -} - diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts deleted file mode 100644 index 5563f9a1ac..0000000000 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Brackets } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { RoleAssignmentsRepository, RolesRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiRole } from '@/models/Role.js'; -import { bindThis } from '@/decorators.js'; -import { DEFAULT_POLICIES } from '@/core/RoleService.js'; -import { IdService } from '@/core/IdService.js'; - -@Injectable() -export class RoleEntityService { - constructor( - @Inject(DI.rolesRepository) - private rolesRepository: RolesRepository, - - @Inject(DI.roleAssignmentsRepository) - private roleAssignmentsRepository: RoleAssignmentsRepository, - - private idService: IdService, - ) { - } - - @bindThis - public async pack( - src: MiRole['id'] | MiRole, - me?: { id: MiUser['id'] } | null | undefined, - ) { - const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src }); - - const assignedCount = await this.roleAssignmentsRepository.createQueryBuilder('assign') - .where('assign.roleId = :roleId', { roleId: role.id }) - .andWhere(new Brackets(qb => { - qb - .where('assign.expiresAt IS NULL') - .orWhere('assign.expiresAt > :now', { now: new Date() }); - })) - .getCount(); - - const policies = { ...role.policies }; - for (const [k, v] of Object.entries(DEFAULT_POLICIES)) { - if (policies[k] == null) policies[k] = { - useDefault: true, - priority: 0, - value: v, - }; - } - - return await awaitAll({ - id: role.id, - createdAt: this.idService.parse(role.id).date.toISOString(), - updatedAt: role.updatedAt.toISOString(), - name: role.name, - description: role.description, - color: role.color, - iconUrl: role.iconUrl, - target: role.target, - condFormula: role.condFormula, - isPublic: role.isPublic, - isAdministrator: role.isAdministrator, - isModerator: role.isModerator, - isExplorable: role.isExplorable, - asBadge: role.asBadge, - canEditMembersByModerator: role.canEditMembersByModerator, - displayOrder: role.displayOrder, - policies: policies, - usersCount: assignedCount, - }); - } - - @bindThis - public packMany( - roles: any[], - me: { id: MiUser['id'] }, - ) { - return Promise.all(roles.map(x => this.pack(x, me))); - } -} - diff --git a/packages/backend/src/core/entities/SigninEntityService.ts b/packages/backend/src/core/entities/SigninEntityService.ts deleted file mode 100644 index 6bde3e589a..0000000000 --- a/packages/backend/src/core/entities/SigninEntityService.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import type { } from '@/models/Blocking.js'; -import type { MiSignin } from '@/models/Signin.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; - -@Injectable() -export class SigninEntityService { - constructor( - private idService: IdService, - ) { - } - - @bindThis - public async pack( - src: MiSignin, - ) { - return { - id: src.id, - createdAt: this.idService.parse(src.id).date.toISOString(), - ip: src.ip, - headers: src.headers, - success: src.success, - }; - } -} - diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts deleted file mode 100644 index adb7dfbf86..0000000000 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ /dev/null @@ -1,572 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import _Ajv from 'ajv'; -import { ModuleRef } from '@nestjs/core'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { Promiseable } from '@/misc/prelude/await-all.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; -import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js'; -import { birthdaySchema, listenbrainzSchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/User.js'; -import { MiNotification } from '@/models/Notification.js'; -import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, UserNotePiningsRepository, UserProfilesRepository, AnnouncementReadsRepository, AnnouncementsRepository, MiUserProfile, RenoteMutingsRepository, UserMemoRepository } from '@/models/_.js'; -import { bindThis } from '@/decorators.js'; -import { RoleService } from '@/core/RoleService.js'; -import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; -import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import { IdService } from '@/core/IdService.js'; -import type { AnnouncementService } from '@/core/AnnouncementService.js'; -import type { CustomEmojiService } from '@/core/CustomEmojiService.js'; -import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; -import type { OnModuleInit } from '@nestjs/common'; -import type { NoteEntityService } from './NoteEntityService.js'; -import type { DriveFileEntityService } from './DriveFileEntityService.js'; -import type { PageEntityService } from './PageEntityService.js'; - -type IsUserDetailed = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>; -type IsMeAndIsUserDetailed = - Detailed extends true ? - ExpectsMe extends true ? Packed<'MeDetailed'> : - ExpectsMe extends false ? Packed<'UserDetailedNotMe'> : - Packed<'UserDetailed'> : - Packed<'UserLite'>; - -const Ajv = _Ajv.default; -const ajv = new Ajv(); - -function isLocalUser(user: MiUser): user is MiLocalUser; -function isLocalUser(user: T): user is (T & { host: null; }); -function isLocalUser(user: MiUser | { host: MiUser['host'] }): boolean { - return user.host == null; -} - -function isRemoteUser(user: MiUser): user is MiRemoteUser; -function isRemoteUser(user: T): user is (T & { host: string; }); -function isRemoteUser(user: MiUser | { host: MiUser['host'] }): boolean { - return !isLocalUser(user); -} - -@Injectable() -export class UserEntityService implements OnModuleInit { - private apPersonService: ApPersonService; - private noteEntityService: NoteEntityService; - private driveFileEntityService: DriveFileEntityService; - private pageEntityService: PageEntityService; - private customEmojiService: CustomEmojiService; - private announcementService: AnnouncementService; - private roleService: RoleService; - private federatedInstanceService: FederatedInstanceService; - private idService: IdService; - private avatarDecorationService: AvatarDecorationService; - - constructor( - private moduleRef: ModuleRef, - - @Inject(DI.config) - private config: Config, - - @Inject(DI.redis) - private redisClient: Redis.Redis, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userSecurityKeysRepository) - private userSecurityKeysRepository: UserSecurityKeysRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - @Inject(DI.followRequestsRepository) - private followRequestsRepository: FollowRequestsRepository, - - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - @Inject(DI.renoteMutingsRepository) - private renoteMutingsRepository: RenoteMutingsRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - @Inject(DI.noteUnreadsRepository) - private noteUnreadsRepository: NoteUnreadsRepository, - - @Inject(DI.userNotePiningsRepository) - private userNotePiningsRepository: UserNotePiningsRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.announcementReadsRepository) - private announcementReadsRepository: AnnouncementReadsRepository, - - @Inject(DI.announcementsRepository) - private announcementsRepository: AnnouncementsRepository, - - @Inject(DI.userMemosRepository) - private userMemosRepository: UserMemoRepository, - ) { - } - - onModuleInit() { - this.apPersonService = this.moduleRef.get('ApPersonService'); - this.noteEntityService = this.moduleRef.get('NoteEntityService'); - this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); - this.pageEntityService = this.moduleRef.get('PageEntityService'); - this.customEmojiService = this.moduleRef.get('CustomEmojiService'); - this.announcementService = this.moduleRef.get('AnnouncementService'); - this.roleService = this.moduleRef.get('RoleService'); - this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); - this.idService = this.moduleRef.get('IdService'); - this.avatarDecorationService = this.moduleRef.get('AvatarDecorationService'); - } - - //#region Validators - public validateLocalUsername = ajv.compile(localUsernameSchema); - public validatePassword = ajv.compile(passwordSchema); - public validateName = ajv.compile(nameSchema); - public validateDescription = ajv.compile(descriptionSchema); - public validateLocation = ajv.compile(locationSchema); - public validateBirthday = ajv.compile(birthdaySchema); - public validateListenBrainz = ajv.compile(listenbrainzSchema); - //#endregion - - public isLocalUser = isLocalUser; - public isRemoteUser = isRemoteUser; - - @bindThis - public async getRelation(me: MiUser['id'], target: MiUser['id']) { - const [ - following, - isFollowed, - hasPendingFollowRequestFromYou, - hasPendingFollowRequestToYou, - isBlocking, - isBlocked, - isMuted, - isRenoteMuted, - ] = await Promise.all([ - this.followingsRepository.findOneBy({ - followerId: me, - followeeId: target, - }), - this.followingsRepository.exist({ - where: { - followerId: target, - followeeId: me, - }, - }), - this.followRequestsRepository.exist({ - where: { - followerId: me, - followeeId: target, - }, - }), - this.followRequestsRepository.exist({ - where: { - followerId: target, - followeeId: me, - }, - }), - this.blockingsRepository.exist({ - where: { - blockerId: me, - blockeeId: target, - }, - }), - this.blockingsRepository.exist({ - where: { - blockerId: target, - blockeeId: me, - }, - }), - this.mutingsRepository.exist({ - where: { - muterId: me, - muteeId: target, - }, - }), - this.renoteMutingsRepository.exist({ - where: { - muterId: me, - muteeId: target, - }, - }), - ]); - - return { - id: target, - following, - isFollowing: following != null, - isFollowed, - hasPendingFollowRequestFromYou, - hasPendingFollowRequestToYou, - isBlocking, - isBlocked, - isMuted, - isRenoteMuted, - }; - } - - @bindThis - public async getHasUnreadAntenna(userId: MiUser['id']): Promise { - /* - const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); - - const isUnread = (myAntennas.length > 0 ? await this.antennaNotesRepository.exist({ - where: { - antennaId: In(myAntennas.map(x => x.id)), - read: false, - }, - }) : false); - - return isUnread; - */ - return false; // TODO - } - - @bindThis - public async getNotificationsInfo(userId: MiUser['id']): Promise<{ - hasUnread: boolean; - unreadCount: number; - }> { - const response = { - hasUnread: false, - unreadCount: 0, - }; - - const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); - - if (!latestReadNotificationId) { - response.unreadCount = await this.redisClient.xlen(`notificationTimeline:${userId}`); - } else { - const latestNotificationIdsRes = await this.redisClient.xrevrange( - `notificationTimeline:${userId}`, - '+', - latestReadNotificationId, - ); - - response.unreadCount = (latestNotificationIdsRes.length - 1 >= 0) ? latestNotificationIdsRes.length - 1 : 0; - } - - if (response.unreadCount > 0) { - response.hasUnread = true; - } - - return response; - } - - @bindThis - public async getHasPendingReceivedFollowRequest(userId: MiUser['id']): Promise { - const count = await this.followRequestsRepository.countBy({ - followeeId: userId, - }); - - return count > 0; - } - - @bindThis - public getOnlineStatus(user: MiUser): 'unknown' | 'online' | 'active' | 'offline' { - if (user.hideOnlineStatus) return 'unknown'; - if (user.lastActiveDate == null) return 'unknown'; - const elapsed = Date.now() - user.lastActiveDate.getTime(); - return ( - elapsed < USER_ONLINE_THRESHOLD ? 'online' : - elapsed < USER_ACTIVE_THRESHOLD ? 'active' : - 'offline' - ); - } - - @bindThis - public getIdenticonUrl(user: MiUser): string { - return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`; - } - - @bindThis - public getUserUri(user: MiLocalUser | MiPartialLocalUser | MiRemoteUser | MiPartialRemoteUser): string { - return this.isRemoteUser(user) - ? user.uri : this.genLocalUserUri(user.id); - } - - @bindThis - public genLocalUserUri(userId: string): string { - return `${this.config.url}/users/${userId}`; - } - - public async pack( - src: MiUser['id'] | MiUser, - me?: { id: MiUser['id']; } | null | undefined, - options?: { - detail?: D, - includeSecrets?: boolean, - userProfile?: MiUserProfile, - }, - ): Promise> { - const opts = Object.assign({ - detail: false, - includeSecrets: false, - }, options); - - const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src }); - - // migration - if (user.avatarId != null && user.avatarUrl === null) { - const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); - user.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); - this.usersRepository.update(user.id, { - avatarUrl: user.avatarUrl, - avatarBlurhash: avatar.blurhash, - }); - } - if (user.bannerId != null && user.bannerUrl === null) { - const banner = await this.driveFilesRepository.findOneByOrFail({ id: user.bannerId }); - user.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); - this.usersRepository.update(user.id, { - bannerUrl: user.bannerUrl, - bannerBlurhash: banner.blurhash, - }); - } - if (user.backgroundId != null && user.backgroundUrl === null) { - const background = await this.driveFilesRepository.findOneByOrFail({ id: user.backgroundId }); - user.backgroundUrl = this.driveFileEntityService.getPublicUrl(background); - this.usersRepository.update(user.id, { - backgroundUrl: user.backgroundUrl, - backgroundBlurhash: background.blurhash, - }); - } - - const meId = me ? me.id : null; - const isMe = meId === user.id; - const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false; - - const relation = meId && !isMe && opts.detail ? await this.getRelation(meId, user.id) : null; - const pins = opts.detail ? await this.userNotePiningsRepository.createQueryBuilder('pin') - .where('pin.userId = :userId', { userId: user.id }) - .innerJoinAndSelect('pin.note', 'note') - .orderBy('pin.id', 'DESC') - .getMany() : []; - const profile = opts.detail ? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) : null; - - const mastoapi = !opts.detail ? opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null; - - const followingCount = profile == null ? null : - (profile.followingVisibility === 'public') || isMe ? user.followingCount : - (profile.followingVisibility === 'followers') && (relation && relation.isFollowing) ? user.followingCount : - null; - - const followersCount = profile == null ? null : - (profile.followersVisibility === 'public') || isMe ? user.followersCount : - (profile.followersVisibility === 'followers') && (relation && relation.isFollowing) ? user.followersCount : - null; - - const isModerator = isMe && opts.detail ? this.roleService.isModerator(user) : null; - const isAdmin = isMe && opts.detail ? this.roleService.isAdministrator(user) : null; - const unreadAnnouncements = isMe && opts.detail ? - (await this.announcementService.getUnreadAnnouncements(user)).map((announcement) => ({ - createdAt: this.idService.parse(announcement.id).date.toISOString(), - ...announcement, - })) : null; - - const checkHost = user.host == null ? this.config.host : user.host; - - const notificationsInfo = isMe && opts.detail ? await this.getNotificationsInfo(user.id) : null; - - const packed = { - id: user.id, - name: user.name, - username: user.username, - host: user.host, - avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user), - avatarBlurhash: user.avatarBlurhash, - description: mastoapi ? mastoapi.description : profile ? profile.description : '', - createdAt: this.idService.parse(user.id).date.toISOString(), - avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({ - id: ud.id, - angle: ud.angle || undefined, - flipH: ud.flipH || undefined, - offsetX: ud.offsetX || undefined, - offsetY: ud.offsetY || undefined, - url: decorations.find(d => d.id === ud.id)!.url, - }))) : [], - isBot: user.isBot, - isCat: user.isCat, - noindex: user.noindex, - isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), - speakAsCat: user.speakAsCat ?? false, - approved: user.approved, - instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? { - name: instance.name, - softwareName: instance.softwareName, - softwareVersion: instance.softwareVersion, - iconUrl: instance.iconUrl, - faviconUrl: instance.faviconUrl, - themeColor: instance.themeColor, - } : undefined) : undefined, - followersCount: followersCount ?? 0, - followingCount: followingCount ?? 0, - notesCount: user.notesCount, - emojis: this.customEmojiService.populateEmojis(user.emojis, checkHost), - onlineStatus: this.getOnlineStatus(user), - // パフォーマンス上の理由でローカルユーザーのみ - badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then(rs => rs.sort((a, b) => b.displayOrder - a.displayOrder).map(r => ({ - name: r.name, - iconUrl: r.iconUrl, - displayOrder: r.displayOrder, - }))) : undefined, - - ...(opts.detail ? { - url: profile!.url, - uri: user.uri, - movedTo: user.movedToUri ? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null) : null, - alsoKnownAs: user.alsoKnownAs - ? Promise.all(user.alsoKnownAs.map(uri => this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null))) - .then(xs => xs.length === 0 ? null : xs.filter(x => x != null) as string[]) - : null, - updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, - lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, - bannerUrl: user.bannerUrl, - bannerBlurhash: user.bannerBlurhash, - backgroundUrl: user.backgroundUrl, - backgroundBlurhash: user.backgroundBlurhash, - isLocked: user.isLocked, - isSuspended: user.isSuspended, - location: profile!.location, - birthday: profile!.birthday, - listenbrainz: profile!.listenbrainz, - lang: profile!.lang, - fields: profile!.fields, - verifiedLinks: profile!.verifiedLinks, - pinnedNoteIds: pins.map(pin => pin.noteId), - pinnedNotes: this.noteEntityService.packMany(pins.map(pin => pin.note!), me, { - detail: true, - }), - pinnedPageId: profile!.pinnedPageId, - pinnedPage: profile!.pinnedPageId ? this.pageEntityService.pack(profile!.pinnedPageId, me) : null, - publicReactions: profile!.publicReactions, - followersVisibility: profile!.followersVisibility, - followingVisibility: profile!.followingVisibility, - twoFactorEnabled: profile!.twoFactorEnabled, - usePasswordLessLogin: profile!.usePasswordLessLogin, - securityKeys: profile!.twoFactorEnabled - ? this.userSecurityKeysRepository.countBy({ - userId: user.id, - }).then(result => result >= 1) - : false, - roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ - id: role.id, - name: role.name, - color: role.color, - iconUrl: role.iconUrl, - description: role.description, - isModerator: role.isModerator, - isAdministrator: role.isAdministrator, - displayOrder: role.displayOrder, - }))), - memo: meId == null ? null : await this.userMemosRepository.findOneBy({ - userId: meId, - targetUserId: user.id, - }).then(row => row?.memo ?? null), - moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined, - } : {}), - - ...(opts.detail && isMe ? { - avatarId: user.avatarId, - bannerId: user.bannerId, - backgroundId: user.backgroundId, - isModerator: isModerator, - isAdmin: isAdmin, - injectFeaturedNote: profile!.injectFeaturedNote, - receiveAnnouncementEmail: profile!.receiveAnnouncementEmail, - alwaysMarkNsfw: profile!.alwaysMarkNsfw, - autoSensitive: profile!.autoSensitive, - carefulBot: profile!.carefulBot, - autoAcceptFollowed: profile!.autoAcceptFollowed, - noCrawle: profile!.noCrawle, - preventAiLearning: profile!.preventAiLearning, - isExplorable: user.isExplorable, - isDeleted: user.isDeleted, - twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none', - hideOnlineStatus: user.hideOnlineStatus, - hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({ - where: { userId: user.id, isSpecified: true }, - take: 1, - }).then(count => count > 0), - hasUnreadMentions: this.noteUnreadsRepository.count({ - where: { userId: user.id, isMentioned: true }, - take: 1, - }).then(count => count > 0), - hasUnreadAnnouncement: unreadAnnouncements!.length > 0, - unreadAnnouncements, - hasUnreadAntenna: this.getHasUnreadAntenna(user.id), - hasUnreadChannel: false, // 後方互換性のため - hasUnreadNotification: notificationsInfo?.hasUnread, // 後方互換性のため - hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), - unreadNotificationsCount: notificationsInfo?.unreadCount, - mutedWords: profile!.mutedWords, - hardMutedWords: profile!.hardMutedWords, - mutedInstances: profile!.mutedInstances, - mutingNotificationTypes: [], // 後方互換性のため - notificationRecieveConfig: profile!.notificationRecieveConfig, - emailNotificationTypes: profile!.emailNotificationTypes, - achievements: profile!.achievements, - loggedInDays: profile!.loggedInDates.length, - policies: this.roleService.getUserPolicies(user.id), - } : {}), - - ...(opts.includeSecrets ? { - email: profile!.email, - emailVerified: profile!.emailVerified, - signupReason: user.signupReason, - securityKeysList: profile!.twoFactorEnabled - ? this.userSecurityKeysRepository.find({ - where: { - userId: user.id, - }, - select: { - id: true, - name: true, - lastUsed: true, - }, - }) - : [], - } : {}), - - ...(relation ? { - isFollowing: relation.isFollowing, - isFollowed: relation.isFollowed, - hasPendingFollowRequestFromYou: relation.hasPendingFollowRequestFromYou, - hasPendingFollowRequestToYou: relation.hasPendingFollowRequestToYou, - isBlocking: relation.isBlocking, - isBlocked: relation.isBlocked, - isMuted: relation.isMuted, - isRenoteMuted: relation.isRenoteMuted, - notify: relation.following?.notify ?? 'none', - withReplies: relation.following?.withReplies ?? false, - } : {}), - } as Promiseable> as Promiseable>; - - return await awaitAll(packed); - } - - public packMany( - users: (MiUser['id'] | MiUser)[], - me?: { id: MiUser['id'] } | null | undefined, - options?: { - detail?: D, - includeSecrets?: boolean, - }, - ): Promise[]> { - return Promise.all(users.map(u => this.pack(u, me, options))); - } -} diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts deleted file mode 100644 index 31ab7293da..0000000000 --- a/packages/backend/src/core/entities/UserListEntityService.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; -import type { MiUserList } from '@/models/UserList.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import { UserEntityService } from './UserEntityService.js'; - -@Injectable() -export class UserListEntityService { - constructor( - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - private userEntityService: UserEntityService, - private idService: IdService, - ) { - } - - @bindThis - public async pack( - src: MiUserList['id'] | MiUserList, - ): Promise> { - const userList = typeof src === 'object' ? src : await this.userListsRepository.findOneByOrFail({ id: src }); - - const users = await this.userListMembershipsRepository.findBy({ - userListId: userList.id, - }); - - return { - id: userList.id, - createdAt: this.idService.parse(userList.id).date.toISOString(), - name: userList.name, - userIds: users.map(x => x.userId), - isPublic: userList.isPublic, - }; - } - - @bindThis - public async packMembershipsMany( - memberships: MiUserListMembership[], - ) { - return Promise.all(memberships.map(async x => ({ - id: x.id, - createdAt: this.idService.parse(x.id).date.toISOString(), - userId: x.userId, - user: await this.userEntityService.pack(x.userId), - withReplies: x.withReplies, - }))); - } -} - diff --git a/packages/backend/src/daemons/DaemonModule.ts b/packages/backend/src/daemons/DaemonModule.ts deleted file mode 100644 index 236985076c..0000000000 --- a/packages/backend/src/daemons/DaemonModule.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Module } from '@nestjs/common'; -import { CoreModule } from '@/core/CoreModule.js'; -import { GlobalModule } from '@/GlobalModule.js'; -import { QueueStatsService } from './QueueStatsService.js'; -import { ServerStatsService } from './ServerStatsService.js'; - -@Module({ - imports: [ - GlobalModule, - CoreModule, - ], - providers: [ - QueueStatsService, - ServerStatsService, - ], - exports: [ - QueueStatsService, - ServerStatsService, - ], -}) -export class DaemonModule {} diff --git a/packages/backend/src/daemons/QueueStatsService.ts b/packages/backend/src/daemons/QueueStatsService.ts deleted file mode 100644 index 5edc0f45ab..0000000000 --- a/packages/backend/src/daemons/QueueStatsService.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import Xev from 'xev'; -import * as Bull from 'bullmq'; -import { QueueService } from '@/core/QueueService.js'; -import { bindThis } from '@/decorators.js'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { QUEUE, baseQueueOptions } from '@/queue/const.js'; -import type { OnApplicationShutdown } from '@nestjs/common'; - -const ev = new Xev(); - -const interval = 10000; - -@Injectable() -export class QueueStatsService implements OnApplicationShutdown { - private intervalId: NodeJS.Timeout; - - constructor( - @Inject(DI.config) - private config: Config, - - private queueService: QueueService, - ) { - } - - /** - * Report queue stats regularly - */ - @bindThis - public start(): void { - const log = [] as any[]; - - ev.on('requestQueueStatsLog', x => { - ev.emit(`queueStatsLog:${x.id}`, log.slice(0, x.length ?? 50)); - }); - - let activeDeliverJobs = 0; - let activeInboxJobs = 0; - - const deliverQueueEvents = new Bull.QueueEvents(QUEUE.DELIVER, baseQueueOptions(this.config, QUEUE.DELIVER)); - const inboxQueueEvents = new Bull.QueueEvents(QUEUE.INBOX, baseQueueOptions(this.config, QUEUE.INBOX)); - - deliverQueueEvents.on('active', () => { - activeDeliverJobs++; - }); - - inboxQueueEvents.on('active', () => { - activeInboxJobs++; - }); - - const tick = async () => { - const deliverJobCounts = await this.queueService.deliverQueue.getJobCounts(); - const inboxJobCounts = await this.queueService.inboxQueue.getJobCounts(); - - const stats = { - deliver: { - activeSincePrevTick: activeDeliverJobs, - active: deliverJobCounts.active, - waiting: deliverJobCounts.waiting, - delayed: deliverJobCounts.delayed, - }, - inbox: { - activeSincePrevTick: activeInboxJobs, - active: inboxJobCounts.active, - waiting: inboxJobCounts.waiting, - delayed: inboxJobCounts.delayed, - }, - }; - - ev.emit('queueStats', stats); - - log.unshift(stats); - if (log.length > 200) log.pop(); - - activeDeliverJobs = 0; - activeInboxJobs = 0; - }; - - tick(); - - this.intervalId = setInterval(tick, interval); - } - - @bindThis - public dispose(): void { - clearInterval(this.intervalId); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/daemons/ServerStatsService.ts b/packages/backend/src/daemons/ServerStatsService.ts deleted file mode 100644 index c5ef9b2fa3..0000000000 --- a/packages/backend/src/daemons/ServerStatsService.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import si from 'systeminformation'; -import Xev from 'xev'; -import * as osUtils from 'os-utils'; -import { bindThis } from '@/decorators.js'; -import { MetaService } from '@/core/MetaService.js'; -import type { OnApplicationShutdown } from '@nestjs/common'; - -const ev = new Xev(); - -const interval = 2000; - -const roundCpu = (num: number) => Math.round(num * 1000) / 1000; -const round = (num: number) => Math.round(num * 10) / 10; - -@Injectable() -export class ServerStatsService implements OnApplicationShutdown { - private intervalId: NodeJS.Timeout | null = null; - - constructor( - private metaService: MetaService, - ) { - } - - /** - * Report server stats regularly - */ - @bindThis - public async start(): Promise { - if (!(await this.metaService.fetch(true)).enableServerMachineStats) return; - - const log = [] as any[]; - - ev.on('requestServerStatsLog', x => { - ev.emit(`serverStatsLog:${x.id}`, log.slice(0, x.length ?? 50)); - }); - - const tick = async () => { - const cpu = await cpuUsage(); - const memStats = await mem(); - const netStats = await net(); - const fsStats = await fs(); - - const stats = { - cpu: roundCpu(cpu), - mem: { - used: round(memStats.total - memStats.available), - active: round(memStats.active), - }, - net: { - rx: round(Math.max(0, netStats.rx_sec)), - tx: round(Math.max(0, netStats.tx_sec)), - }, - fs: { - r: round(Math.max(0, fsStats.rIO_sec ?? 0)), - w: round(Math.max(0, fsStats.wIO_sec ?? 0)), - }, - }; - ev.emit('serverStats', stats); - log.unshift(stats); - if (log.length > 200) log.pop(); - }; - - tick(); - - this.intervalId = setInterval(tick, interval); - } - - @bindThis - public dispose(): void { - if (this.intervalId) { - clearInterval(this.intervalId); - } - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} - -// CPU STAT -function cpuUsage(): Promise { - return new Promise((res, rej) => { - osUtils.cpuUsage((cpuUsage) => { - res(cpuUsage); - }); - }); -} - -// MEMORY STAT -async function mem() { - const data = await si.mem(); - return data; -} - -// NETWORK STAT -async function net() { - const iface = await si.networkInterfaceDefault(); - const data = await si.networkStats(iface); - return data[0]; -} - -// FS STAT -async function fs() { - return await si.disksIO().catch(() => ({ rIO_sec: 0, wIO_sec: 0 })); -} diff --git a/packages/backend/src/daemons/janitor.ts b/packages/backend/src/daemons/janitor.ts new file mode 100644 index 0000000000..f2a1bfcc2f --- /dev/null +++ b/packages/backend/src/daemons/janitor.ts @@ -0,0 +1,20 @@ +// TODO: 消したい + +const interval = 30 * 60 * 1000; +import { AttestationChallenges } from '@/models/index.js'; +import { LessThan } from 'typeorm'; + +/** + * Clean up database occasionally + */ +export default function() { + async function tick() { + await AttestationChallenges.delete({ + createdAt: LessThan(new Date(new Date().getTime() - 5 * 60 * 1000)), + }); + } + + tick(); + + setInterval(tick, interval); +} diff --git a/packages/backend/src/daemons/queue-stats.ts b/packages/backend/src/daemons/queue-stats.ts new file mode 100644 index 0000000000..1535abc6af --- /dev/null +++ b/packages/backend/src/daemons/queue-stats.ts @@ -0,0 +1,60 @@ +import Xev from 'xev'; +import { deliverQueue, inboxQueue } from '../queue/queues.js'; + +const ev = new Xev(); + +const interval = 10000; + +/** + * Report queue stats regularly + */ +export default function() { + const log = [] as any[]; + + ev.on('requestQueueStatsLog', x => { + ev.emit(`queueStatsLog:${x.id}`, log.slice(0, x.length || 50)); + }); + + let activeDeliverJobs = 0; + let activeInboxJobs = 0; + + deliverQueue.on('global:active', () => { + activeDeliverJobs++; + }); + + inboxQueue.on('global:active', () => { + activeInboxJobs++; + }); + + async function tick() { + const deliverJobCounts = await deliverQueue.getJobCounts(); + const inboxJobCounts = await inboxQueue.getJobCounts(); + + const stats = { + deliver: { + activeSincePrevTick: activeDeliverJobs, + active: deliverJobCounts.active, + waiting: deliverJobCounts.waiting, + delayed: deliverJobCounts.delayed, + }, + inbox: { + activeSincePrevTick: activeInboxJobs, + active: inboxJobCounts.active, + waiting: inboxJobCounts.waiting, + delayed: inboxJobCounts.delayed, + }, + }; + + ev.emit('queueStats', stats); + + log.unshift(stats); + if (log.length > 200) log.pop(); + + activeDeliverJobs = 0; + activeInboxJobs = 0; + } + + tick(); + + setInterval(tick, interval); +} diff --git a/packages/backend/src/daemons/server-stats.ts b/packages/backend/src/daemons/server-stats.ts new file mode 100644 index 0000000000..faf4e6e4a4 --- /dev/null +++ b/packages/backend/src/daemons/server-stats.ts @@ -0,0 +1,79 @@ +import si from 'systeminformation'; +import Xev from 'xev'; +import * as osUtils from 'os-utils'; + +const ev = new Xev(); + +const interval = 2000; + +const roundCpu = (num: number) => Math.round(num * 1000) / 1000; +const round = (num: number) => Math.round(num * 10) / 10; + +/** + * Report server stats regularly + */ +export default function() { + const log = [] as any[]; + + ev.on('requestServerStatsLog', x => { + ev.emit(`serverStatsLog:${x.id}`, log.slice(0, x.length || 50)); + }); + + async function tick() { + const cpu = await cpuUsage(); + const memStats = await mem(); + const netStats = await net(); + const fsStats = await fs(); + + const stats = { + cpu: roundCpu(cpu), + mem: { + used: round(memStats.used - memStats.buffers - memStats.cached), + active: round(memStats.active), + }, + net: { + rx: round(Math.max(0, netStats.rx_sec)), + tx: round(Math.max(0, netStats.tx_sec)), + }, + fs: { + r: round(Math.max(0, fsStats.rIO_sec ?? 0)), + w: round(Math.max(0, fsStats.wIO_sec ?? 0)), + }, + }; + ev.emit('serverStats', stats); + log.unshift(stats); + if (log.length > 200) log.pop(); + } + + tick(); + + setInterval(tick, interval); +} + +// CPU STAT +function cpuUsage(): Promise { + return new Promise((res, rej) => { + osUtils.cpuUsage((cpuUsage) => { + res(cpuUsage); + }); + }); +} + +// MEMORY STAT +async function mem() { + const data = await si.mem(); + return data; +} + +// NETWORK STAT +async function net() { + const iface = await si.networkInterfaceDefault(); + const data = await si.networkStats(iface); + return data[0]; +} + +// FS STAT +async function fs() { + const data = await si.disksIO().catch(() => ({ rIO_sec: 0, wIO_sec: 0 })); + return data || { rIO_sec: 0, wIO_sec: 0 }; +} diff --git a/packages/backend/src/db/elasticsearch.ts b/packages/backend/src/db/elasticsearch.ts new file mode 100644 index 0000000000..d98c5d180b --- /dev/null +++ b/packages/backend/src/db/elasticsearch.ts @@ -0,0 +1,56 @@ +import * as elasticsearch from '@elastic/elasticsearch'; +import config from '@/config/index.js'; + +const index = { + settings: { + analysis: { + analyzer: { + ngram: { + tokenizer: 'ngram', + }, + }, + }, + }, + mappings: { + properties: { + text: { + type: 'text', + index: true, + analyzer: 'ngram', + }, + userId: { + type: 'keyword', + index: true, + }, + userHost: { + type: 'keyword', + index: true, + }, + }, + }, +}; + +// Init ElasticSearch connection +const client = config.elasticsearch ? new elasticsearch.Client({ + node: `${config.elasticsearch.ssl ? 'https://' : 'http://'}${config.elasticsearch.host}:${config.elasticsearch.port}`, + auth: (config.elasticsearch.user && config.elasticsearch.pass) ? { + username: config.elasticsearch.user, + password: config.elasticsearch.pass, + } : undefined, + pingTimeout: 30000, +}) : null; + +if (client) { + client.indices.exists({ + index: config.elasticsearch.index || 'misskey_note', + }).then(exist => { + if (!exist.body) { + client.indices.create({ + index: config.elasticsearch.index || 'misskey_note', + body: index, + }); + } + }); +} + +export default client; diff --git a/packages/backend/src/db/logger.ts b/packages/backend/src/db/logger.ts new file mode 100644 index 0000000000..22f4c6b1b0 --- /dev/null +++ b/packages/backend/src/db/logger.ts @@ -0,0 +1,3 @@ +import Logger from '@/services/logger.js'; + +export const dbLogger = new Logger('db'); diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts new file mode 100644 index 0000000000..94d55e4310 --- /dev/null +++ b/packages/backend/src/db/postgre.ts @@ -0,0 +1,256 @@ +// https://github.com/typeorm/typeorm/issues/2400 +import pg from 'pg'; +pg.types.setTypeParser(20, Number); + +import { Logger, DataSource } from 'typeorm'; +import * as highlight from 'cli-highlight'; +import config from '@/config/index.js'; + +import { User } from '@/models/entities/user.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { DriveFolder } from '@/models/entities/drive-folder.js'; +import { AccessToken } from '@/models/entities/access-token.js'; +import { App } from '@/models/entities/app.js'; +import { PollVote } from '@/models/entities/poll-vote.js'; +import { Note } from '@/models/entities/note.js'; +import { NoteReaction } from '@/models/entities/note-reaction.js'; +import { NoteWatching } from '@/models/entities/note-watching.js'; +import { NoteThreadMuting } from '@/models/entities/note-thread-muting.js'; +import { NoteUnread } from '@/models/entities/note-unread.js'; +import { Notification } from '@/models/entities/notification.js'; +import { Meta } from '@/models/entities/meta.js'; +import { Following } from '@/models/entities/following.js'; +import { Instance } from '@/models/entities/instance.js'; +import { Muting } from '@/models/entities/muting.js'; +import { SwSubscription } from '@/models/entities/sw-subscription.js'; +import { Blocking } from '@/models/entities/blocking.js'; +import { UserList } from '@/models/entities/user-list.js'; +import { UserListJoining } from '@/models/entities/user-list-joining.js'; +import { UserGroup } from '@/models/entities/user-group.js'; +import { UserGroupJoining } from '@/models/entities/user-group-joining.js'; +import { UserGroupInvitation } from '@/models/entities/user-group-invitation.js'; +import { Hashtag } from '@/models/entities/hashtag.js'; +import { NoteFavorite } from '@/models/entities/note-favorite.js'; +import { AbuseUserReport } from '@/models/entities/abuse-user-report.js'; +import { RegistrationTicket } from '@/models/entities/registration-tickets.js'; +import { MessagingMessage } from '@/models/entities/messaging-message.js'; +import { Signin } from '@/models/entities/signin.js'; +import { AuthSession } from '@/models/entities/auth-session.js'; +import { FollowRequest } from '@/models/entities/follow-request.js'; +import { Emoji } from '@/models/entities/emoji.js'; +import { UserNotePining } from '@/models/entities/user-note-pining.js'; +import { Poll } from '@/models/entities/poll.js'; +import { UserKeypair } from '@/models/entities/user-keypair.js'; +import { UserPublickey } from '@/models/entities/user-publickey.js'; +import { UserProfile } from '@/models/entities/user-profile.js'; +import { UserSecurityKey } from '@/models/entities/user-security-key.js'; +import { AttestationChallenge } from '@/models/entities/attestation-challenge.js'; +import { Page } from '@/models/entities/page.js'; +import { PageLike } from '@/models/entities/page-like.js'; +import { GalleryPost } from '@/models/entities/gallery-post.js'; +import { GalleryLike } from '@/models/entities/gallery-like.js'; +import { ModerationLog } from '@/models/entities/moderation-log.js'; +import { UsedUsername } from '@/models/entities/used-username.js'; +import { Announcement } from '@/models/entities/announcement.js'; +import { AnnouncementRead } from '@/models/entities/announcement-read.js'; +import { Clip } from '@/models/entities/clip.js'; +import { ClipNote } from '@/models/entities/clip-note.js'; +import { Antenna } from '@/models/entities/antenna.js'; +import { AntennaNote } from '@/models/entities/antenna-note.js'; +import { PromoNote } from '@/models/entities/promo-note.js'; +import { PromoRead } from '@/models/entities/promo-read.js'; +import { Relay } from '@/models/entities/relay.js'; +import { MutedNote } from '@/models/entities/muted-note.js'; +import { Channel } from '@/models/entities/channel.js'; +import { ChannelFollowing } from '@/models/entities/channel-following.js'; +import { ChannelNotePining } from '@/models/entities/channel-note-pining.js'; +import { RegistryItem } from '@/models/entities/registry-item.js'; +import { Ad } from '@/models/entities/ad.js'; +import { PasswordResetRequest } from '@/models/entities/password-reset-request.js'; +import { UserPending } from '@/models/entities/user-pending.js'; +import { Webhook } from '@/models/entities/webhook.js'; +import { UserIp } from '@/models/entities/user-ip.js'; + +import { entities as charts } from '@/services/chart/entities.js'; +import { envOption } from '../env.js'; +import { dbLogger } from './logger.js'; +import { redisClient } from './redis.js'; + +const sqlLogger = dbLogger.createSubLogger('sql', 'gray', false); + +class MyCustomLogger implements Logger { + private highlight(sql: string) { + return highlight.highlight(sql, { + language: 'sql', ignoreIllegals: true, + }); + } + + public logQuery(query: string, parameters?: any[]) { + sqlLogger.info(this.highlight(query).substring(0, 100)); + } + + public logQueryError(error: string, query: string, parameters?: any[]) { + sqlLogger.error(this.highlight(query)); + } + + public logQuerySlow(time: number, query: string, parameters?: any[]) { + sqlLogger.warn(this.highlight(query)); + } + + public logSchemaBuild(message: string) { + sqlLogger.info(message); + } + + public log(message: string) { + sqlLogger.info(message); + } + + public logMigration(message: string) { + sqlLogger.info(message); + } +} + +export const entities = [ + Announcement, + AnnouncementRead, + Meta, + Instance, + App, + AuthSession, + AccessToken, + User, + UserProfile, + UserKeypair, + UserPublickey, + UserList, + UserListJoining, + UserGroup, + UserGroupJoining, + UserGroupInvitation, + UserNotePining, + UserSecurityKey, + UsedUsername, + AttestationChallenge, + Following, + FollowRequest, + Muting, + Blocking, + Note, + NoteFavorite, + NoteReaction, + NoteWatching, + NoteThreadMuting, + NoteUnread, + Page, + PageLike, + GalleryPost, + GalleryLike, + DriveFile, + DriveFolder, + Poll, + PollVote, + Notification, + Emoji, + Hashtag, + SwSubscription, + AbuseUserReport, + RegistrationTicket, + MessagingMessage, + Signin, + ModerationLog, + Clip, + ClipNote, + Antenna, + AntennaNote, + PromoNote, + PromoRead, + Relay, + MutedNote, + Channel, + ChannelFollowing, + ChannelNotePining, + RegistryItem, + Ad, + PasswordResetRequest, + UserPending, + Webhook, + UserIp, + ...charts, +]; + +const log = process.env.NODE_ENV !== 'production'; + +export const db = new DataSource({ + type: 'postgres', + host: config.db.host, + port: config.db.port, + username: config.db.user, + password: config.db.pass, + database: config.db.db, + extra: { + statement_timeout: 1000 * 10, + ...config.db.extra, + }, + synchronize: process.env.NODE_ENV === 'test', + dropSchema: process.env.NODE_ENV === 'test', + cache: !config.db.disableCache ? { + type: 'ioredis', + options: { + host: config.redis.host, + port: config.redis.port, + family: config.redis.family == null ? 0 : config.redis.family, + password: config.redis.pass, + keyPrefix: `${config.redis.prefix}:query:`, + db: config.redis.db || 0, + }, + } : false, + logging: log, + logger: log ? new MyCustomLogger() : undefined, + maxQueryExecutionTime: 300, + entities: entities, + migrations: ['../../migration/*.js'], +}); + +export async function initDb(force = false) { + if (force) { + if (db.isInitialized) { + await db.destroy(); + } + await db.initialize(); + return; + } + + if (db.isInitialized) { + // nop + } else { + await db.initialize(); + } +} + +export async function resetDb() { + const reset = async () => { + await redisClient.flushdb(); + const tables = await db.query(`SELECT relname AS "table" + FROM pg_class C LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) + WHERE nspname NOT IN ('pg_catalog', 'information_schema') + AND C.relkind = 'r' + AND nspname !~ '^pg_toast';`); + for (const table of tables) { + await db.query(`DELETE FROM "${table.table}" CASCADE`); + } + }; + + for (let i = 1; i <= 3; i++) { + try { + await reset(); + } catch (e) { + if (i === 3) { + throw e; + } else { + await new Promise(resolve => setTimeout(resolve, 1000)); + continue; + } + } + break; + } +} diff --git a/packages/backend/src/db/redis.ts b/packages/backend/src/db/redis.ts new file mode 100644 index 0000000000..49f5bb2ba8 --- /dev/null +++ b/packages/backend/src/db/redis.ts @@ -0,0 +1,18 @@ +import Redis from 'ioredis'; +import config from '@/config/index.js'; + +export function createConnection() { + return new Redis({ + port: config.redis.port, + host: config.redis.host, + family: config.redis.family == null ? 0 : config.redis.family, + password: config.redis.pass, + keyPrefix: `${config.redis.prefix}:`, + db: config.redis.db || 0, + }); +} + +export const subsdcriber = createConnection(); +subsdcriber.subscribe(config.host); + +export const redisClient = createConnection(); diff --git a/packages/backend/src/decorators.ts b/packages/backend/src/decorators.ts deleted file mode 100644 index 6b439978db..0000000000 --- a/packages/backend/src/decorators.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// https://github.com/andreypopp/autobind-decorator - -/** - * Return a descriptor removing the value and returning a getter - * The getter will return a .bind version of the function - * and memoize the result against a symbol on the instance - */ -export function bindThis(target: any, key: string, descriptor: any) { - let fn = descriptor.value; - - if (typeof fn !== 'function') { - throw new TypeError(`@bindThis decorator can only be applied to methods not: ${typeof fn}`); - } - - return { - configurable: true, - get() { - // eslint-disable-next-line no-prototype-builtins - if (this === target.prototype || this.hasOwnProperty(key) || - typeof fn !== 'function') { - return fn; - } - - const boundFn = fn.bind(this); - Object.defineProperty(this, key, { - configurable: true, - get() { - return boundFn; - }, - set(value) { - fn = value; - delete this[key]; - }, - }); - return boundFn; - }, - set(value: any) { - fn = value; - }, - }; -} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts deleted file mode 100644 index 0c5ac8f2d3..0000000000 --- a/packages/backend/src/di-symbols.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const DI = { - config: Symbol('config'), - db: Symbol('db'), - meilisearch: Symbol('meilisearch'), - redis: Symbol('redis'), - redisForPub: Symbol('redisForPub'), - redisForSub: Symbol('redisForSub'), - redisForTimelines: Symbol('redisForTimelines'), - - //#region Repositories - usersRepository: Symbol('usersRepository'), - notesRepository: Symbol('notesRepository'), - announcementsRepository: Symbol('announcementsRepository'), - announcementReadsRepository: Symbol('announcementReadsRepository'), - appsRepository: Symbol('appsRepository'), - avatarDecorationsRepository: Symbol('avatarDecorationsRepository'), - noteFavoritesRepository: Symbol('noteFavoritesRepository'), - noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'), - noteReactionsRepository: Symbol('noteReactionsRepository'), - noteUnreadsRepository: Symbol('noteUnreadsRepository'), - pollsRepository: Symbol('pollsRepository'), - pollVotesRepository: Symbol('pollVotesRepository'), - userProfilesRepository: Symbol('userProfilesRepository'), - userKeypairsRepository: Symbol('userKeypairsRepository'), - userPendingsRepository: Symbol('userPendingsRepository'), - userSecurityKeysRepository: Symbol('userSecurityKeysRepository'), - userPublickeysRepository: Symbol('userPublickeysRepository'), - userListsRepository: Symbol('userListsRepository'), - userListFavoritesRepository: Symbol('userListFavoritesRepository'), - userListMembershipsRepository: Symbol('userListMembershipsRepository'), - userNotePiningsRepository: Symbol('userNotePiningsRepository'), - userIpsRepository: Symbol('userIpsRepository'), - usedUsernamesRepository: Symbol('usedUsernamesRepository'), - followingsRepository: Symbol('followingsRepository'), - followRequestsRepository: Symbol('followRequestsRepository'), - instancesRepository: Symbol('instancesRepository'), - emojisRepository: Symbol('emojisRepository'), - driveFilesRepository: Symbol('driveFilesRepository'), - driveFoldersRepository: Symbol('driveFoldersRepository'), - metasRepository: Symbol('metasRepository'), - mutingsRepository: Symbol('mutingsRepository'), - renoteMutingsRepository: Symbol('renoteMutingsRepository'), - blockingsRepository: Symbol('blockingsRepository'), - swSubscriptionsRepository: Symbol('swSubscriptionsRepository'), - hashtagsRepository: Symbol('hashtagsRepository'), - abuseUserReportsRepository: Symbol('abuseUserReportsRepository'), - registrationTicketsRepository: Symbol('registrationTicketsRepository'), - authSessionsRepository: Symbol('authSessionsRepository'), - accessTokensRepository: Symbol('accessTokensRepository'), - signinsRepository: Symbol('signinsRepository'), - pagesRepository: Symbol('pagesRepository'), - pageLikesRepository: Symbol('pageLikesRepository'), - galleryPostsRepository: Symbol('galleryPostsRepository'), - galleryLikesRepository: Symbol('galleryLikesRepository'), - moderationLogsRepository: Symbol('moderationLogsRepository'), - clipsRepository: Symbol('clipsRepository'), - clipNotesRepository: Symbol('clipNotesRepository'), - clipFavoritesRepository: Symbol('clipFavoritesRepository'), - antennasRepository: Symbol('antennasRepository'), - promoNotesRepository: Symbol('promoNotesRepository'), - promoReadsRepository: Symbol('promoReadsRepository'), - relaysRepository: Symbol('relaysRepository'), - channelsRepository: Symbol('channelsRepository'), - channelFollowingsRepository: Symbol('channelFollowingsRepository'), - channelFavoritesRepository: Symbol('channelFavoritesRepository'), - registryItemsRepository: Symbol('registryItemsRepository'), - webhooksRepository: Symbol('webhooksRepository'), - adsRepository: Symbol('adsRepository'), - passwordResetRequestsRepository: Symbol('passwordResetRequestsRepository'), - retentionAggregationsRepository: Symbol('retentionAggregationsRepository'), - rolesRepository: Symbol('rolesRepository'), - roleAssignmentsRepository: Symbol('roleAssignmentsRepository'), - flashsRepository: Symbol('flashsRepository'), - flashLikesRepository: Symbol('flashLikesRepository'), - userMemosRepository: Symbol('userMemosRepository'), - noteEditRepository: Symbol('noteEditRepository'), - //#endregion -}; diff --git a/packages/backend/src/env.ts b/packages/backend/src/env.ts index af1c3bdd3c..1b678edc44 100644 --- a/packages/backend/src/env.ts +++ b/packages/backend/src/env.ts @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - const envOption = { onlyQueue: false, onlyServer: false, @@ -11,6 +6,7 @@ const envOption = { verbose: false, withLogTime: false, quiet: false, + slow: false, }; for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) { diff --git a/packages/backend/src/global.d.ts b/packages/backend/src/global.d.ts index a9e6243cc4..7343aa1994 100644 --- a/packages/backend/src/global.d.ts +++ b/packages/backend/src/global.d.ts @@ -1,6 +1 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - type FIXME = any; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts new file mode 100644 index 0000000000..bd9c0098bc --- /dev/null +++ b/packages/backend/src/index.ts @@ -0,0 +1,13 @@ +/** + * Misskey Entry Point! + */ + +import { EventEmitter } from 'node:events'; +import boot from './boot/index.js'; + +Error.stackTraceLimit = Infinity; +EventEmitter.defaultMaxListeners = 128; + +boot().catch(err => { + console.error(err); +}); diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts deleted file mode 100644 index 5c10559ec6..0000000000 --- a/packages/backend/src/logger.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import cluster from 'node:cluster'; -import chalk from 'chalk'; -import { default as convertColor } from 'color-convert'; -import { format as dateFormat } from 'date-fns'; -import { bindThis } from '@/decorators.js'; -import { envOption } from './env.js'; -import type { KEYWORD } from 'color-convert/conversions.js'; - -type Context = { - name: string; - color?: KEYWORD; -}; - -type Level = 'error' | 'success' | 'warning' | 'debug' | 'info'; - -// eslint-disable-next-line import/no-default-export -export default class Logger { - private context: Context; - private parentLogger: Logger | null = null; - private store: boolean; - - constructor(context: string, color?: KEYWORD, store = true) { - this.context = { - name: context, - color: color, - }; - this.store = store; - } - - @bindThis - public createSubLogger(context: string, color?: KEYWORD, store = true): Logger { - const logger = new Logger(context, color, store); - logger.parentLogger = this; - return logger; - } - - @bindThis - private log(level: Level, message: string, data?: Record | null, important = false, subContexts: Context[] = [], store = true): void { - if (envOption.quiet) return; - if (!this.store) store = false; - if (level === 'debug') store = false; - - if (this.parentLogger) { - this.parentLogger.log(level, message, data, important, [this.context].concat(subContexts), store); - return; - } - - const time = dateFormat(new Date(), 'HH:mm:ss'); - const worker = cluster.isPrimary ? '*' : cluster.worker!.id; - const l = - level === 'error' ? important ? chalk.bgRed.white('ERR ') : chalk.red('ERR ') : - level === 'warning' ? chalk.yellow('WARN') : - level === 'success' ? important ? chalk.bgGreen.white('DONE') : chalk.green('DONE') : - level === 'debug' ? chalk.gray('VERB') : - level === 'info' ? chalk.blue('INFO') : - null; - const contexts = [this.context].concat(subContexts).map(d => d.color ? chalk.rgb(...convertColor.keyword.rgb(d.color))(d.name) : chalk.white(d.name)); - const m = - level === 'error' ? chalk.red(message) : - level === 'warning' ? chalk.yellow(message) : - level === 'success' ? chalk.green(message) : - level === 'debug' ? chalk.gray(message) : - level === 'info' ? message : - null; - - let log = `${l} ${worker}\t[${contexts.join(' ')}]\t${m}`; - if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log; - - console.log(important ? chalk.bold(log) : log); - if (level === 'error' && data) console.log(data); - } - - @bindThis - public error(x: string | Error, data?: Record | null, important = false): void { // 実行を継続できない状況で使う - if (x instanceof Error) { - data = data ?? {}; - data.e = x; - this.log('error', x.toString(), data, important); - } else if (typeof x === 'object') { - this.log('error', `${(x as any).message ?? (x as any).name ?? x}`, data, important); - } else { - this.log('error', `${x}`, data, important); - } - } - - @bindThis - public warn(message: string, data?: Record | null, important = false): void { // 実行を継続できるが改善すべき状況で使う - this.log('warning', message, data, important); - } - - @bindThis - public succ(message: string, data?: Record | null, important = false): void { // 何かに成功した状況で使う - this.log('success', message, data, important); - } - - @bindThis - public debug(message: string, data?: Record | null, important = false): void { // デバッグ用に使う(開発者に必要だが利用者に不要な情報) - if (process.env.NODE_ENV !== 'production' || envOption.verbose) { - this.log('debug', message, data, important); - } - } - - @bindThis - public info(message: string, data?: Record | null, important = false): void { // それ以外 - this.log('info', message, data, important); - } -} diff --git a/packages/backend/src/mfm/from-html.ts b/packages/backend/src/mfm/from-html.ts new file mode 100644 index 0000000000..7751bac563 --- /dev/null +++ b/packages/backend/src/mfm/from-html.ts @@ -0,0 +1,213 @@ +import { URL } from 'node:url'; +import * as parse5 from 'parse5'; +import * as TreeAdapter from '../../node_modules/parse5/dist/tree-adapters/default.js'; + +const treeAdapter = TreeAdapter.defaultTreeAdapter; + +const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; +const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; + +export function fromHtml(html: string, hashtagNames?: string[]): string { + // some AP servers like Pixelfed use br tags as well as newlines + html = html.replace(/\r?\n/gi, '\n'); + + const dom = parse5.parseFragment(html); + + let text = ''; + + for (const n of dom.childNodes) { + analyze(n); + } + + return text.trim(); + + function getText(node: TreeAdapter.Node): string { + if (treeAdapter.isTextNode(node)) return node.value; + if (!treeAdapter.isElementNode(node)) return ''; + if (node.nodeName === 'br') return '\n'; + + if (node.childNodes) { + return node.childNodes.map(n => getText(n)).join(''); + } + + return ''; + } + + function appendChildren(childNodes: TreeAdapter.ChildNode[]): void { + if (childNodes) { + for (const n of childNodes) { + analyze(n); + } + } + } + + function analyze(node: TreeAdapter.Node) { + if (treeAdapter.isTextNode(node)) { + text += node.value; + return; + } + + // Skip comment or document type node + if (!treeAdapter.isElementNode(node)) return; + + switch (node.nodeName) { + case 'br': { + text += '\n'; + break; + } + + case 'a': + { + const txt = getText(node); + const rel = node.attrs.find(x => x.name === 'rel'); + const href = node.attrs.find(x => x.name === 'href'); + + // ハッシュタグ + if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) { + text += txt; + // メンション + } else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) { + const part = txt.split('@'); + + if (part.length === 2 && href) { + //#region ホスト名部分が省略されているので復元する + const acct = `${txt}@${(new URL(href.value)).hostname}`; + text += acct; + //#endregion + } else if (part.length === 3) { + text += txt; + } + // その他 + } else { + const generateLink = () => { + if (!href && !txt) { + return ''; + } + if (!href) { + return txt; + } + if (!txt || txt === href.value) { // #6383: Missing text node + if (href.value.match(urlRegexFull)) { + return href.value; + } else { + return `<${href.value}>`; + } + } + if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) { + return `[${txt}](<${href.value}>)`; // #6846 + } else { + return `[${txt}](${href.value})`; + } + }; + + text += generateLink(); + } + break; + } + + case 'h1': + { + text += '【'; + appendChildren(node.childNodes); + text += '】\n'; + break; + } + + case 'b': + case 'strong': + { + text += '**'; + appendChildren(node.childNodes); + text += '**'; + break; + } + + case 'small': + { + text += ''; + appendChildren(node.childNodes); + text += ''; + break; + } + + case 's': + case 'del': + { + text += '~~'; + appendChildren(node.childNodes); + text += '~~'; + break; + } + + case 'i': + case 'em': + { + text += ''; + appendChildren(node.childNodes); + text += ''; + break; + } + + // block code (
)
+			case 'pre': {
+				if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
+					text += '\n```\n';
+					text += getText(node.childNodes[0]);
+					text += '\n```\n';
+				} else {
+					appendChildren(node.childNodes);
+				}
+				break;
+			}
+
+			// inline code ()
+			case 'code': {
+				text += '`';
+				appendChildren(node.childNodes);
+				text += '`';
+				break;
+			}
+
+			case 'blockquote': {
+				const t = getText(node);
+				if (t) {
+					text += '\n> ';
+					text += t.split('\n').join('\n> ');
+				}
+				break;
+			}
+
+			case 'p':
+			case 'h2':
+			case 'h3':
+			case 'h4':
+			case 'h5':
+			case 'h6':
+			{
+				text += '\n\n';
+				appendChildren(node.childNodes);
+				break;
+			}
+
+			// other block elements
+			case 'div':
+			case 'header':
+			case 'footer':
+			case 'article':
+			case 'li':
+			case 'dt':
+			case 'dd':
+			{
+				text += '\n';
+				appendChildren(node.childNodes);
+				break;
+			}
+
+			default:	// includes inline elements
+			{
+				appendChildren(node.childNodes);
+				break;
+			}
+		}
+	}
+}
diff --git a/packages/backend/src/mfm/to-html.ts b/packages/backend/src/mfm/to-html.ts
new file mode 100644
index 0000000000..bcb5c86a3d
--- /dev/null
+++ b/packages/backend/src/mfm/to-html.ts
@@ -0,0 +1,159 @@
+import { JSDOM } from 'jsdom';
+import * as mfm from 'mfm-js';
+import config from '@/config/index.js';
+import { intersperse } from '@/prelude/array.js';
+import { IMentionedRemoteUsers } from '@/models/entities/note.js';
+
+export function toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) {
+	if (nodes == null) {
+		return null;
+	}
+
+	const { window } = new JSDOM('');
+
+	const doc = window.document;
+
+	function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
+		if (children) {
+			for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child);
+		}
+	}
+
+	const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => any } = {
+		bold(node) {
+			const el = doc.createElement('b');
+			appendChildren(node.children, el);
+			return el;
+		},
+
+		small(node) {
+			const el = doc.createElement('small');
+			appendChildren(node.children, el);
+			return el;
+		},
+
+		strike(node) {
+			const el = doc.createElement('del');
+			appendChildren(node.children, el);
+			return el;
+		},
+
+		italic(node) {
+			const el = doc.createElement('i');
+			appendChildren(node.children, el);
+			return el;
+		},
+
+		fn(node) {
+			const el = doc.createElement('i');
+			appendChildren(node.children, el);
+			return el;
+		},
+
+		blockCode(node) {
+			const pre = doc.createElement('pre');
+			const inner = doc.createElement('code');
+			inner.textContent = node.props.code;
+			pre.appendChild(inner);
+			return pre;
+		},
+
+		center(node) {
+			const el = doc.createElement('div');
+			appendChildren(node.children, el);
+			return el;
+		},
+
+		emojiCode(node) {
+			return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
+		},
+
+		unicodeEmoji(node) {
+			return doc.createTextNode(node.props.emoji);
+		},
+
+		hashtag(node) {
+			const a = doc.createElement('a');
+			a.href = `${config.url}/tags/${node.props.hashtag}`;
+			a.textContent = `#${node.props.hashtag}`;
+			a.setAttribute('rel', 'tag');
+			return a;
+		},
+
+		inlineCode(node) {
+			const el = doc.createElement('code');
+			el.textContent = node.props.code;
+			return el;
+		},
+
+		mathInline(node) {
+			const el = doc.createElement('code');
+			el.textContent = node.props.formula;
+			return el;
+		},
+
+		mathBlock(node) {
+			const el = doc.createElement('code');
+			el.textContent = node.props.formula;
+			return el;
+		},
+
+		link(node) {
+			const a = doc.createElement('a');
+			a.href = node.props.url;
+			appendChildren(node.children, a);
+			return a;
+		},
+
+		mention(node) {
+			const a = doc.createElement('a');
+			const { username, host, acct } = node.props;
+			const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
+			a.href = remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${config.url}/${acct}`;
+			a.className = 'u-url mention';
+			a.textContent = acct;
+			return a;
+		},
+
+		quote(node) {
+			const el = doc.createElement('blockquote');
+			appendChildren(node.children, el);
+			return el;
+		},
+
+		text(node) {
+			const el = doc.createElement('span');
+			const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
+
+			for (const x of intersperse('br', nodes)) {
+				el.appendChild(x === 'br' ? doc.createElement('br') : x);
+			}
+
+			return el;
+		},
+
+		url(node) {
+			const a = doc.createElement('a');
+			a.href = node.props.url;
+			a.textContent = node.props.url;
+			return a;
+		},
+
+		search(node) {
+			const a = doc.createElement('a');
+			a.href = `https://www.google.com/search?q=${node.props.query}`;
+			a.textContent = node.props.content;
+			return a;
+		},
+
+		plain(node) {
+			const el = doc.createElement('span');
+			appendChildren(node.children, el);
+			return el;
+		},
+	};
+
+	appendChildren(nodes, doc.body);
+
+	return `

${doc.body.innerHTML}

`; +} diff --git a/packages/backend/src/misc/acct.ts b/packages/backend/src/misc/acct.ts index 5db72746c0..c32cee86c9 100644 --- a/packages/backend/src/misc/acct.ts +++ b/packages/backend/src/misc/acct.ts @@ -1,17 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - export type Acct = { username: string; host: string | null; }; export function parse(acct: string): Acct { - if (acct.startsWith('@')) acct = acct.substring(1); + if (acct.startsWith('@')) acct = acct.substr(1); const split = acct.split('@', 2); - return { username: split[0], host: split[1] ?? null }; + return { username: split[0], host: split[1] || null }; } export function toString(acct: Acct): string { diff --git a/packages/backend/src/misc/antenna-cache.ts b/packages/backend/src/misc/antenna-cache.ts new file mode 100644 index 0000000000..dcf96c1610 --- /dev/null +++ b/packages/backend/src/misc/antenna-cache.ts @@ -0,0 +1,36 @@ +import { Antennas } from '@/models/index.js'; +import { Antenna } from '@/models/entities/antenna.js'; +import { subsdcriber } from '../db/redis.js'; + +let antennasFetched = false; +let antennas: Antenna[] = []; + +export async function getAntennas() { + if (!antennasFetched) { + antennas = await Antennas.find(); + antennasFetched = true; + } + + return antennas; +} + +subsdcriber.on('message', async (_, data) => { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message; + switch (type) { + case 'antennaCreated': + antennas.push(body); + break; + case 'antennaUpdated': + antennas[antennas.findIndex(a => a.id === body.id)] = body; + break; + case 'antennaDeleted': + antennas = antennas.filter(a => a.id !== body.id); + break; + default: + break; + } + } +}); diff --git a/packages/backend/src/misc/api-permissions.ts b/packages/backend/src/misc/api-permissions.ts new file mode 100644 index 0000000000..160cdf9fd6 --- /dev/null +++ b/packages/backend/src/misc/api-permissions.ts @@ -0,0 +1,35 @@ +export const kinds = [ + 'read:account', + 'write:account', + 'read:blocks', + 'write:blocks', + 'read:drive', + 'write:drive', + 'read:favorites', + 'write:favorites', + 'read:following', + 'write:following', + 'read:messaging', + 'write:messaging', + 'read:mutes', + 'write:mutes', + 'write:notes', + 'read:notifications', + 'write:notifications', + 'read:reactions', + 'write:reactions', + 'write:votes', + 'read:pages', + 'write:pages', + 'write:page-likes', + 'read:page-likes', + 'read:user-groups', + 'write:user-groups', + 'read:channels', + 'write:channels', + 'read:gallery', + 'write:gallery', + 'read:gallery-likes', + 'write:gallery-likes', +]; +// IF YOU ADD KINDS(PERMISSIONS), YOU MUST ADD TRANSLATIONS (under _permissions). diff --git a/packages/backend/src/misc/app-lock.ts b/packages/backend/src/misc/app-lock.ts new file mode 100644 index 0000000000..b5089cc6a6 --- /dev/null +++ b/packages/backend/src/misc/app-lock.ts @@ -0,0 +1,31 @@ +import { redisClient } from '../db/redis.js'; +import { promisify } from 'node:util'; +import redisLock from 'redis-lock'; + +/** + * Retry delay (ms) for lock acquisition + */ +const retryDelay = 100; + +const lock: (key: string, timeout?: number) => Promise<() => void> + = redisClient + ? promisify(redisLock(redisClient, retryDelay)) + : async () => () => { }; + +/** + * Get AP Object lock + * @param uri AP object ID + * @param timeout Lock timeout (ms), The timeout releases previous lock. + * @returns Unlock function + */ +export function getApLock(uri: string, timeout = 30 * 1000) { + return lock(`ap-object:${uri}`, timeout); +} + +export function getFetchInstanceMetadataLock(host: string, timeout = 30 * 1000) { + return lock(`instance:${host}`, timeout); +} + +export function getChartInsertLock(lockKey: string, timeout = 30 * 1000) { + return lock(`chart-insert:${lockKey}`, timeout); +} diff --git a/packages/backend/src/misc/before-shutdown.ts b/packages/backend/src/misc/before-shutdown.ts new file mode 100644 index 0000000000..93ac7a1f39 --- /dev/null +++ b/packages/backend/src/misc/before-shutdown.ts @@ -0,0 +1,94 @@ +// https://gist.github.com/nfantone/1eaa803772025df69d07f4dbf5df7e58 + +'use strict'; + +/** + * @callback BeforeShutdownListener + * @param {string} [signalOrEvent] The exit signal or event name received on the process. + */ + +/** + * System signals the app will listen to initiate shutdown. + * @const {string[]} + */ +const SHUTDOWN_SIGNALS = ['SIGINT', 'SIGTERM']; + +/** + * Time in milliseconds to wait before forcing shutdown. + * @const {number} + */ +const SHUTDOWN_TIMEOUT = 15000; + +/** + * A queue of listener callbacks to execute before shutting + * down the process. + * @type {BeforeShutdownListener[]} + */ +const shutdownListeners: ((signalOrEvent: string) => void)[] = []; + +/** + * Listen for signals and execute given `fn` function once. + * @param {string[]} signals System signals to listen to. + * @param {function(string)} fn Function to execute on shutdown. + */ +const processOnce = (signals: string[], fn: (signalOrEvent: string) => void) => { + for (const sig of signals) { + process.once(sig, fn); + } +}; + +/** + * Sets a forced shutdown mechanism that will exit the process after `timeout` milliseconds. + * @param {number} timeout Time to wait before forcing shutdown (milliseconds) + */ +const forceExitAfter = (timeout: number) => () => { + setTimeout(() => { + // Force shutdown after timeout + console.warn(`Could not close resources gracefully after ${timeout}ms: forcing shutdown`); + return process.exit(1); + }, timeout).unref(); +}; + +/** + * Main process shutdown handler. Will invoke every previously registered async shutdown listener + * in the queue and exit with a code of `0`. Any `Promise` rejections from any listener will + * be logged out as a warning, but won't prevent other callbacks from executing. + * @param {string} signalOrEvent The exit signal or event name received on the process. + */ +async function shutdownHandler(signalOrEvent: string) { + if (process.env.NODE_ENV === 'test') return process.exit(0); + + console.warn(`Shutting down: received [${signalOrEvent}] signal`); + + for (const listener of shutdownListeners) { + try { + await listener(signalOrEvent); + } catch (err) { + if (err instanceof Error) { + console.warn(`A shutdown handler failed before completing with: ${err.message || err}`); + } + } + } + + return process.exit(0); +} + +/** + * Registers a new shutdown listener to be invoked before exiting + * the main process. Listener handlers are guaranteed to be called in the order + * they were registered. + * @param {BeforeShutdownListener} listener The shutdown listener to register. + * @returns {BeforeShutdownListener} Echoes back the supplied `listener`. + */ +export function beforeShutdown(listener: () => void) { + shutdownListeners.push(listener); + return listener; +} + +// Register shutdown callback that kills the process after `SHUTDOWN_TIMEOUT` milliseconds +// This prevents custom shutdown handlers from hanging the process indefinitely +processOnce(SHUTDOWN_SIGNALS, forceExitAfter(SHUTDOWN_TIMEOUT)); + +// Register process shutdown callback +// Will listen to incoming signal events and execute all registered handlers in the stack +processOnce(SHUTDOWN_SIGNALS, shutdownHandler); diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index c235871931..e5b911ed32 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -1,250 +1,38 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as Redis from 'ioredis'; -import { bindThis } from '@/decorators.js'; - -export class RedisKVCache { - private redisClient: Redis.Redis; - private name: string; +export class Cache { + public cache: Map; private lifetime: number; - private memoryCache: MemoryKVCache; - private fetcher: (key: string) => Promise; - private toRedisConverter: (value: T) => string; - private fromRedisConverter: (value: string) => T | undefined; - constructor(redisClient: RedisKVCache['redisClient'], name: RedisKVCache['name'], opts: { - lifetime: RedisKVCache['lifetime']; - memoryCacheLifetime: number; - fetcher: RedisKVCache['fetcher']; - toRedisConverter: RedisKVCache['toRedisConverter']; - fromRedisConverter: RedisKVCache['fromRedisConverter']; - }) { - this.redisClient = redisClient; - this.name = name; - this.lifetime = opts.lifetime; - this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime); - this.fetcher = opts.fetcher; - this.toRedisConverter = opts.toRedisConverter; - this.fromRedisConverter = opts.fromRedisConverter; - } - - @bindThis - public async set(key: string, value: T): Promise { - this.memoryCache.set(key, value); - if (this.lifetime === Infinity) { - await this.redisClient.set( - `kvcache:${this.name}:${key}`, - this.toRedisConverter(value), - ); - } else { - await this.redisClient.set( - `kvcache:${this.name}:${key}`, - this.toRedisConverter(value), - 'EX', Math.round(this.lifetime / 1000), - ); - } - } - - @bindThis - public async get(key: string): Promise { - const memoryCached = this.memoryCache.get(key); - if (memoryCached !== undefined) return memoryCached; - - const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`); - if (cached == null) return undefined; - return this.fromRedisConverter(cached); - } - - @bindThis - public async delete(key: string): Promise { - this.memoryCache.delete(key); - await this.redisClient.del(`kvcache:${this.name}:${key}`); - } - - /** - * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します - */ - @bindThis - public async fetch(key: string): Promise { - const cachedValue = await this.get(key); - if (cachedValue !== undefined) { - // Cache HIT - return cachedValue; - } - - // Cache MISS - const value = await this.fetcher(key); - this.set(key, value); - return value; - } - - @bindThis - public async refresh(key: string) { - const value = await this.fetcher(key); - this.set(key, value); - - // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする - } - - @bindThis - public gc() { - this.memoryCache.gc(); - } - - @bindThis - public dispose() { - this.memoryCache.dispose(); - } -} - -export class RedisSingleCache { - private redisClient: Redis.Redis; - private name: string; - private lifetime: number; - private memoryCache: MemorySingleCache; - private fetcher: () => Promise; - private toRedisConverter: (value: T) => string; - private fromRedisConverter: (value: string) => T | undefined; - - constructor(redisClient: RedisSingleCache['redisClient'], name: RedisSingleCache['name'], opts: { - lifetime: RedisSingleCache['lifetime']; - memoryCacheLifetime: number; - fetcher: RedisSingleCache['fetcher']; - toRedisConverter: RedisSingleCache['toRedisConverter']; - fromRedisConverter: RedisSingleCache['fromRedisConverter']; - }) { - this.redisClient = redisClient; - this.name = name; - this.lifetime = opts.lifetime; - this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime); - this.fetcher = opts.fetcher; - this.toRedisConverter = opts.toRedisConverter; - this.fromRedisConverter = opts.fromRedisConverter; - } - - @bindThis - public async set(value: T): Promise { - this.memoryCache.set(value); - if (this.lifetime === Infinity) { - await this.redisClient.set( - `singlecache:${this.name}`, - this.toRedisConverter(value), - ); - } else { - await this.redisClient.set( - `singlecache:${this.name}`, - this.toRedisConverter(value), - 'EX', Math.round(this.lifetime / 1000), - ); - } - } - - @bindThis - public async get(): Promise { - const memoryCached = this.memoryCache.get(); - if (memoryCached !== undefined) return memoryCached; - - const cached = await this.redisClient.get(`singlecache:${this.name}`); - if (cached == null) return undefined; - return this.fromRedisConverter(cached); - } - - @bindThis - public async delete(): Promise { - this.memoryCache.delete(); - await this.redisClient.del(`singlecache:${this.name}`); - } - - /** - * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します - */ - @bindThis - public async fetch(): Promise { - const cachedValue = await this.get(); - if (cachedValue !== undefined) { - // Cache HIT - return cachedValue; - } - - // Cache MISS - const value = await this.fetcher(); - this.set(value); - return value; - } - - @bindThis - public async refresh() { - const value = await this.fetcher(); - this.set(value); - - // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする - } -} - -// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? - -function nothingToDo(value: T): V { - return value as unknown as V; -} - -export class MemoryKVCache { - public cache: Map; - private lifetime: number; - private gcIntervalHandle: NodeJS.Timeout; - private toMapConverter: (value: T) => V; - private fromMapConverter: (cached: V) => T | undefined; - - constructor(lifetime: MemoryKVCache['lifetime'], options: { - toMapConverter: (value: T) => V; - fromMapConverter: (cached: V) => T | undefined; - } = { - toMapConverter: nothingToDo, - fromMapConverter: nothingToDo, - }) { + constructor(lifetime: Cache['lifetime']) { this.cache = new Map(); this.lifetime = lifetime; - this.toMapConverter = options.toMapConverter; - this.fromMapConverter = options.fromMapConverter; - - this.gcIntervalHandle = setInterval(() => { - this.gc(); - }, 1000 * 60 * 3); } - @bindThis - public set(key: string, value: T): void { + public set(key: string | null, value: T): void { this.cache.set(key, { date: Date.now(), - value: this.toMapConverter(value), + value, }); } - @bindThis - public get(key: string): T | undefined { + public get(key: string | null): T | undefined { const cached = this.cache.get(key); if (cached == null) return undefined; if ((Date.now() - cached.date) > this.lifetime) { this.cache.delete(key); return undefined; } - return this.fromMapConverter(cached.value); + return cached.value; } - @bindThis - public delete(key: string): void { + public delete(key: string | null) { this.cache.delete(key); } /** * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします - * fetcherの引数はcacheに保存されている値があれば渡されます */ - @bindThis - public async fetch(key: string, fetcher: (value: V | undefined) => Promise, validator?: (cachedValue: T) => boolean): Promise { + public async fetch(key: string | null, fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { const cachedValue = this.get(key); if (cachedValue !== undefined) { if (validator) { @@ -259,7 +47,7 @@ export class MemoryKVCache { } // Cache MISS - const value = await fetcher(this.cache.get(key)?.value); + const value = await fetcher(); this.set(key, value); return value; } @@ -267,10 +55,8 @@ export class MemoryKVCache { /** * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします - * fetcherの引数はcacheに保存されている値があれば渡されます */ - @bindThis - public async fetchMaybe(key: string, fetcher: (value: V | undefined) => Promise, validator?: (cachedValue: T) => boolean): Promise { + public async fetchMaybe(key: string | null, fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { const cachedValue = this.get(key); if (cachedValue !== undefined) { if (validator) { @@ -285,110 +71,10 @@ export class MemoryKVCache { } // Cache MISS - const value = await fetcher(this.cache.get(key)?.value); + const value = await fetcher(); if (value !== undefined) { this.set(key, value); } return value; } - - @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); - } - } - } - - @bindThis - public dispose(): void { - clearInterval(this.gcIntervalHandle); - } -} - -export class MemorySingleCache { - private cachedAt: number | null = null; - private value: T | undefined; - private lifetime: number; - - constructor(lifetime: MemorySingleCache['lifetime']) { - this.lifetime = lifetime; - } - - @bindThis - public set(value: T): void { - this.cachedAt = Date.now(); - this.value = value; - } - - @bindThis - public get(): T | undefined { - if (this.cachedAt == null) return undefined; - if ((Date.now() - this.cachedAt) > this.lifetime) { - this.value = undefined; - this.cachedAt = null; - return undefined; - } - return this.value; - } - - @bindThis - public delete() { - this.value = undefined; - this.cachedAt = null; - } - - /** - * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します - * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします - */ - @bindThis - public async fetch(fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { - const cachedValue = this.get(); - if (cachedValue !== undefined) { - if (validator) { - if (validator(cachedValue)) { - // Cache HIT - return cachedValue; - } - } else { - // Cache HIT - return cachedValue; - } - } - - // Cache MISS - const value = await fetcher(); - this.set(value); - return value; - } - - /** - * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します - * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします - */ - @bindThis - public async fetchMaybe(fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { - const cachedValue = this.get(); - if (cachedValue !== undefined) { - if (validator) { - if (validator(cachedValue)) { - // Cache HIT - return cachedValue; - } - } else { - // Cache HIT - return cachedValue; - } - } - - // Cache MISS - const value = await fetcher(); - if (value !== undefined) { - this.set(value); - } - return value; - } } diff --git a/packages/backend/src/misc/captcha.ts b/packages/backend/src/misc/captcha.ts new file mode 100644 index 0000000000..9a87a4a3c8 --- /dev/null +++ b/packages/backend/src/misc/captcha.ts @@ -0,0 +1,57 @@ +import fetch from 'node-fetch'; +import { URLSearchParams } from 'node:url'; +import { getAgentByUrl } from './fetch.js'; +import config from '@/config/index.js'; + +export async function verifyRecaptcha(secret: string, response: string) { + const result = await getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(e => { + throw `recaptcha-request-failed: ${e}`; + }); + + if (result.success !== true) { + const errorCodes = result['error-codes'] ? result['error-codes']?.join(', ') : ''; + throw `recaptcha-failed: ${errorCodes}`; + } +} + +export async function verifyHcaptcha(secret: string, response: string) { + const result = await getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(e => { + throw `hcaptcha-request-failed: ${e}`; + }); + + if (result.success !== true) { + const errorCodes = result['error-codes'] ? result['error-codes']?.join(', ') : ''; + throw `hcaptcha-failed: ${errorCodes}`; + } +} + +type CaptchaResponse = { + success: boolean; + 'error-codes'?: string[]; +}; + +async function getCaptchaResponse(url: string, secret: string, response: string): Promise { + const params = new URLSearchParams({ + secret, + response, + }); + + const res = await fetch(url, { + method: 'POST', + body: params, + headers: { + 'User-Agent': config.userAgent, + }, + // TODO + //timeout: 10 * 1000, + agent: getAgentByUrl, + }).catch(e => { + throw `${e.message || e}`; + }); + + if (!res.ok) { + throw `${res.status}`; + } + + return await res.json() as CaptchaResponse; +} diff --git a/packages/backend/src/misc/check-hit-antenna.ts b/packages/backend/src/misc/check-hit-antenna.ts new file mode 100644 index 0000000000..d9cedee7df --- /dev/null +++ b/packages/backend/src/misc/check-hit-antenna.ts @@ -0,0 +1,99 @@ +import { Antenna } from '@/models/entities/antenna.js'; +import { Note } from '@/models/entities/note.js'; +import { User } from '@/models/entities/user.js'; +import { UserListJoinings, UserGroupJoinings, Blockings } from '@/models/index.js'; +import { getFullApAccount } from './convert-host.js'; +import * as Acct from '@/misc/acct.js'; +import { Packed } from './schema.js'; +import { Cache } from './cache.js'; + +const blockingCache = new Cache(1000 * 60 * 5); + +// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている + +/** + * noteUserFollowers / antennaUserFollowing はどちらか一方が指定されていればよい + */ +export async function checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }, noteUserFollowers?: User['id'][], antennaUserFollowing?: User['id'][]): Promise { + if (note.visibility === 'specified') return false; + + // アンテナ作成者がノート作成者にブロックされていたらスキップ + const blockings = await blockingCache.fetch(noteUser.id, () => Blockings.findBy({ blockerId: noteUser.id }).then(res => res.map(x => x.blockeeId))); + if (blockings.some(blocking => blocking === antenna.userId)) return false; + + if (note.visibility === 'followers') { + if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false; + if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false; + } + + if (!antenna.withReplies && note.replyId != null) return false; + + if (antenna.src === 'home') { + if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false; + if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false; + } else if (antenna.src === 'list') { + const listUsers = (await UserListJoinings.findBy({ + userListId: antenna.userListId!, + })).map(x => x.userId); + + if (!listUsers.includes(note.userId)) return false; + } else if (antenna.src === 'group') { + const joining = await UserGroupJoinings.findOneByOrFail({ id: antenna.userGroupJoiningId! }); + + const groupUsers = (await UserGroupJoinings.findBy({ + userGroupId: joining.userGroupId, + })).map(x => x.userId); + + if (!groupUsers.includes(note.userId)) return false; + } else if (antenna.src === 'users') { + const accts = antenna.users.map(x => { + const { username, host } = Acct.parse(x); + return getFullApAccount(username, host).toLowerCase(); + }); + if (!accts.includes(getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false; + } + + const keywords = antenna.keywords + // Clean up + .map(xs => xs.filter(x => x !== '')) + .filter(xs => xs.length > 0); + + if (keywords.length > 0) { + if (note.text == null) return false; + + const matched = keywords.some(and => + and.every(keyword => + antenna.caseSensitive + ? note.text!.includes(keyword) + : note.text!.toLowerCase().includes(keyword.toLowerCase()) + )); + + if (!matched) return false; + } + + const excludeKeywords = antenna.excludeKeywords + // Clean up + .map(xs => xs.filter(x => x !== '')) + .filter(xs => xs.length > 0); + + if (excludeKeywords.length > 0) { + if (note.text == null) return false; + + const matched = excludeKeywords.some(and => + and.every(keyword => + antenna.caseSensitive + ? note.text!.includes(keyword) + : note.text!.toLowerCase().includes(keyword.toLowerCase()) + )); + + if (matched) return false; + } + + if (antenna.withFile) { + if (note.fileIds && note.fileIds.length === 0) return false; + } + + // TODO: eval expression + + return true; +} diff --git a/packages/backend/src/misc/check-https.ts b/packages/backend/src/misc/check-https.ts deleted file mode 100644 index 0b13ccabdd..0000000000 --- a/packages/backend/src/misc/check-https.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export function checkHttps(url: string): boolean { - return url.startsWith('https://') || - (url.startsWith('http://') && process.env.NODE_ENV !== 'production'); -} diff --git a/packages/backend/src/misc/check-word-mute.ts b/packages/backend/src/misc/check-word-mute.ts index cef5595451..d7662820af 100644 --- a/packages/backend/src/misc/check-word-mute.ts +++ b/packages/backend/src/misc/check-word-mute.ts @@ -1,25 +1,16 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { AhoCorasick } from 'slacc'; import RE2 from 're2'; -import type { MiNote } from '@/models/Note.js'; -import type { MiUser } from '@/models/User.js'; +import { Note } from '@/models/entities/note.js'; +import { User } from '@/models/entities/user.js'; type NoteLike = { - userId: MiNote['userId']; - text: MiNote['text']; - cw?: MiNote['cw']; + userId: Note['userId']; + text: Note['text']; }; type UserLike = { - id: MiUser['id']; + id: User['id']; }; -const acCache = new Map(); - export async function checkWordMute(note: NoteLike, me: UserLike | null | undefined, mutedWords: Array): Promise { // 自分自身 if (me && (note.userId === me.id)) return false; @@ -29,22 +20,7 @@ export async function checkWordMute(note: NoteLike, me: UserLike | null | undefi if (text === '') return false; - const acable = mutedWords.filter(filter => Array.isArray(filter) && filter.length === 1).map(filter => filter[0]).sort(); - const unacable = mutedWords.filter(filter => !Array.isArray(filter) || filter.length !== 1); - const acCacheKey = acable.join('\n'); - const ac = acCache.get(acCacheKey) ?? AhoCorasick.withPatterns(acable); - acCache.delete(acCacheKey); - for (const obsoleteKeys of acCache.keys()) { - if (acCache.size > 1000) { - acCache.delete(obsoleteKeys); - } - } - acCache.set(acCacheKey, ac); - if (ac.isMatch(text)) { - return true; - } - - const matched = unacable.some(filter => { + const matched = mutedWords.some(filter => { if (Array.isArray(filter)) { return filter.every(keyword => text.includes(keyword)); } else { diff --git a/packages/backend/src/misc/clone.ts b/packages/backend/src/misc/clone.ts deleted file mode 100644 index 9d20deac3b..0000000000 --- a/packages/backend/src/misc/clone.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// structredCloneが遅いため -// SEE: http://var.blog.jp/archives/86038606.html - -type Cloneable = string | number | boolean | null | { [key: string]: Cloneable } | Cloneable[]; - -export function deepClone(x: T): T { - if (typeof x === 'object') { - if (x === null) return x; - if (Array.isArray(x)) return x.map(deepClone) as T; - const obj = {} as Record; - for (const [k, v] of Object.entries(x)) { - obj[k] = deepClone(v); - } - return obj as T; - } else { - return x; - } -} diff --git a/packages/backend/src/misc/content-disposition.ts b/packages/backend/src/misc/content-disposition.ts index 1ac8c88d21..b2aec471d5 100644 --- a/packages/backend/src/misc/content-disposition.ts +++ b/packages/backend/src/misc/content-disposition.ts @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import cd from 'content-disposition'; export function contentDisposition(type: 'inline' | 'attachment', filename: string): string { diff --git a/packages/backend/src/misc/convert-host.ts b/packages/backend/src/misc/convert-host.ts new file mode 100644 index 0000000000..7eb940a7e0 --- /dev/null +++ b/packages/backend/src/misc/convert-host.ts @@ -0,0 +1,26 @@ +import { URL } from 'node:url'; +import config from '@/config/index.js'; +import { toASCII } from 'punycode'; + +export function getFullApAccount(username: string, host: string | null) { + return host ? `${username}@${toPuny(host)}` : `${username}@${toPuny(config.host)}`; +} + +export function isSelfHost(host: string) { + if (host == null) return true; + return toPuny(config.host) === toPuny(host); +} + +export function extractDbHost(uri: string) { + const url = new URL(uri); + return toPuny(url.hostname); +} + +export function toPuny(host: string) { + return toASCII(host.toLowerCase()); +} + +export function toPunyNullable(host: string | null | undefined): string | null { + if (host == null) return null; + return toASCII(host.toLowerCase()); +} diff --git a/packages/backend/src/misc/correct-filename.ts b/packages/backend/src/misc/correct-filename.ts deleted file mode 100644 index 9130af44c3..0000000000 --- a/packages/backend/src/misc/correct-filename.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -/** - * Array.includes()よりSet.has()の方が高速 - */ -const targetExtsToSkip = new Set([ - '.gz', - '.tar', - '.tgz', - '.bz2', - '.xz', - '.zip', - '.7z', -]); - -const extRegExp = /\.[0-9a-zA-Z]+$/i; - -/** - * 与えられた拡張子とファイル名が一致しているかどうかを確認し、 - * 一致していない場合は拡張子を付与して返す - * - * extはfile-typeのextを想定 - */ -export function correctFilename(filename: string, ext: string | null) { - const dotExt = ext ? ext[0] === '.' ? ext : `.${ext}` : '.unknown'; - - const match = extRegExp.exec(filename); - if (!match || !match[0]) { - // filenameが拡張子を持っていない場合は拡張子をつける - return `${filename}${dotExt}`; - } - - const filenameExt = match[0].toLowerCase(); - if ( - // 未知のファイル形式かつ拡張子がある場合は何もしない - ext === null || - // 拡張子が一致している場合は何もしない - filenameExt === dotExt || - - // jpeg, tiffを同一視 - dotExt === '.jpg' && filenameExt === '.jpeg' || - dotExt === '.tif' && filenameExt === '.tiff' || - // dllもexeもportable executableなので判定が正しく行われない - dotExt === '.exe' && filenameExt === '.dll' || - - // 圧縮形式っぽければ下手に拡張子を変えない - // https://github.com/misskey-dev/misskey/issues/11482 - targetExtsToSkip.has(dotExt) - ) { - return filename; - } - - // 拡張子があるが一致していないなどの場合は拡張子を付け足す - return `${filename}${dotExt}`; -} diff --git a/packages/backend/src/misc/count-same-renotes.ts b/packages/backend/src/misc/count-same-renotes.ts new file mode 100644 index 0000000000..b7f8ce90c8 --- /dev/null +++ b/packages/backend/src/misc/count-same-renotes.ts @@ -0,0 +1,15 @@ +import { Notes } from '@/models/index.js'; + +export async function countSameRenotes(userId: string, renoteId: string, excludeNoteId: string | undefined): Promise { + // 指定したユーザーの指定したノートのリノートがいくつあるか数える + const query = Notes.createQueryBuilder('note') + .where('note.userId = :userId', { userId }) + .andWhere('note.renoteId = :renoteId', { renoteId }); + + // 指定した投稿を除く + if (excludeNoteId) { + query.andWhere('note.id != :excludeNoteId', { excludeNoteId }); + } + + return await query.getCount(); +} diff --git a/packages/backend/src/misc/create-temp.ts b/packages/backend/src/misc/create-temp.ts index 5b4943b7a2..fa88769de0 100644 --- a/packages/backend/src/misc/create-temp.ts +++ b/packages/backend/src/misc/create-temp.ts @@ -1,15 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import * as tmp from 'tmp'; export function createTemp(): Promise<[string, () => void]> { return new Promise<[string, () => void]>((res, rej) => { tmp.file((e, path, fd, cleanup) => { if (e) return rej(e); - res([path, process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'development' ? cleanup : () => {}]); + res([path, cleanup]); }); }); } @@ -22,8 +17,8 @@ export function createTempDir(): Promise<[string, () => void]> { }, (e, path, cleanup) => { if (e) return rej(e); - res([path, process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'development' ? cleanup : () => {}]); - }, + res([path, cleanup]); + } ); }); } diff --git a/packages/backend/src/misc/detect-url-mime.ts b/packages/backend/src/misc/detect-url-mime.ts new file mode 100644 index 0000000000..cd143cf2fb --- /dev/null +++ b/packages/backend/src/misc/detect-url-mime.ts @@ -0,0 +1,15 @@ +import { createTemp } from './create-temp.js'; +import { downloadUrl } from './download-url.js'; +import { detectType } from './get-file-info.js'; + +export async function detectUrlMime(url: string) { + const [path, cleanup] = await createTemp(); + + try { + await downloadUrl(url, path); + const { mime } = await detectType(path); + return mime; + } finally { + cleanup(); + } +} diff --git a/packages/backend/src/misc/dev-null.ts b/packages/backend/src/misc/dev-null.ts deleted file mode 100644 index f510177c0b..0000000000 --- a/packages/backend/src/misc/dev-null.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Writable, WritableOptions } from 'node:stream'; - -export class DevNull extends Writable implements NodeJS.WritableStream { - constructor(opts?: WritableOptions) { - super(opts); - } - - _write (chunk: any, encoding: BufferEncoding, cb: (err?: Error | null) => void) { - setImmediate(cb); - } -} diff --git a/packages/backend/src/misc/download-text-file.ts b/packages/backend/src/misc/download-text-file.ts new file mode 100644 index 0000000000..c62c70ee33 --- /dev/null +++ b/packages/backend/src/misc/download-text-file.ts @@ -0,0 +1,25 @@ +import * as fs from 'node:fs'; +import * as util from 'node:util'; +import Logger from '@/services/logger.js'; +import { createTemp } from './create-temp.js'; +import { downloadUrl } from './download-url.js'; + +const logger = new Logger('download-text-file'); + +export async function downloadTextFile(url: string): Promise { + // Create temp file + const [path, cleanup] = await createTemp(); + + logger.info(`Temp file is ${path}`); + + try { + // write content at URL to temp file + await downloadUrl(url, path); + + const text = await util.promisify(fs.readFile)(path, 'utf8'); + + return text; + } finally { + cleanup(); + } +} diff --git a/packages/backend/src/misc/download-url.ts b/packages/backend/src/misc/download-url.ts new file mode 100644 index 0000000000..7c57b140ef --- /dev/null +++ b/packages/backend/src/misc/download-url.ts @@ -0,0 +1,89 @@ +import * as fs from 'node:fs'; +import * as stream from 'node:stream'; +import * as util from 'node:util'; +import got, * as Got from 'got'; +import { httpAgent, httpsAgent, StatusError } from './fetch.js'; +import config from '@/config/index.js'; +import chalk from 'chalk'; +import Logger from '@/services/logger.js'; +import IPCIDR from 'ip-cidr'; +import PrivateIp from 'private-ip'; + +const pipeline = util.promisify(stream.pipeline); + +export async function downloadUrl(url: string, path: string): Promise { + const logger = new Logger('download'); + + logger.info(`Downloading ${chalk.cyan(url)} ...`); + + const timeout = 30 * 1000; + const operationTimeout = 60 * 1000; + const maxSize = config.maxFileSize || 262144000; + + const req = got.stream(url, { + headers: { + 'User-Agent': config.userAgent, + }, + timeout: { + lookup: timeout, + connect: timeout, + secureConnect: timeout, + socket: timeout, // read timeout + response: timeout, + send: timeout, + request: operationTimeout, // whole operation timeout + }, + agent: { + http: httpAgent, + https: httpsAgent, + }, + http2: false, // default + retry: { + limit: 0, + }, + }).on('response', (res: Got.Response) => { + if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !config.proxy && res.ip) { + if (isPrivateIp(res.ip)) { + logger.warn(`Blocked address: ${res.ip}`); + req.destroy(); + } + } + + const contentLength = res.headers['content-length']; + if (contentLength != null) { + const size = Number(contentLength); + if (size > maxSize) { + logger.warn(`maxSize exceeded (${size} > ${maxSize}) on response`); + req.destroy(); + } + } + }).on('downloadProgress', (progress: Got.Progress) => { + if (progress.transferred > maxSize) { + logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`); + req.destroy(); + } + }); + + try { + await pipeline(req, fs.createWriteStream(path)); + } catch (e) { + if (e instanceof Got.HTTPError) { + throw new StatusError(`${e.response.statusCode} ${e.response.statusMessage}`, e.response.statusCode, e.response.statusMessage); + } else { + throw e; + } + } + + logger.succ(`Download finished: ${chalk.cyan(url)}`); +} + +function isPrivateIp(ip: string): boolean { + for (const net of config.allowedPrivateNetworks || []) { + const cidr = new IPCIDR(net); + if (cidr.contains(ip)) { + return false; + } + } + + return PrivateIp(ip); +} diff --git a/packages/backend/src/misc/emoji-regex.ts b/packages/backend/src/misc/emoji-regex.ts index 04c2f2e913..ca224971c5 100644 --- a/packages/backend/src/misc/emoji-regex.ts +++ b/packages/backend/src/misc/emoji-regex.ts @@ -1,10 +1,4 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// taken from @twemoji/parser/dist/lib/regex.js -const twemojiRegex = /(?:\ud83d\udc68\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffc-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb\udffd-\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb-\udffd\udfff]|\ud83e\uddd1\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb-\udffe]|\ud83d\udc68\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffc-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffd-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc68\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffd\udfff]|\ud83d\udc68\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffe]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffc-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffc-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffd-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb\udffd-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffd\udfff]|\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb-\udffd\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffe]|\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb-\udffe]|\ud83e\uddd1\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffc-\udfff]|\ud83e\uddd1\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb\udffd-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb-\udffd\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb-\udffe]|\ud83e\uddd1\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d[\udc68\udc69]|\ud83e\udef1\ud83c\udffb\u200d\ud83e\udef2\ud83c[\udffc-\udfff]|\ud83e\udef1\ud83c\udffc\u200d\ud83e\udef2\ud83c[\udffb\udffd-\udfff]|\ud83e\udef1\ud83c\udffd\u200d\ud83e\udef2\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\udef1\ud83c\udffe\u200d\ud83e\udef2\ud83c[\udffb-\udffd\udfff]|\ud83e\udef1\ud83c\udfff\u200d\ud83e\udef2\ud83c[\udffb-\udffe]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d[\udc68\udc69]|\ud83e\uddd1\u200d\ud83e\udd1d\u200d\ud83e\uddd1|\ud83d\udc6b\ud83c[\udffb-\udfff]|\ud83d\udc6c\ud83c[\udffb-\udfff]|\ud83d\udc6d\ud83c[\udffb-\udfff]|\ud83d\udc8f\ud83c[\udffb-\udfff]|\ud83d\udc91\ud83c[\udffb-\udfff]|\ud83e\udd1d\ud83c[\udffb-\udfff]|\ud83d[\udc6b-\udc6d\udc8f\udc91]|\ud83e\udd1d)|(?:\ud83d[\udc68\udc69]|\ud83e\uddd1)(?:\ud83c[\udffb-\udfff])?\u200d(?:\u2695\ufe0f|\u2696\ufe0f|\u2708\ufe0f|\ud83c[\udf3e\udf73\udf7c\udf84\udf93\udfa4\udfa8\udfeb\udfed]|\ud83d[\udcbb\udcbc\udd27\udd2c\ude80\ude92]|\ud83e[\uddaf-\uddb3\uddbc\uddbd])|(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75]|\u26f9)((?:\ud83c[\udffb-\udfff]|\ufe0f)\u200d[\u2640\u2642]\ufe0f)|(?:\ud83c[\udfc3\udfc4\udfca]|\ud83d[\udc6e\udc70\udc71\udc73\udc77\udc81\udc82\udc86\udc87\ude45-\ude47\ude4b\ude4d\ude4e\udea3\udeb4-\udeb6]|\ud83e[\udd26\udd35\udd37-\udd39\udd3d\udd3e\uddb8\uddb9\uddcd-\uddcf\uddd4\uddd6-\udddd])(?:\ud83c[\udffb-\udfff])?\u200d[\u2640\u2642]\ufe0f|(?:\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83c\udff3\ufe0f\u200d\u26a7\ufe0f|\ud83c\udff3\ufe0f\u200d\ud83c\udf08|\ud83d\ude36\u200d\ud83c\udf2b\ufe0f|\u2764\ufe0f\u200d\ud83d\udd25|\u2764\ufe0f\u200d\ud83e\ude79|\ud83c\udff4\u200d\u2620\ufe0f|\ud83d\udc15\u200d\ud83e\uddba|\ud83d\udc3b\u200d\u2744\ufe0f|\ud83d\udc41\u200d\ud83d\udde8|\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc6f\u200d\u2640\ufe0f|\ud83d\udc6f\u200d\u2642\ufe0f|\ud83d\ude2e\u200d\ud83d\udca8|\ud83d\ude35\u200d\ud83d\udcab|\ud83e\udd3c\u200d\u2640\ufe0f|\ud83e\udd3c\u200d\u2642\ufe0f|\ud83e\uddde\u200d\u2640\ufe0f|\ud83e\uddde\u200d\u2642\ufe0f|\ud83e\udddf\u200d\u2640\ufe0f|\ud83e\udddf\u200d\u2642\ufe0f|\ud83d\udc08\u200d\u2b1b|\ud83d\udc26\u200d\u2b1b)|[#*0-9]\ufe0f?\u20e3|(?:[©®\u2122\u265f]\ufe0f)|(?:\ud83c[\udc04\udd70\udd71\udd7e\udd7f\ude02\ude1a\ude2f\ude37\udf21\udf24-\udf2c\udf36\udf7d\udf96\udf97\udf99-\udf9b\udf9e\udf9f\udfcd\udfce\udfd4-\udfdf\udff3\udff5\udff7]|\ud83d[\udc3f\udc41\udcfd\udd49\udd4a\udd6f\udd70\udd73\udd76-\udd79\udd87\udd8a-\udd8d\udda5\udda8\uddb1\uddb2\uddbc\uddc2-\uddc4\uddd1-\uddd3\udddc-\uddde\udde1\udde3\udde8\uddef\uddf3\uddfa\udecb\udecd-\udecf\udee0-\udee5\udee9\udef0\udef3]|[\u203c\u2049\u2139\u2194-\u2199\u21a9\u21aa\u231a\u231b\u2328\u23cf\u23ed-\u23ef\u23f1\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267b\u267f\u2692-\u2697\u2699\u269b\u269c\u26a0\u26a1\u26a7\u26aa\u26ab\u26b0\u26b1\u26bd\u26be\u26c4\u26c5\u26c8\u26cf\u26d1\u26d3\u26d4\u26e9\u26ea\u26f0-\u26f5\u26f8\u26fa\u26fd\u2702\u2708\u2709\u270f\u2712\u2714\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u2764\u27a1\u2934\u2935\u2b05-\u2b07\u2b1b\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299])(?:\ufe0f|(?!\ufe0e))|(?:(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75\udd90]|\ud83e\udef0|[\u261d\u26f7\u26f9\u270c\u270d])(?:\ufe0f|(?!\ufe0e))|(?:\ud83c[\udf85\udfc2-\udfc4\udfc7\udfca]|\ud83d[\udc42\udc43\udc46-\udc50\udc66-\udc69\udc6e\udc70-\udc78\udc7c\udc81-\udc83\udc85-\udc87\udcaa\udd7a\udd95\udd96\ude45-\ude47\ude4b-\ude4f\udea3\udeb4-\udeb6\udec0\udecc]|\ud83e[\udd0c\udd0f\udd18-\udd1c\udd1e\udd1f\udd26\udd30-\udd39\udd3d\udd3e\udd77\uddb5\uddb6\uddb8\uddb9\uddbb\uddcd-\uddcf\uddd1-\udddd\udec3-\udec5\udef1-\udef8]|[\u270a\u270b]))(?:\ud83c[\udffb-\udfff])?|(?:\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f|\ud83c\udde6\ud83c[\udde8-\uddec\uddee\uddf1\uddf2\uddf4\uddf6-\uddfa\uddfc\uddfd\uddff]|\ud83c\udde7\ud83c[\udde6\udde7\udde9-\uddef\uddf1-\uddf4\uddf6-\uddf9\uddfb\uddfc\uddfe\uddff]|\ud83c\udde8\ud83c[\udde6\udde8\udde9\uddeb-\uddee\uddf0-\uddf5\uddf7\uddfa-\uddff]|\ud83c\udde9\ud83c[\uddea\uddec\uddef\uddf0\uddf2\uddf4\uddff]|\ud83c\uddea\ud83c[\udde6\udde8\uddea\uddec\udded\uddf7-\uddfa]|\ud83c\uddeb\ud83c[\uddee-\uddf0\uddf2\uddf4\uddf7]|\ud83c\uddec\ud83c[\udde6\udde7\udde9-\uddee\uddf1-\uddf3\uddf5-\uddfa\uddfc\uddfe]|\ud83c\udded\ud83c[\uddf0\uddf2\uddf3\uddf7\uddf9\uddfa]|\ud83c\uddee\ud83c[\udde8-\uddea\uddf1-\uddf4\uddf6-\uddf9]|\ud83c\uddef\ud83c[\uddea\uddf2\uddf4\uddf5]|\ud83c\uddf0\ud83c[\uddea\uddec-\uddee\uddf2\uddf3\uddf5\uddf7\uddfc\uddfe\uddff]|\ud83c\uddf1\ud83c[\udde6-\udde8\uddee\uddf0\uddf7-\uddfb\uddfe]|\ud83c\uddf2\ud83c[\udde6\udde8-\udded\uddf0-\uddff]|\ud83c\uddf3\ud83c[\udde6\udde8\uddea-\uddec\uddee\uddf1\uddf4\uddf5\uddf7\uddfa\uddff]|\ud83c\uddf4\ud83c\uddf2|\ud83c\uddf5\ud83c[\udde6\uddea-\udded\uddf0-\uddf3\uddf7-\uddf9\uddfc\uddfe]|\ud83c\uddf6\ud83c\udde6|\ud83c\uddf7\ud83c[\uddea\uddf4\uddf8\uddfa\uddfc]|\ud83c\uddf8\ud83c[\udde6-\uddea\uddec-\uddf4\uddf7-\uddf9\uddfb\uddfd-\uddff]|\ud83c\uddf9\ud83c[\udde6\udde8\udde9\uddeb-\udded\uddef-\uddf4\uddf7\uddf9\uddfb\uddfc\uddff]|\ud83c\uddfa\ud83c[\udde6\uddec\uddf2\uddf3\uddf8\uddfe\uddff]|\ud83c\uddfb\ud83c[\udde6\udde8\uddea\uddec\uddee\uddf3\uddfa]|\ud83c\uddfc\ud83c[\uddeb\uddf8]|\ud83c\uddfd\ud83c\uddf0|\ud83c\uddfe\ud83c[\uddea\uddf9]|\ud83c\uddff\ud83c[\udde6\uddf2\uddfc]|\ud83c[\udccf\udd8e\udd91-\udd9a\udde6-\uddff\ude01\ude32-\ude36\ude38-\ude3a\ude50\ude51\udf00-\udf20\udf2d-\udf35\udf37-\udf7c\udf7e-\udf84\udf86-\udf93\udfa0-\udfc1\udfc5\udfc6\udfc8\udfc9\udfcf-\udfd3\udfe0-\udff0\udff4\udff8-\udfff]|\ud83d[\udc00-\udc3e\udc40\udc44\udc45\udc51-\udc65\udc6a\udc6f\udc79-\udc7b\udc7d-\udc80\udc84\udc88-\udc8e\udc90\udc92-\udca9\udcab-\udcfc\udcff-\udd3d\udd4b-\udd4e\udd50-\udd67\udda4\uddfb-\ude44\ude48-\ude4a\ude80-\udea2\udea4-\udeb3\udeb7-\udebf\udec1-\udec5\uded0-\uded2\uded5-\uded7\udedc-\udedf\udeeb\udeec\udef4-\udefc\udfe0-\udfeb\udff0]|\ud83e[\udd0d\udd0e\udd10-\udd17\udd20-\udd25\udd27-\udd2f\udd3a\udd3c\udd3f-\udd45\udd47-\udd76\udd78-\uddb4\uddb7\uddba\uddbc-\uddcc\uddd0\uddde-\uddff\ude70-\ude7c\ude80-\ude88\ude90-\udebd\udebf-\udec2\udece-\udedb\udee0-\udee8]|[\u23e9-\u23ec\u23f0\u23f3\u267e\u26ce\u2705\u2728\u274c\u274e\u2753-\u2755\u2795-\u2797\u27b0\u27bf\ue50a])|\ufe0f/g; +import twemoji from 'twemoji-parser/dist/lib/regex.js'; +const twemojiRegex = twemoji.default; export const emojiRegex = new RegExp(`(${twemojiRegex.source})`); -export const emojiRegexAtStartToEnd = new RegExp(`^(${twemojiRegex.source})$`); diff --git a/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts b/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts index 0e8dfd21f8..a0319d8dd5 100644 --- a/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts +++ b/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts @@ -1,15 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as mfm from '@sharkey/sfm-js'; -import { unique } from '@/misc/prelude/array.js'; +import * as mfm from 'mfm-js'; +import { unique } from '@/prelude/array.js'; export function extractCustomEmojisFromMfm(nodes: mfm.MfmNode[]): string[] { const emojiNodes = mfm.extract(nodes, (node) => { return (node.type === 'emojiCode' && node.props.name.length <= 100); - }) as mfm.MfmEmojiCode[]; + }); return unique(emojiNodes.map(x => x.props.name)); } diff --git a/packages/backend/src/misc/extract-hashtags.ts b/packages/backend/src/misc/extract-hashtags.ts index 3598d90093..0b0418eefd 100644 --- a/packages/backend/src/misc/extract-hashtags.ts +++ b/packages/backend/src/misc/extract-hashtags.ts @@ -1,13 +1,8 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as mfm from '@sharkey/sfm-js'; -import { unique } from '@/misc/prelude/array.js'; +import * as mfm from 'mfm-js'; +import { unique } from '@/prelude/array.js'; export function extractHashtags(nodes: mfm.MfmNode[]): string[] { - const hashtagNodes = mfm.extract(nodes, (node) => node.type === 'hashtag') as mfm.MfmHashtag[]; + const hashtagNodes = mfm.extract(nodes, (node) => node.type === 'hashtag'); const hashtags = unique(hashtagNodes.map(x => x.props.hashtag)); return hashtags; diff --git a/packages/backend/src/misc/extract-mentions.ts b/packages/backend/src/misc/extract-mentions.ts index b0897b05a8..cc19b161a8 100644 --- a/packages/backend/src/misc/extract-mentions.ts +++ b/packages/backend/src/misc/extract-mentions.ts @@ -1,15 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - // test is located in test/extract-mentions -import * as mfm from '@sharkey/sfm-js'; +import * as mfm from 'mfm-js'; export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] { // TODO: 重複を削除 - const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention') as mfm.MfmMention[]; + const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention'); const mentions = mentionNodes.map(x => x.props); return mentions; diff --git a/packages/backend/src/misc/fastify-reply-error.ts b/packages/backend/src/misc/fastify-reply-error.ts deleted file mode 100644 index 7c889bab7a..0000000000 --- a/packages/backend/src/misc/fastify-reply-error.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// https://www.fastify.io/docs/latest/Reference/Reply/#async-await-and-promises -export class FastifyReplyError extends Error { - public message: string; - public statusCode: number; - - constructor(statusCode: number, message: string) { - super(message); - this.message = message; - this.statusCode = statusCode; - } -} diff --git a/packages/backend/src/misc/fetch-meta.ts b/packages/backend/src/misc/fetch-meta.ts new file mode 100644 index 0000000000..e855ac28ee --- /dev/null +++ b/packages/backend/src/misc/fetch-meta.ts @@ -0,0 +1,44 @@ +import { db } from '@/db/postgre.js'; +import { Meta } from '@/models/entities/meta.js'; + +let cache: Meta; + +export async function fetchMeta(noCache = false): Promise { + if (!noCache && cache) return cache; + + return await db.transaction(async transactionalEntityManager => { + // 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する + const metas = await transactionalEntityManager.find(Meta, { + order: { + id: 'DESC', + }, + }); + + const meta = metas[0]; + + if (meta) { + cache = meta; + return meta; + } else { + // metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う + const saved = await transactionalEntityManager + .upsert( + Meta, + { + id: 'x', + }, + ['id'], + ) + .then((x) => transactionalEntityManager.findOneByOrFail(Meta, x.identifiers[0])); + + cache = saved; + return saved; + } + }); +} + +setInterval(() => { + fetchMeta(true).then(meta => { + cache = meta; + }); +}, 1000 * 10); diff --git a/packages/backend/src/misc/fetch-proxy-account.ts b/packages/backend/src/misc/fetch-proxy-account.ts new file mode 100644 index 0000000000..b61bba264b --- /dev/null +++ b/packages/backend/src/misc/fetch-proxy-account.ts @@ -0,0 +1,9 @@ +import { fetchMeta } from './fetch-meta.js'; +import { ILocalUser } from '@/models/entities/user.js'; +import { Users } from '@/models/index.js'; + +export async function fetchProxyAccount(): Promise { + const meta = await fetchMeta(); + if (meta.proxyAccountId == null) return null; + return await Users.findOneByOrFail({ id: meta.proxyAccountId }) as ILocalUser; +} diff --git a/packages/backend/src/misc/fetch.ts b/packages/backend/src/misc/fetch.ts new file mode 100644 index 0000000000..af6bf2fca7 --- /dev/null +++ b/packages/backend/src/misc/fetch.ts @@ -0,0 +1,141 @@ +import * as http from 'node:http'; +import * as https from 'node:https'; +import { URL } from 'node:url'; +import CacheableLookup from 'cacheable-lookup'; +import fetch from 'node-fetch'; +import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; +import config from '@/config/index.js'; + +export async function getJson(url: string, accept = 'application/json, */*', timeout = 10000, headers?: Record) { + const res = await getResponse({ + url, + method: 'GET', + headers: Object.assign({ + 'User-Agent': config.userAgent, + Accept: accept, + }, headers || {}), + timeout, + }); + + return await res.json(); +} + +export async function getHtml(url: string, accept = 'text/html, */*', timeout = 10000, headers?: Record) { + const res = await getResponse({ + url, + method: 'GET', + headers: Object.assign({ + 'User-Agent': config.userAgent, + Accept: accept, + }, headers || {}), + timeout, + }); + + return await res.text(); +} + +export async function getResponse(args: { url: string, method: string, body?: string, headers: Record, timeout?: number, size?: number }) { + const timeout = args.timeout || 10 * 1000; + + const controller = new AbortController(); + setTimeout(() => { + controller.abort(); + }, timeout * 6); + + const res = await fetch(args.url, { + method: args.method, + headers: args.headers, + body: args.body, + timeout, + size: args.size || 10 * 1024 * 1024, + agent: getAgentByUrl, + signal: controller.signal, + }); + + if (!res.ok) { + throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText); + } + + return res; +} + +const cache = new CacheableLookup({ + maxTtl: 3600, // 1hours + errorTtl: 30, // 30secs + lookup: false, // nativeのdns.lookupにfallbackしない +}); + +/** + * Get http non-proxy agent + */ +const _http = new http.Agent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + lookup: cache.lookup, +} as http.AgentOptions); + +/** + * Get https non-proxy agent + */ +const _https = new https.Agent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + lookup: cache.lookup, +} as https.AgentOptions); + +const maxSockets = Math.max(256, config.deliverJobConcurrency || 128); + +/** + * Get http proxy or non-proxy agent + */ +export const httpAgent = config.proxy + ? new HttpProxyAgent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + maxSockets, + maxFreeSockets: 256, + scheduling: 'lifo', + proxy: config.proxy, + }) + : _http; + +/** + * Get https proxy or non-proxy agent + */ +export const httpsAgent = config.proxy + ? new HttpsProxyAgent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + maxSockets, + maxFreeSockets: 256, + scheduling: 'lifo', + proxy: config.proxy, + }) + : _https; + +/** + * Get agent by URL + * @param url URL + * @param bypassProxy Allways bypass proxy + */ +export function getAgentByUrl(url: URL, bypassProxy = false) { + if (bypassProxy || (config.proxyBypassHosts || []).includes(url.hostname)) { + return url.protocol === 'http:' ? _http : _https; + } else { + return url.protocol === 'http:' ? httpAgent : httpsAgent; + } +} + +export class StatusError extends Error { + public statusCode: number; + public statusMessage?: string; + public isClientError: boolean; + + constructor(message: string, statusCode: number, statusMessage?: string) { + super(message); + this.name = 'StatusError'; + this.statusCode = statusCode; + this.statusMessage = statusMessage; + this.isClientError = typeof this.statusCode === 'number' && this.statusCode >= 400 && this.statusCode < 500; + } +} diff --git a/packages/backend/src/misc/gen-id.ts b/packages/backend/src/misc/gen-id.ts new file mode 100644 index 0000000000..fcf476857f --- /dev/null +++ b/packages/backend/src/misc/gen-id.ts @@ -0,0 +1,21 @@ +import { ulid } from 'ulid'; +import { genAid } from './id/aid.js'; +import { genMeid } from './id/meid.js'; +import { genMeidg } from './id/meidg.js'; +import { genObjectId } from './id/object-id.js'; +import config from '@/config/index.js'; + +const metohd = config.id.toLowerCase(); + +export function genId(date?: Date): string { + if (!date || (date > new Date())) date = new Date(); + + switch (metohd) { + case 'aid': return genAid(date); + case 'meid': return genMeid(date); + case 'meidg': return genMeidg(date); + case 'ulid': return ulid(date.getTime()); + case 'objectid': return genObjectId(date); + default: throw new Error('unrecognized id generation method'); + } +} diff --git a/packages/backend/src/misc/gen-identicon.ts b/packages/backend/src/misc/gen-identicon.ts index c36b00af63..322ffee22e 100644 --- a/packages/backend/src/misc/gen-identicon.ts +++ b/packages/backend/src/misc/gen-identicon.ts @@ -1,16 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - /** * Identicon generator * https://en.wikipedia.org/wiki/Identicon */ +import { WriteStream } from 'node:fs'; import * as p from 'pureimage'; import gen from 'random-seed'; -import type { WriteStream } from 'node:fs'; const size = 128; // px const n = 5; // resolution @@ -56,7 +51,7 @@ export function genIdenticon(seed: string, stream: WriteStream): Promise { bg.addColorStop(0, bgColors[0]); bg.addColorStop(1, bgColors[1]); - ctx.fillStyle = bg as any; + ctx.fillStyle = bg; ctx.beginPath(); ctx.fillRect(0, 0, size, size); diff --git a/packages/backend/src/misc/gen-key-pair.ts b/packages/backend/src/misc/gen-key-pair.ts index c0815613e7..e2ad598501 100644 --- a/packages/backend/src/misc/gen-key-pair.ts +++ b/packages/backend/src/misc/gen-key-pair.ts @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import * as crypto from 'node:crypto'; import * as util from 'node:util'; diff --git a/packages/backend/src/misc/generate-invite-code.ts b/packages/backend/src/misc/generate-invite-code.ts deleted file mode 100644 index 7c88561179..0000000000 --- a/packages/backend/src/misc/generate-invite-code.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { secureRndstr } from './secure-rndstr.js'; - -const CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; // [0-9A-Z] w/o [01IO] (32 patterns) - -export function generateInviteCode(): string { - const code = secureRndstr(8, { - chars: CHARS, - }); - - const uniqueId = []; - let n = Math.floor(Date.now() / 1000 / 60); - while (true) { - uniqueId.push(CHARS[n % CHARS.length]); - const t = Math.floor(n / CHARS.length); - if (!t) break; - n = t; - } - - return code + uniqueId.reverse().join(''); -} diff --git a/packages/backend/src/misc/generate-native-user-token.ts b/packages/backend/src/misc/generate-native-user-token.ts deleted file mode 100644 index 094c625120..0000000000 --- a/packages/backend/src/misc/generate-native-user-token.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { secureRndstr } from '@/misc/secure-rndstr.js'; - -// eslint-disable-next-line import/no-default-export -export default () => secureRndstr(16); diff --git a/packages/backend/src/misc/get-file-info.ts b/packages/backend/src/misc/get-file-info.ts new file mode 100644 index 0000000000..1c988b2487 --- /dev/null +++ b/packages/backend/src/misc/get-file-info.ts @@ -0,0 +1,374 @@ +import * as fs from 'node:fs'; +import * as crypto from 'node:crypto'; +import { join } from 'node:path'; +import * as stream from 'node:stream'; +import * as util from 'node:util'; +import { FSWatcher } from 'chokidar'; +import { fileTypeFromFile } from 'file-type'; +import FFmpeg from 'fluent-ffmpeg'; +import isSvg from 'is-svg'; +import probeImageSize from 'probe-image-size'; +import { type predictionType } from 'nsfwjs'; +import sharp from 'sharp'; +import { encode } from 'blurhash'; +import { detectSensitive } from '@/services/detect-sensitive.js'; +import { createTempDir } from './create-temp.js'; + +const pipeline = util.promisify(stream.pipeline); + +export type FileInfo = { + size: number; + md5: string; + type: { + mime: string; + ext: string | null; + }; + width?: number; + height?: number; + orientation?: number; + blurhash?: string; + sensitive: boolean; + porn: boolean; + warnings: string[]; +}; + +const TYPE_OCTET_STREAM = { + mime: 'application/octet-stream', + ext: null, +}; + +const TYPE_SVG = { + mime: 'image/svg+xml', + ext: 'svg', +}; + +/** + * Get file information + */ +export async function getFileInfo(path: string, opts: { + skipSensitiveDetection: boolean; + sensitiveThreshold?: number; + sensitiveThresholdForPorn?: number; + enableSensitiveMediaDetectionForVideos?: boolean; +}): Promise { + const warnings = [] as string[]; + + const size = await getFileSize(path); + const md5 = await calcHash(path); + + let type = await detectType(path); + + // image dimensions + let width: number | undefined; + let height: number | undefined; + let orientation: number | undefined; + + if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/bmp', 'image/tiff', 'image/svg+xml', 'image/vnd.adobe.photoshop'].includes(type.mime)) { + const imageSize = await detectImageSize(path).catch(e => { + warnings.push(`detectImageSize failed: ${e}`); + return undefined; + }); + + // うまく判定できない画像は octet-stream にする + if (!imageSize) { + warnings.push('cannot detect image dimensions'); + type = TYPE_OCTET_STREAM; + } else if (imageSize.wUnits === 'px') { + width = imageSize.width; + height = imageSize.height; + orientation = imageSize.orientation; + + // 制限を超えている画像は octet-stream にする + if (imageSize.width > 16383 || imageSize.height > 16383) { + warnings.push('image dimensions exceeds limits'); + type = TYPE_OCTET_STREAM; + } + } else { + warnings.push(`unsupported unit type: ${imageSize.wUnits}`); + } + } + + let blurhash: string | undefined; + + if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/svg+xml'].includes(type.mime)) { + blurhash = await getBlurhash(path).catch(e => { + warnings.push(`getBlurhash failed: ${e}`); + return undefined; + }); + } + + let sensitive = false; + let porn = false; + + if (!opts.skipSensitiveDetection) { + await detectSensitivity( + path, + type.mime, + opts.sensitiveThreshold ?? 0.5, + opts.sensitiveThresholdForPorn ?? 0.75, + opts.enableSensitiveMediaDetectionForVideos ?? false, + ).then(value => { + [sensitive, porn] = value; + }, error => { + warnings.push(`detectSensitivity failed: ${error}`); + }); + } + + return { + size, + md5, + type, + width, + height, + orientation, + blurhash, + sensitive, + porn, + warnings, + }; +} + +async function detectSensitivity(source: string, mime: string, sensitiveThreshold: number, sensitiveThresholdForPorn: number, analyzeVideo: boolean): Promise<[sensitive: boolean, porn: boolean]> { + let sensitive = false; + let porn = false; + + function judgePrediction(result: readonly predictionType[]): [sensitive: boolean, porn: boolean] { + let sensitive = false; + let porn = false; + + if ((result.find(x => x.className === 'Sexy')?.probability ?? 0) > sensitiveThreshold) sensitive = true; + if ((result.find(x => x.className === 'Hentai')?.probability ?? 0) > sensitiveThreshold) sensitive = true; + if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThreshold) sensitive = true; + + if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThresholdForPorn) porn = true; + + return [sensitive, porn]; + } + + if (['image/jpeg', 'image/png', 'image/webp'].includes(mime)) { + const result = await detectSensitive(source); + if (result) { + [sensitive, porn] = judgePrediction(result); + } + } else if (analyzeVideo && (mime === 'image/apng' || mime.startsWith('video/'))) { + const [outDir, disposeOutDir] = await createTempDir(); + try { + const command = FFmpeg() + .input(source) + .inputOptions([ + '-skip_frame', 'nokey', // 可能ならキーフレームのみを取得してほしいとする(そうなるとは限らない) + '-lowres', '3', // 元の画質でデコードする必要はないので 1/8 画質でデコードしてもよいとする(そうなるとは限らない) + ]) + .noAudio() + .videoFilters([ + { + filter: 'select', // フレームのフィルタリング + options: { + e: 'eq(pict_type,PICT_TYPE_I)', // I-Frame のみをフィルタする(VP9 とかはデコードしてみないとわからないっぽい) + }, + }, + { + filter: 'blackframe', // 暗いフレームの検出 + options: { + amount: '0', // 暗さに関わらず全てのフレームで測定値を取る + }, + }, + { + filter: 'metadata', + options: { + mode: 'select', // フレーム選択モード + key: 'lavfi.blackframe.pblack', // フレームにおける暗部の百分率(前のフィルタからのメタデータを参照する) + value: '50', + function: 'less', // 50% 未満のフレームを選択する(50% 以上暗部があるフレームだと誤検知を招くかもしれないので) + }, + }, + { + filter: 'scale', + options: { + w: 299, + h: 299, + }, + }, + ]) + .format('image2') + .output(join(outDir, '%d.png')) + .outputOptions(['-vsync', '0']); // 可変フレームレートにすることで穴埋めをさせない + const results: ReturnType[] = []; + let frameIndex = 0; + let targetIndex = 0; + let nextIndex = 1; + for await (const path of asyncIterateFrames(outDir, command)) { + try { + const index = frameIndex++; + if (index !== targetIndex) { + continue; + } + targetIndex = nextIndex; + nextIndex += index; // fibonacci sequence によってフレーム数制限を掛ける + const result = await detectSensitive(path); + if (result) { + results.push(judgePrediction(result)); + } + } finally { + fs.promises.unlink(path); + } + } + sensitive = results.filter(x => x[0]).length >= Math.ceil(results.length * sensitiveThreshold); + porn = results.filter(x => x[1]).length >= Math.ceil(results.length * sensitiveThresholdForPorn); + } finally { + disposeOutDir(); + } + } + + return [sensitive, porn]; +} + +async function* asyncIterateFrames(cwd: string, command: FFmpeg.FfmpegCommand): AsyncGenerator { + const watcher = new FSWatcher({ + cwd, + disableGlobbing: true, + }); + let finished = false; + command.once('end', () => { + finished = true; + watcher.close(); + }); + command.run(); + for (let i = 1; true; i++) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition + const current = `${i}.png`; + const next = `${i + 1}.png`; + const framePath = join(cwd, current); + if (await exists(join(cwd, next))) { + yield framePath; + } else if (!finished) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition + watcher.add(next); + await new Promise((resolve, reject) => { + watcher.on('add', function onAdd(path) { + if (path === next) { // 次フレームの書き出しが始まっているなら、現在フレームの書き出しは終わっている + watcher.unwatch(current); + watcher.off('add', onAdd); + resolve(); + } + }); + command.once('end', resolve); // 全てのフレームを処理し終わったなら、最終フレームである現在フレームの書き出しは終わっている + command.once('error', reject); + }); + yield framePath; + } else if (await exists(framePath)) { + yield framePath; + } else { + return; + } + } +} + +function exists(path: string): Promise { + return fs.promises.access(path).then(() => true, () => false); +} + +/** + * Detect MIME Type and extension + */ +export async function detectType(path: string): Promise<{ + mime: string; + ext: string | null; +}> { + // Check 0 byte + const fileSize = await getFileSize(path); + if (fileSize === 0) { + return TYPE_OCTET_STREAM; + } + + const type = await fileTypeFromFile(path); + + if (type) { + // XMLはSVGかもしれない + if (type.mime === 'application/xml' && await checkSvg(path)) { + return TYPE_SVG; + } + + return { + mime: type.mime, + ext: type.ext, + }; + } + + // 種類が不明でもSVGかもしれない + if (await checkSvg(path)) { + return TYPE_SVG; + } + + // それでも種類が不明なら application/octet-stream にする + return TYPE_OCTET_STREAM; +} + +/** + * Check the file is SVG or not + */ +export async function checkSvg(path: string) { + try { + const size = await getFileSize(path); + if (size > 1 * 1024 * 1024) return false; + return isSvg(fs.readFileSync(path)); + } catch { + return false; + } +} + +/** + * Get file size + */ +export async function getFileSize(path: string): Promise { + const getStat = util.promisify(fs.stat); + return (await getStat(path)).size; +} + +/** + * Calculate MD5 hash + */ +async function calcHash(path: string): Promise { + const hash = crypto.createHash('md5').setEncoding('hex'); + await pipeline(fs.createReadStream(path), hash); + return hash.read(); +} + +/** + * Detect dimensions of image + */ +async function detectImageSize(path: string): Promise<{ + width: number; + height: number; + wUnits: string; + hUnits: string; + orientation?: number; +}> { + const readable = fs.createReadStream(path); + const imageSize = await probeImageSize(readable); + readable.destroy(); + return imageSize; +} + +/** + * Calculate average color of image + */ +function getBlurhash(path: string): Promise { + return new Promise((resolve, reject) => { + sharp(path) + .raw() + .ensureAlpha() + .resize(64, 64, { fit: 'inside' }) + .toBuffer((err, buffer, { width, height }) => { + if (err) return reject(err); + + let hash; + + try { + hash = encode(new Uint8ClampedArray(buffer), width, height, 7, 7); + } catch (e) { + return reject(e); + } + + resolve(hash); + }); + }); +} diff --git a/packages/backend/src/misc/get-ip-hash.ts b/packages/backend/src/misc/get-ip-hash.ts index 3a01e4f578..379325bb13 100644 --- a/packages/backend/src/misc/get-ip-hash.ts +++ b/packages/backend/src/misc/get-ip-hash.ts @@ -1,19 +1,9 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import IPCIDR from 'ip-cidr'; -export function getIpHash(ip: string): string { - try { - // because a single person may control many IPv6 addresses, - // only a /64 subnet prefix of any IP will be taken into account. - // (this means for IPv4 the entire address is used) - const prefix = IPCIDR.createAddress(ip).mask(64); - return 'ip-' + BigInt('0b' + prefix).toString(36); - } catch (e) { - const prefix = IPCIDR.createAddress(ip.replace(/:[0-9]+$/, '')).mask(64); - return 'ip-' + BigInt('0b' + prefix).toString(36); - } +export function getIpHash(ip: string) { + // because a single person may control many IPv6 addresses, + // only a /64 subnet prefix of any IP will be taken into account. + // (this means for IPv4 the entire address is used) + const prefix = IPCIDR.createAddress(ip).mask(64); + return 'ip-' + BigInt('0b' + prefix).toString(36); } diff --git a/packages/backend/src/misc/get-note-summary.ts b/packages/backend/src/misc/get-note-summary.ts index 1bda5cdcf7..3f35ccee82 100644 --- a/packages/backend/src/misc/get-note-summary.ts +++ b/packages/backend/src/misc/get-note-summary.ts @@ -1,9 +1,4 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Packed } from './json-schema.js'; +import { Packed } from './schema.js'; /** * 投稿を表す文字列を取得します。 @@ -11,11 +6,11 @@ import type { Packed } from './json-schema.js'; */ export const getNoteSummary = (note: Packed<'Note'>): string => { if (note.deletedAt) { - return '(❌⛔)'; + return `(❌⛔)`; } if (note.isHidden) { - return '(⛔)'; + return `(⛔)`; } let summary = ''; @@ -28,13 +23,13 @@ export const getNoteSummary = (note: Packed<'Note'>): string => { } // ファイルが添付されているとき - if ((note.files ?? []).length !== 0) { + if ((note.files || []).length !== 0) { summary += ` (📎${note.files!.length})`; } // 投票が添付されているとき if (note.poll) { - summary += ' (📊)'; + summary += ` (📊)`; } // 返信のとき diff --git a/packages/backend/src/misc/get-reaction-emoji.ts b/packages/backend/src/misc/get-reaction-emoji.ts index 80ef7ff7bc..c2e0b98582 100644 --- a/packages/backend/src/misc/get-reaction-emoji.ts +++ b/packages/backend/src/misc/get-reaction-emoji.ts @@ -1,9 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// eslint-disable-next-line import/no-default-export export default function(reaction: string): string { switch (reaction) { case 'like': return '👍'; diff --git a/packages/backend/src/misc/hard-limits.ts b/packages/backend/src/misc/hard-limits.ts new file mode 100644 index 0000000000..1039f7335a --- /dev/null +++ b/packages/backend/src/misc/hard-limits.ts @@ -0,0 +1,14 @@ + +// If you change DB_* values, you must also change the DB schema. + +/** + * Maximum note text length that can be stored in DB. + * Surrogate pairs count as one + */ +export const DB_MAX_NOTE_TEXT_LENGTH = 8192; + +/** + * Maximum image description length that can be stored in DB. + * Surrogate pairs count as one + */ +export const DB_MAX_IMAGE_COMMENT_LENGTH = 512; diff --git a/packages/backend/src/misc/i18n.ts b/packages/backend/src/misc/i18n.ts index 4c9d1a08e3..4fa398763a 100644 --- a/packages/backend/src/misc/i18n.ts +++ b/packages/backend/src/misc/i18n.ts @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - export class I18n> { public locale: T; @@ -10,7 +5,7 @@ export class I18n> { this.locale = locale; //#region BIND - //this.t = this.t.bind(this); + this.t = this.t.bind(this); //#endregion } @@ -18,7 +13,7 @@ export class I18n> { // なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも public t(key: string, args?: Record): string { try { - let str = key.split('.').reduce((o, i) => o[i], this.locale as any) as string; + let str = key.split('.').reduce((o, i) => o[i], this.locale) as string; if (args) { for (const [k, v] of Object.entries(args)) { diff --git a/packages/backend/src/misc/id/aid.ts b/packages/backend/src/misc/id/aid.ts index de03f6793f..87e6888263 100644 --- a/packages/backend/src/misc/id/aid.ts +++ b/packages/backend/src/misc/id/aid.ts @@ -1,40 +1,25 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - // AID // 長さ8の[2000年1月1日からの経過ミリ秒をbase36でエンコードしたもの] + 長さ2の[ノイズ文字列] import * as crypto from 'node:crypto'; -export const aidRegExp = /^[0-9a-z]{10}$/; - const TIME2000 = 946684800000; let counter = crypto.randomBytes(2).readUInt16LE(0); -function getTime(time: number): string { +function getTime(time: number) { time = time - TIME2000; if (time < 0) time = 0; return time.toString(36).padStart(8, '0'); } -function getNoise(): string { +function getNoise() { return counter.toString(36).padStart(2, '0').slice(-2); } -export function genAid(t: number): string { - if (isNaN(t)) throw new Error('Failed to create AID: Invalid Date'); +export function genAid(date: Date): string { + const t = date.getTime(); + if (isNaN(t)) throw 'Failed to create AID: Invalid Date'; counter++; return getTime(t) + getNoise(); } - -export function parseAid(id: string): { date: Date; } { - const time = parseInt(id.slice(0, 8), 36) + TIME2000; - return { date: new Date(time) }; -} - -export function isSafeAidT(t: number): boolean { - return t > TIME2000; -} diff --git a/packages/backend/src/misc/id/aidx.ts b/packages/backend/src/misc/id/aidx.ts deleted file mode 100644 index 9f457f6f0a..0000000000 --- a/packages/backend/src/misc/id/aidx.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// AIDX -// 長さ8の[2000年1月1日からの経過ミリ秒をbase36でエンコードしたもの] + 長さ4の[個体ID] + 長さ4の[カウンタ] -// (c) mei23 -// https://misskey.m544.net/notes/71899acdcc9859ec5708ac24 - -import { customAlphabet } from 'nanoid'; - -export const aidxRegExp = /^[0-9a-z]{16}$/; - -const TIME2000 = 946684800000; -const TIME_LENGTH = 8; -const NODE_LENGTH = 4; -const NOISE_LENGTH = 4; - -const nodeId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', NODE_LENGTH)(); -let counter = 0; - -function getTime(time: number): string { - time = time - TIME2000; - if (time < 0) time = 0; - - return time.toString(36).padStart(TIME_LENGTH, '0').slice(-TIME_LENGTH); -} - -function getNoise(): string { - return counter.toString(36).padStart(NOISE_LENGTH, '0').slice(-NOISE_LENGTH); -} - -export function genAidx(t: number): string { - if (isNaN(t)) throw new Error('Failed to create AIDX: Invalid Date'); - counter++; - return getTime(t) + nodeId + getNoise(); -} - -export function parseAidx(id: string): { date: Date; } { - const time = parseInt(id.slice(0, TIME_LENGTH), 36) + TIME2000; - return { date: new Date(time) }; -} - -export function isSafeAidxT(t: number): boolean { - return t > TIME2000; -} diff --git a/packages/backend/src/misc/id/meid.ts b/packages/backend/src/misc/id/meid.ts index 7646282edb..30bbdf1698 100644 --- a/packages/backend/src/misc/id/meid.ts +++ b/packages/backend/src/misc/id/meid.ts @@ -1,13 +1,5 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - const CHARS = '0123456789abcdef'; -// same as object-id -export const meidRegExp = /^[0-9a-f]{24}$/; - function getTime(time: number) { if (time < 0) time = 0; if (time === 0) { @@ -29,16 +21,6 @@ function getRandom() { return str; } -export function genMeid(t: number): string { - return getTime(t) + getRandom(); -} - -export function parseMeid(id: string): { date: Date; } { - return { - date: new Date(parseInt(id.slice(0, 12), 16) - 0x800000000000), - }; -} - -export function isSafeMeidT(t: number): boolean { - return t > 0; +export function genMeid(date: Date): string { + return getTime(date.getTime()) + getRandom(); } diff --git a/packages/backend/src/misc/id/meidg.ts b/packages/backend/src/misc/id/meidg.ts index f2a55443ef..d4aaaea1ba 100644 --- a/packages/backend/src/misc/id/meidg.ts +++ b/packages/backend/src/misc/id/meidg.ts @@ -1,14 +1,8 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - const CHARS = '0123456789abcdef'; // 4bit Fixed hex value 'g' // 44bit UNIX Time ms in Hex // 48bit Random value in Hex -export const meidgRegExp = /^g[0-9a-f]{23}$/; function getTime(time: number) { if (time < 0) time = 0; @@ -29,16 +23,6 @@ function getRandom() { return str; } -export function genMeidg(t: number): string { - return 'g' + getTime(t) + getRandom(); -} - -export function parseMeidg(id: string): { date: Date; } { - return { - date: new Date(parseInt(id.slice(1, 12), 16)), - }; -} - -export function isSafeMeidgT(t: number): boolean { - return t > 0; +export function genMeidg(date: Date): string { + return 'g' + getTime(date.getTime()) + getRandom(); } diff --git a/packages/backend/src/misc/id/object-id.ts b/packages/backend/src/misc/id/object-id.ts index f5c3619fdb..392ea43301 100644 --- a/packages/backend/src/misc/id/object-id.ts +++ b/packages/backend/src/misc/id/object-id.ts @@ -1,13 +1,5 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - const CHARS = '0123456789abcdef'; -// same as meid -export const objectIdRegExp = /^[0-9a-f]{24}$/; - function getTime(time: number) { if (time < 0) time = 0; if (time === 0) { @@ -29,16 +21,6 @@ function getRandom() { return str; } -export function genObjectId(t: number): string { - return getTime(t) + getRandom(); -} - -export function parseObjectId(id: string): { date: Date; } { - return { - date: new Date(parseInt(id.slice(0, 8), 16) * 1000), - }; -} - -export function isSafeObjectIdT(t: number): boolean { - return t > 0; +export function genObjectId(date: Date): string { + return getTime(date.getTime()) + getRandom(); } diff --git a/packages/backend/src/misc/id/ulid.ts b/packages/backend/src/misc/id/ulid.ts deleted file mode 100644 index 00dd67dafe..0000000000 --- a/packages/backend/src/misc/id/ulid.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// Crockford's Base32 -// https://github.com/ulid/spec#encoding -const CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; - -export const ulidRegExp = /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/; - -export function parseUlid(id: string): { date: Date; } { - const timestamp = id.slice(0, 10); - let time = 0; - for (let i = 0; i < 10; i++) { - time = time * 32 + CHARS.indexOf(timestamp[i]); - } - return { date: new Date(time) }; -} diff --git a/packages/backend/src/misc/identifiable-error.ts b/packages/backend/src/misc/identifiable-error.ts index 71a4773fac..2d7c6bd0c6 100644 --- a/packages/backend/src/misc/identifiable-error.ts +++ b/packages/backend/src/misc/identifiable-error.ts @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - /** * ID付きエラー */ @@ -12,7 +7,7 @@ export class IdentifiableError extends Error { constructor(id: string, message?: string) { super(message); - this.message = message ?? ''; + this.message = message || ''; this.id = id; } } diff --git a/packages/backend/src/misc/is-duplicate-key-value-error.ts b/packages/backend/src/misc/is-duplicate-key-value-error.ts index 91e0a6b93d..04ff191e41 100644 --- a/packages/backend/src/misc/is-duplicate-key-value-error.ts +++ b/packages/backend/src/misc/is-duplicate-key-value-error.ts @@ -1,10 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { QueryFailedError } from 'typeorm'; - export function isDuplicateKeyValueError(e: unknown | Error): boolean { - return e instanceof QueryFailedError && e.driverError.code === '23505'; + return (e as any).message && (e as Error).message.startsWith('duplicate key value'); } diff --git a/packages/backend/src/misc/is-instance-muted.ts b/packages/backend/src/misc/is-instance-muted.ts index 35fe11849d..a74ba524e3 100644 --- a/packages/backend/src/misc/is-instance-muted.ts +++ b/packages/backend/src/misc/is-instance-muted.ts @@ -1,21 +1,15 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ +import { Packed } from './schema.js'; -import { MiNote } from '@/models/Note.js'; -import type { Packed } from './json-schema.js'; - -export function isInstanceMuted(note: Packed<'Note'> | MiNote, mutedInstances: Set): boolean { - if (mutedInstances.has(note.user?.host ?? '')) return true; - if (mutedInstances.has(note.reply?.user?.host ?? '')) return true; - if (mutedInstances.has(note.renote?.user?.host ?? '')) return true; +export function isInstanceMuted(note: Packed<'Note'>, mutedInstances: Set): boolean { + if (mutedInstances.has(note?.user?.host ?? '')) return true; + if (mutedInstances.has(note?.reply?.user?.host ?? '')) return true; + if (mutedInstances.has(note?.renote?.user?.host ?? '')) return true; return false; } export function isUserFromMutedInstance(notif: Packed<'Notification'>, mutedInstances: Set): boolean { - if (mutedInstances.has(notif.user?.host ?? '')) return true; + if (mutedInstances.has(notif?.user?.host ?? '')) return true; return false; } diff --git a/packages/backend/src/misc/is-mime-image.ts b/packages/backend/src/misc/is-mime-image.ts index 1a5a8cf0f4..8993ede33a 100644 --- a/packages/backend/src/misc/is-mime-image.ts +++ b/packages/backend/src/misc/is-mime-image.ts @@ -1,16 +1,8 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; const dictionary = { 'safe-file': FILE_TYPE_BROWSERSAFE, - 'sharp-convertible-image': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml'], - 'sharp-animation-convertible-image': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'], - 'sharp-convertible-image-with-bmp': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'], - 'sharp-animation-convertible-image-with-bmp': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'], + 'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/svg+xml'], }; export const isMimeImage = (mime: string, type: keyof typeof dictionary): boolean => dictionary[type].includes(mime); diff --git a/packages/backend/src/misc/is-native-token.ts b/packages/backend/src/misc/is-native-token.ts deleted file mode 100644 index 618e60b7d8..0000000000 --- a/packages/backend/src/misc/is-native-token.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// eslint-disable-next-line import/no-default-export -export default (token: string) => token.length === 16; diff --git a/packages/backend/src/misc/is-not-null.ts b/packages/backend/src/misc/is-not-null.ts deleted file mode 100644 index 153a9e51ef..0000000000 --- a/packages/backend/src/misc/is-not-null.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// we are using {} as "any non-nullish value" as expected -// eslint-disable-next-line @typescript-eslint/ban-types -export function isNotNull(input: T | undefined | null): input is T { - return input != null; -} diff --git a/packages/backend/src/misc/is-pure-renote.ts b/packages/backend/src/misc/is-pure-renote.ts deleted file mode 100644 index 994d981522..0000000000 --- a/packages/backend/src/misc/is-pure-renote.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { MiNote } from '@/models/Note.js'; - -export function isPureRenote(note: MiNote): note is MiNote & { renoteId: NonNullable } { - if (!note.renoteId) return false; - - if (note.text) return false; // it's quoted with text - if (note.fileIds.length !== 0) return false; // it's quoted with files - if (note.hasPoll) return false; // it's quoted with poll - return true; -} diff --git a/packages/backend/src/misc/is-quote.ts b/packages/backend/src/misc/is-quote.ts index db72d1d57a..779f548b03 100644 --- a/packages/backend/src/misc/is-quote.ts +++ b/packages/backend/src/misc/is-quote.ts @@ -1,12 +1,5 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ +import { Note } from '@/models/entities/note.js'; -import type { MiNote } from '@/models/Note.js'; - -// eslint-disable-next-line import/no-default-export -export default function(note: MiNote): boolean { - // sync with NoteCreateService.isQuote +export default function(note: Note): boolean { return note.renoteId != null && (note.text != null || note.hasPoll || (note.fileIds != null && note.fileIds.length > 0)); } diff --git a/packages/backend/src/misc/is-reply.ts b/packages/backend/src/misc/is-reply.ts deleted file mode 100644 index 964c2aa153..0000000000 --- a/packages/backend/src/misc/is-reply.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { MiUser } from '@/models/User.js'; - -export function isReply(note: any, viewerId?: MiUser['id'] | undefined | null): boolean { - return note.replyId && note.replyUserId !== note.userId && note.replyUserId !== viewerId; -} diff --git a/packages/backend/src/misc/is-user-related.ts b/packages/backend/src/misc/is-user-related.ts index 6efb1194d3..e6bbdb5d35 100644 --- a/packages/backend/src/misc/is-user-related.ts +++ b/packages/backend/src/misc/is-user-related.ts @@ -1,18 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export function isUserRelated(note: any, userIds: Set, ignoreAuthor = false): boolean { - if (userIds.has(note.userId) && !ignoreAuthor) { +export function isUserRelated(note: any, userIds: Set): boolean { + if (userIds.has(note.userId)) { return true; } - if (note.reply != null && note.reply.userId !== note.userId && userIds.has(note.reply.userId)) { + if (note.reply != null && userIds.has(note.reply.userId)) { return true; } - if (note.renote != null && note.renote.userId !== note.userId && userIds.has(note.renote.userId)) { + if (note.renote != null && userIds.has(note.renote.userId)) { return true; } diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts deleted file mode 100644 index 176978d35f..0000000000 --- a/packages/backend/src/misc/json-schema.ts +++ /dev/null @@ -1,201 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { - packedUserLiteSchema, - packedUserDetailedNotMeOnlySchema, - packedMeDetailedOnlySchema, - packedUserDetailedNotMeSchema, - packedMeDetailedSchema, - packedUserDetailedSchema, - packedUserSchema, -} from '@/models/json-schema/user.js'; -import { packedNoteSchema } from '@/models/json-schema/note.js'; -import { packedUserListSchema } from '@/models/json-schema/user-list.js'; -import { packedAppSchema } from '@/models/json-schema/app.js'; -import { packedNotificationSchema } from '@/models/json-schema/notification.js'; -import { packedDriveFileSchema } from '@/models/json-schema/drive-file.js'; -import { packedDriveFolderSchema } from '@/models/json-schema/drive-folder.js'; -import { packedFollowingSchema } from '@/models/json-schema/following.js'; -import { packedMutingSchema } from '@/models/json-schema/muting.js'; -import { packedRenoteMutingSchema } from '@/models/json-schema/renote-muting.js'; -import { packedBlockingSchema } from '@/models/json-schema/blocking.js'; -import { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js'; -import { packedHashtagSchema } from '@/models/json-schema/hashtag.js'; -import { packedInviteCodeSchema } from '@/models/json-schema/invite-code.js'; -import { packedPageSchema } from '@/models/json-schema/page.js'; -import { packedNoteFavoriteSchema } from '@/models/json-schema/note-favorite.js'; -import { packedChannelSchema } from '@/models/json-schema/channel.js'; -import { packedAntennaSchema } from '@/models/json-schema/antenna.js'; -import { packedClipSchema } from '@/models/json-schema/clip.js'; -import { packedFederationInstanceSchema } from '@/models/json-schema/federation-instance.js'; -import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; -import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js'; -import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js'; -import { packedFlashSchema } from '@/models/json-schema/flash.js'; -import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; -import { packedSigninSchema } from '@/models/json-schema/signin.js'; -import { packedRoleLiteSchema, packedRoleSchema } from '@/models/json-schema/role.js'; -import { packedAdSchema } from '@/models/json-schema/ad.js'; - -export const refs = { - UserLite: packedUserLiteSchema, - UserDetailedNotMeOnly: packedUserDetailedNotMeOnlySchema, - MeDetailedOnly: packedMeDetailedOnlySchema, - UserDetailedNotMe: packedUserDetailedNotMeSchema, - MeDetailed: packedMeDetailedSchema, - UserDetailed: packedUserDetailedSchema, - User: packedUserSchema, - - UserList: packedUserListSchema, - Ad: packedAdSchema, - Announcement: packedAnnouncementSchema, - App: packedAppSchema, - Note: packedNoteSchema, - NoteReaction: packedNoteReactionSchema, - NoteFavorite: packedNoteFavoriteSchema, - Notification: packedNotificationSchema, - DriveFile: packedDriveFileSchema, - DriveFolder: packedDriveFolderSchema, - Following: packedFollowingSchema, - Muting: packedMutingSchema, - RenoteMuting: packedRenoteMutingSchema, - Blocking: packedBlockingSchema, - Hashtag: packedHashtagSchema, - InviteCode: packedInviteCodeSchema, - Page: packedPageSchema, - Channel: packedChannelSchema, - QueueCount: packedQueueCountSchema, - Antenna: packedAntennaSchema, - Clip: packedClipSchema, - FederationInstance: packedFederationInstanceSchema, - GalleryPost: packedGalleryPostSchema, - EmojiSimple: packedEmojiSimpleSchema, - EmojiDetailed: packedEmojiDetailedSchema, - Flash: packedFlashSchema, - Signin: packedSigninSchema, - RoleLite: packedRoleLiteSchema, - Role: packedRoleSchema, -}; - -export type Packed = SchemaType; - -type TypeStringef = 'null' | 'boolean' | 'integer' | 'number' | 'string' | 'array' | 'object' | 'any'; -type StringDefToType = - T extends 'null' ? null : - T extends 'boolean' ? boolean : - T extends 'integer' ? number : - T extends 'number' ? number : - T extends 'string' ? string | Date : - T extends 'array' ? ReadonlyArray : - T extends 'object' ? Record : - any; - -// https://swagger.io/specification/?sbsearch=optional#schema-object -type OfSchema = { - readonly anyOf?: ReadonlyArray; - readonly oneOf?: ReadonlyArray; - readonly allOf?: ReadonlyArray; -} - -export interface Schema extends OfSchema { - readonly type?: TypeStringef; - readonly nullable?: boolean; - readonly optional?: boolean; - readonly items?: Schema; - readonly properties?: Obj; - readonly required?: ReadonlyArray, string>>; - readonly description?: string; - readonly example?: any; - readonly format?: string; - readonly ref?: keyof typeof refs; - readonly enum?: ReadonlyArray; - readonly default?: (this['type'] extends TypeStringef ? StringDefToType : any) | null; - readonly maxLength?: number; - readonly minLength?: number; - readonly maximum?: number; - readonly minimum?: number; - readonly pattern?: string; -} - -type RequiredPropertyNames = { - [K in keyof s]: - // K is not optional - s[K]['optional'] extends false ? K : - // K has default value - s[K]['default'] extends null | string | number | boolean | Record ? K : - never -}[keyof s]; - -export type Obj = Record; - -// https://github.com/misskey-dev/misskey/issues/8535 -// To avoid excessive stack depth error, -// deceive TypeScript with UnionToIntersection (or more precisely, `infer` expression within it). -export type ObjType> = - UnionToIntersection< - { -readonly [R in RequiredPropertyNames]-?: SchemaType } & - { -readonly [R in RequiredProps[number]]-?: SchemaType } & - { -readonly [P in keyof s]?: SchemaType } - >; - -type NullOrUndefined

= - | (p['nullable'] extends true ? null : never) - | (p['optional'] extends true ? undefined : never) - | T; - -// https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection -// Get intersection from union -type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; -type PartialIntersection = Partial>; - -// https://github.com/misskey-dev/misskey/pull/8144#discussion_r785287552 -// To get union, we use `Foo extends any ? Hoge : never` -type UnionSchemaType = X extends any ? SchemaType : never; -//type UnionObjectSchemaType = X extends any ? ObjectSchemaType : never; -type UnionObjType = a[number]> = X extends any ? ObjType : never; -type ArrayUnion = T extends any ? Array : never; - -type ObjectSchemaTypeDef

= - p['ref'] extends keyof typeof refs ? Packed : - p['properties'] extends NonNullable ? - p['anyOf'] extends ReadonlyArray ? p['anyOf'][number]['required'] extends ReadonlyArray ? - UnionObjType> & ObjType> - : never - : ObjType> - : - p['anyOf'] extends ReadonlyArray ? never : // see CONTRIBUTING.md - p['allOf'] extends ReadonlyArray ? UnionToIntersection> : - any - -type ObjectSchemaType

= NullOrUndefined>; - -export type SchemaTypeDef

= - p['type'] extends 'null' ? null : - p['type'] extends 'integer' ? number : - p['type'] extends 'number' ? number : - p['type'] extends 'string' ? ( - p['enum'] extends readonly (string | null)[] ? - p['enum'][number] : - p['format'] extends 'date-time' ? string : // Dateにする?? - string - ) : - p['type'] extends 'boolean' ? boolean : - p['type'] extends 'object' ? ObjectSchemaTypeDef

: - p['type'] extends 'array' ? ( - p['items'] extends OfSchema ? ( - p['items']['anyOf'] extends ReadonlyArray ? UnionSchemaType>[] : - p['items']['oneOf'] extends ReadonlyArray ? ArrayUnion>> : - p['items']['allOf'] extends ReadonlyArray ? UnionToIntersection>>[] : - never - ) : - p['items'] extends NonNullable ? SchemaTypeDef[] : - any[] - ) : - p['anyOf'] extends ReadonlyArray ? UnionSchemaType & PartialIntersection> : - p['oneOf'] extends ReadonlyArray ? UnionSchemaType : - any; - -export type SchemaType

= NullOrUndefined>; diff --git a/packages/backend/src/misc/keypair-store.ts b/packages/backend/src/misc/keypair-store.ts new file mode 100644 index 0000000000..1183b9a781 --- /dev/null +++ b/packages/backend/src/misc/keypair-store.ts @@ -0,0 +1,10 @@ +import { UserKeypairs } from '@/models/index.js'; +import { User } from '@/models/entities/user.js'; +import { UserKeypair } from '@/models/entities/user-keypair.js'; +import { Cache } from './cache.js'; + +const cache = new Cache(Infinity); + +export async function getUserKeypair(userId: User['id']): Promise { + return await cache.fetch(userId, () => UserKeypairs.findOneByOrFail({ userId: userId })); +} diff --git a/packages/backend/src/misc/langmap.ts b/packages/backend/src/misc/langmap.ts index 9e287677df..5ee85e6c09 100644 --- a/packages/backend/src/misc/langmap.ts +++ b/packages/backend/src/misc/langmap.ts @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - // TODO: sharedに置いてフロントエンドのと統合したい export const langmap = { 'ach': { diff --git a/packages/backend/src/misc/loader.ts b/packages/backend/src/misc/loader.ts deleted file mode 100644 index 25f7b54d31..0000000000 --- a/packages/backend/src/misc/loader.ts +++ /dev/null @@ -1,52 +0,0 @@ -export type FetchFunction = (key: K) => Promise; - -type ResolveReject = Parameters>[0]>; - -type ResolverPair = { - resolve: ResolveReject[0]; - reject: ResolveReject[1]; -}; - -export class DebounceLoader { - private resolverMap = new Map>(); - private promiseMap = new Map>(); - private resolvedPromise = Promise.resolve(); - constructor(private loadFn: FetchFunction) {} - - public load(key: K): Promise { - const promise = this.promiseMap.get(key); - if (typeof promise !== 'undefined') { - return promise; - } - - const isFirst = this.promiseMap.size === 0; - const newPromise = new Promise((resolve, reject) => { - this.resolverMap.set(key, { resolve, reject }); - }); - this.promiseMap.set(key, newPromise); - - if (isFirst) { - this.enqueueDebouncedLoadJob(); - } - - return newPromise; - } - - private runDebouncedLoad(): void { - const resolvers = [...this.resolverMap]; - this.resolverMap.clear(); - this.promiseMap.clear(); - - for (const [key, { resolve, reject }] of resolvers) { - this.loadFn(key).then(resolve, reject); - } - } - - private enqueueDebouncedLoadJob(): void { - this.resolvedPromise.then(() => { - process.nextTick(() => { - this.runDebouncedLoad(); - }); - }); - } -} diff --git a/packages/backend/src/misc/normalize-for-search.ts b/packages/backend/src/misc/normalize-for-search.ts index 9d96f4169d..200540566e 100644 --- a/packages/backend/src/misc/normalize-for-search.ts +++ b/packages/backend/src/misc/normalize-for-search.ts @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - export function normalizeForSearch(tag: string): string { // ref. // - https://analytics-note.xyz/programming/unicode-normalization-forms/ diff --git a/packages/backend/src/misc/nyaize.ts b/packages/backend/src/misc/nyaize.ts new file mode 100644 index 0000000000..500d1db2cb --- /dev/null +++ b/packages/backend/src/misc/nyaize.ts @@ -0,0 +1,15 @@ +export function nyaize(text: string): string { + return text + // ja-JP + .replace(/な/g, 'にゃ').replace(/ナ/g, 'ニャ').replace(/ナ/g, 'ニャ') + // en-US + .replace(/(?<=n)a/gi, x => x === 'A' ? 'YA' : 'ya') + .replace(/(?<=morn)ing/gi, x => x === 'ING' ? 'YAN' : 'yan') + .replace(/(?<=every)one/gi, x => x === 'ONE' ? 'NYAN' : 'nyan') + // ko-KR + .replace(/[나-낳]/g, match => String.fromCharCode( + match.charCodeAt(0)! + '냐'.charCodeAt(0) - '나'.charCodeAt(0) + )) + .replace(/(다$)|(다(?=\.))|(다(?= ))|(다(?=!))|(다(?=\?))/gm, '다냥') + .replace(/(야(?=\?))|(야$)|(야(?= ))/gm, '냥'); +} diff --git a/packages/backend/src/misc/populate-emojis.ts b/packages/backend/src/misc/populate-emojis.ts new file mode 100644 index 0000000000..6a185d09f6 --- /dev/null +++ b/packages/backend/src/misc/populate-emojis.ts @@ -0,0 +1,125 @@ +import { In, IsNull } from 'typeorm'; +import { Emojis } from '@/models/index.js'; +import { Emoji } from '@/models/entities/emoji.js'; +import { Note } from '@/models/entities/note.js'; +import { Cache } from './cache.js'; +import { isSelfHost, toPunyNullable } from './convert-host.js'; +import { decodeReaction } from './reaction-lib.js'; +import config from '@/config/index.js'; +import { query } from '@/prelude/url.js'; + +const cache = new Cache(1000 * 60 * 60 * 12); + +/** + * 添付用絵文字情報 + */ +type PopulatedEmoji = { + name: string; + url: string; +}; + +function normalizeHost(src: string | undefined, noteUserHost: string | null): string | null { + // クエリに使うホスト + let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ) + : src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない) + : isSelfHost(src) ? null // 自ホスト指定 + : (src || noteUserHost); // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない) + + host = toPunyNullable(host); + + return host; +} + +function parseEmojiStr(emojiName: string, noteUserHost: string | null) { + const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/); + if (!match) return { name: null, host: null }; + + const name = match[1]; + + // ホスト正規化 + const host = toPunyNullable(normalizeHost(match[2], noteUserHost)); + + return { name, host }; +} + +/** + * 添付用絵文字情報を解決する + * @param emojiName ノートやユーザープロフィールに添付された、またはリアクションのカスタム絵文字名 (:は含めない, リアクションでローカルホストの場合は@.を付ける (これはdecodeReactionで可能)) + * @param noteUserHost ノートやユーザープロフィールの所有者のホスト + * @returns 絵文字情報, nullは未マッチを意味する + */ +export async function populateEmoji(emojiName: string, noteUserHost: string | null): Promise { + const { name, host } = parseEmojiStr(emojiName, noteUserHost); + if (name == null) return null; + + const queryOrNull = async () => (await Emojis.findOneBy({ + name, + host: host ?? IsNull(), + })) || null; + + const emoji = await cache.fetch(`${name} ${host}`, queryOrNull); + + if (emoji == null) return null; + + const isLocal = emoji.host == null; + const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため + const url = isLocal ? emojiUrl : `${config.url}/proxy/${encodeURIComponent((new URL(emojiUrl)).pathname)}?${query({ url: emojiUrl })}`; + + return { + name: emojiName, + url, + }; +} + +/** + * 複数の添付用絵文字情報を解決する (キャシュ付き, 存在しないものは結果から除外される) + */ +export async function populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise { + const emojis = await Promise.all(emojiNames.map(x => populateEmoji(x, noteUserHost))); + return emojis.filter((x): x is PopulatedEmoji => x != null); +} + +export function aggregateNoteEmojis(notes: Note[]) { + let emojis: { name: string | null; host: string | null; }[] = []; + for (const note of notes) { + emojis = emojis.concat(note.emojis + .map(e => parseEmojiStr(e, note.userHost))); + if (note.renote) { + emojis = emojis.concat(note.renote.emojis + .map(e => parseEmojiStr(e, note.renote!.userHost))); + if (note.renote.user) { + emojis = emojis.concat(note.renote.user.emojis + .map(e => parseEmojiStr(e, note.renote!.userHost))); + } + } + const customReactions = Object.keys(note.reactions).map(x => decodeReaction(x)).filter(x => x.name != null) as typeof emojis; + emojis = emojis.concat(customReactions); + if (note.user) { + emojis = emojis.concat(note.user.emojis + .map(e => parseEmojiStr(e, note.userHost))); + } + } + return emojis.filter(x => x.name != null) as { name: string; host: string | null; }[]; +} + +/** + * 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します + */ +export async function prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise { + const notCachedEmojis = emojis.filter(emoji => cache.get(`${emoji.name} ${emoji.host}`) == null); + const emojisQuery: any[] = []; + const hosts = new Set(notCachedEmojis.map(e => e.host)); + for (const host of hosts) { + emojisQuery.push({ + name: In(notCachedEmojis.filter(e => e.host === host).map(e => e.name)), + host: host ?? IsNull(), + }); + } + const _emojis = emojisQuery.length > 0 ? await Emojis.find({ + where: emojisQuery, + select: ['name', 'host', 'originalUrl', 'publicUrl'], + }) : []; + for (const emoji of _emojis) { + cache.set(`${emoji.name} ${emoji.host}`, emoji); + } +} diff --git a/packages/backend/src/misc/prelude/array.ts b/packages/backend/src/misc/prelude/array.ts deleted file mode 100644 index 8438b64805..0000000000 --- a/packages/backend/src/misc/prelude/array.ts +++ /dev/null @@ -1,148 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { EndoRelation, Predicate } from './relation.js'; - -/** - * Count the number of elements that satisfy the predicate - */ - -export function countIf(f: Predicate, xs: T[]): number { - return xs.filter(f).length; -} - -/** - * Count the number of elements that is equal to the element - */ -export function count(a: T, xs: T[]): number { - return countIf(x => x === a, xs); -} - -/** - * Concatenate an array of arrays - */ -export function concat(xss: T[][]): T[] { - return ([] as T[]).concat(...xss); -} - -/** - * Intersperse the element between the elements of the array - * @param sep The element to be interspersed - */ -export function intersperse(sep: T, xs: T[]): T[] { - return concat(xs.map(x => [sep, x])).slice(1); -} - -/** - * Returns the array of elements that is not equal to the element - */ -export function erase(a: T, xs: T[]): T[] { - return xs.filter(x => x !== a); -} - -/** - * Finds the array of all elements in the first array not contained in the second array. - * The order of result values are determined by the first array. - */ -export function difference(xs: T[], ys: T[]): T[] { - return xs.filter(x => !ys.includes(x)); -} - -/** - * Remove all but the first element from every group of equivalent elements - */ -export function unique(xs: T[]): T[] { - return [...new Set(xs)]; -} - -export function sum(xs: number[]): number { - return xs.reduce((a, b) => a + b, 0); -} - -export function maximum(xs: number[]): number { - return Math.max(...xs); -} - -/** - * Splits an array based on the equivalence relation. - * The concatenation of the result is equal to the argument. - */ -export function groupBy(f: EndoRelation, xs: T[]): T[][] { - const groups = [] as T[][]; - for (const x of xs) { - const lastGroup = groups.at(-1); - if (lastGroup !== undefined && f(lastGroup[0], x)) { - lastGroup.push(x); - } else { - groups.push([x]); - } - } - return groups; -} - -/** - * Splits an array based on the equivalence relation induced by the function. - * The concatenation of the result is equal to the argument. - */ -export function groupOn(f: (x: T) => S, xs: T[]): T[][] { - return groupBy((a, b) => f(a) === f(b), xs); -} - -export function groupByX(collections: T[], keySelector: (x: T) => string) { - return collections.reduce((obj: Record, item: T) => { - const key = keySelector(item); - if (!Object.prototype.hasOwnProperty.call(obj, key)) { - obj[key] = []; - } - - obj[key].push(item); - - return obj; - }, {}); -} - -/** - * Compare two arrays by lexicographical order - */ -export function lessThan(xs: number[], ys: number[]): boolean { - for (let i = 0; i < Math.min(xs.length, ys.length); i++) { - if (xs[i] < ys[i]) return true; - if (xs[i] > ys[i]) return false; - } - return xs.length < ys.length; -} - -/** - * Returns the longest prefix of elements that satisfy the predicate - */ -export function takeWhile(f: Predicate, xs: T[]): T[] { - const ys = []; - for (const x of xs) { - if (f(x)) { - ys.push(x); - } else { - break; - } - } - return ys; -} - -export function cumulativeSum(xs: number[]): number[] { - const ys = Array.from(xs); // deep copy - for (let i = 1; i < ys.length; i++) ys[i] += ys[i - 1]; - return ys; -} - -export function toArray(x: T | T[] | undefined): T[] { - return Array.isArray(x) ? x : x != null ? [x] : []; -} - -export function toSingle(x: T | T[] | undefined): T | undefined { - return Array.isArray(x) ? x[0] : x; -} - -export function toSingleLast(x: T | T[] | undefined): T | undefined { - return Array.isArray(x) ? x.at(-1) : x; -} diff --git a/packages/backend/src/misc/prelude/math.ts b/packages/backend/src/misc/prelude/math.ts deleted file mode 100644 index 87b5017d09..0000000000 --- a/packages/backend/src/misc/prelude/math.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export function gcd(a: number, b: number): number { - return b === 0 ? a : gcd(b, a % b); -} diff --git a/packages/backend/src/misc/prelude/maybe.ts b/packages/backend/src/misc/prelude/maybe.ts deleted file mode 100644 index 17c100b80d..0000000000 --- a/packages/backend/src/misc/prelude/maybe.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export interface IMaybe { - isJust(): this is IJust; -} - -export interface IJust extends IMaybe { - get(): T; -} - -export function just(value: T): IJust { - return { - isJust: () => true, - get: () => value, - }; -} - -export function nothing(): IMaybe { - return { - isJust: () => false, - }; -} diff --git a/packages/backend/src/misc/prelude/relation.ts b/packages/backend/src/misc/prelude/relation.ts deleted file mode 100644 index 3456c1a0bc..0000000000 --- a/packages/backend/src/misc/prelude/relation.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export type Predicate = (a: T) => boolean; - -export type Relation = (a: T, b: U) => boolean; - -export type EndoRelation = Relation; diff --git a/packages/backend/src/misc/prelude/string.ts b/packages/backend/src/misc/prelude/string.ts deleted file mode 100644 index a727ab7f1d..0000000000 --- a/packages/backend/src/misc/prelude/string.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export function concat(xs: string[]): string { - return xs.join(''); -} - -export function capitalize(s: string): string { - return toUpperCase(s.charAt(0)) + toLowerCase(s.slice(1)); -} - -export function toUpperCase(s: string): string { - return s.toUpperCase(); -} - -export function toLowerCase(s: string): string { - return s.toLowerCase(); -} diff --git a/packages/backend/src/misc/prelude/symbol.ts b/packages/backend/src/misc/prelude/symbol.ts deleted file mode 100644 index 91c058a845..0000000000 --- a/packages/backend/src/misc/prelude/symbol.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const fallback = Symbol('fallback'); diff --git a/packages/backend/src/misc/prelude/time.ts b/packages/backend/src/misc/prelude/time.ts deleted file mode 100644 index 4479db1081..0000000000 --- a/packages/backend/src/misc/prelude/time.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -const dateTimeIntervals = { - 'day': 86400000, - 'hour': 3600000, - 'ms': 1, -}; - -export function dateUTC(time: number[]): Date { - const d = - time.length === 2 ? Date.UTC(time[0], time[1]) - : time.length === 3 ? Date.UTC(time[0], time[1], time[2]) - : time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3]) - : time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4]) - : time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5]) - : time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6]) - : null; - - if (!d) throw new Error('wrong number of arguments'); - - return new Date(d); -} - -export function isTimeSame(a: Date, b: Date): boolean { - return a.getTime() === b.getTime(); -} - -export function isTimeBefore(a: Date, b: Date): boolean { - return (a.getTime() - b.getTime()) < 0; -} - -export function isTimeAfter(a: Date, b: Date): boolean { - return (a.getTime() - b.getTime()) > 0; -} - -export function addTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date { - return new Date(x.getTime() + (value * dateTimeIntervals[span])); -} - -export function subtractTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date { - return new Date(x.getTime() - (value * dateTimeIntervals[span])); -} diff --git a/packages/backend/src/misc/prelude/url.ts b/packages/backend/src/misc/prelude/url.ts deleted file mode 100644 index 633eb98218..0000000000 --- a/packages/backend/src/misc/prelude/url.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -/* objを検査して - * 1. 配列に何も入っていない時はクエリを付けない - * 2. プロパティがundefinedの時はクエリを付けない - * (new URLSearchParams(obj)ではそこまで丁寧なことをしてくれない) - */ -export function query(obj: Record): string { - const params = Object.entries(obj) - .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) - .reduce((a, [k, v]) => (a[k] = v, a), {} as Record); - - return Object.entries(params) - .map((e) => `${e[0]}=${encodeURIComponent(e[1])}`) - .join('&'); -} - -export function appendQuery(url: string, query: string): string { - return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`; -} diff --git a/packages/backend/src/misc/reaction-lib.ts b/packages/backend/src/misc/reaction-lib.ts new file mode 100644 index 0000000000..fefc2781f3 --- /dev/null +++ b/packages/backend/src/misc/reaction-lib.ts @@ -0,0 +1,131 @@ +/* eslint-disable key-spacing */ +import { emojiRegex } from './emoji-regex.js'; +import { fetchMeta } from './fetch-meta.js'; +import { Emojis } from '@/models/index.js'; +import { toPunyNullable } from './convert-host.js'; +import { IsNull } from 'typeorm'; + +const legacies: Record = { + 'like': '👍', + 'love': '❤', // ここに記述する場合は異体字セレクタを入れない + 'laugh': '😆', + 'hmm': '🤔', + 'surprise': '😮', + 'congrats': '🎉', + 'angry': '💢', + 'confused': '😥', + 'rip': '😇', + 'pudding': '🍮', + 'star': '⭐', +}; + +export async function getFallbackReaction(): Promise { + const meta = await fetchMeta(); + return meta.useStarForReactionFallback ? '⭐' : '👍'; +} + +export function convertLegacyReactions(reactions: Record) { + const _reactions = {} as Record; + + for (const reaction of Object.keys(reactions)) { + if (reactions[reaction] <= 0) continue; + + if (Object.keys(legacies).includes(reaction)) { + if (_reactions[legacies[reaction]]) { + _reactions[legacies[reaction]] += reactions[reaction]; + } else { + _reactions[legacies[reaction]] = reactions[reaction]; + } + } else { + if (_reactions[reaction]) { + _reactions[reaction] += reactions[reaction]; + } else { + _reactions[reaction] = reactions[reaction]; + } + } + } + + const _reactions2 = {} as Record; + + for (const reaction of Object.keys(_reactions)) { + _reactions2[decodeReaction(reaction).reaction] = _reactions[reaction]; + } + + return _reactions2; +} + +export async function toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise { + if (reaction == null) return await getFallbackReaction(); + + reacterHost = toPunyNullable(reacterHost); + + // 文字列タイプのリアクションを絵文字に変換 + if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; + + // Unicode絵文字 + const match = emojiRegex.exec(reaction); + if (match) { + // 合字を含む1つの絵文字 + const unicode = match[0]; + + // 異体字セレクタ除去 + return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, ''); + } + + const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/); + if (custom) { + const name = custom[1]; + const emoji = await Emojis.findOneBy({ + host: reacterHost ?? IsNull(), + name, + }); + + if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`; + } + + return await getFallbackReaction(); +} + +type DecodedReaction = { + /** + * リアクション名 (Unicode Emoji or ':name@hostname' or ':name@.') + */ + reaction: string; + + /** + * name (カスタム絵文字の場合name, Emojiクエリに使う) + */ + name?: string; + + /** + * host (カスタム絵文字の場合host, Emojiクエリに使う) + */ + host?: string | null; +}; + +export function decodeReaction(str: string): DecodedReaction { + const custom = str.match(/^:([\w+-]+)(?:@([\w.-]+))?:$/); + + if (custom) { + const name = custom[1]; + const host = custom[2] || null; + + return { + reaction: `:${name}@${host || '.'}:`, // ローカル分は@以降を省略するのではなく.にする + name, + host, + }; + } + + return { + reaction: str, + name: undefined, + host: undefined, + }; +} + +export function convertLegacyReaction(reaction: string): string { + reaction = decodeReaction(reaction).reaction; + if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; + return reaction; +} diff --git a/packages/backend/src/misc/reset-db.ts b/packages/backend/src/misc/reset-db.ts deleted file mode 100644 index a571460a59..0000000000 --- a/packages/backend/src/misc/reset-db.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { DataSource } from 'typeorm'; - -export async function resetDb(db: DataSource) { - const reset = async () => { - const tables = await db.query(`SELECT relname AS "table" - FROM pg_class C LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) - WHERE nspname NOT IN ('pg_catalog', 'information_schema') - AND C.relkind = 'r' - AND nspname !~ '^pg_toast';`); - for (const table of tables) { - await db.query(`DELETE FROM "${table.table}" CASCADE`); - } - }; - - for (let i = 1; i <= 3; i++) { - try { - await reset(); - } catch (e) { - if (i === 3) { - throw e; - } else { - await new Promise(resolve => setTimeout(resolve, 1000)); - continue; - } - } - break; - } -} diff --git a/packages/backend/src/misc/safe-for-sql.ts b/packages/backend/src/misc/safe-for-sql.ts index d7bdd0a81c..02eb7f0a26 100644 --- a/packages/backend/src/misc/safe-for-sql.ts +++ b/packages/backend/src/misc/safe-for-sql.ts @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - export function safeForSql(text: string): boolean { return !/[\0\x08\x09\x1a\n\r"'\\\%]/g.test(text); } diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/schema.ts new file mode 100644 index 0000000000..fdecc278d4 --- /dev/null +++ b/packages/backend/src/misc/schema.ts @@ -0,0 +1,172 @@ +import { + packedUserLiteSchema, + packedUserDetailedNotMeOnlySchema, + packedMeDetailedOnlySchema, + packedUserDetailedNotMeSchema, + packedMeDetailedSchema, + packedUserDetailedSchema, + packedUserSchema, +} from '@/models/schema/user.js'; +import { packedNoteSchema } from '@/models/schema/note.js'; +import { packedUserListSchema } from '@/models/schema/user-list.js'; +import { packedAppSchema } from '@/models/schema/app.js'; +import { packedMessagingMessageSchema } from '@/models/schema/messaging-message.js'; +import { packedNotificationSchema } from '@/models/schema/notification.js'; +import { packedDriveFileSchema } from '@/models/schema/drive-file.js'; +import { packedDriveFolderSchema } from '@/models/schema/drive-folder.js'; +import { packedFollowingSchema } from '@/models/schema/following.js'; +import { packedMutingSchema } from '@/models/schema/muting.js'; +import { packedBlockingSchema } from '@/models/schema/blocking.js'; +import { packedNoteReactionSchema } from '@/models/schema/note-reaction.js'; +import { packedHashtagSchema } from '@/models/schema/hashtag.js'; +import { packedPageSchema } from '@/models/schema/page.js'; +import { packedUserGroupSchema } from '@/models/schema/user-group.js'; +import { packedNoteFavoriteSchema } from '@/models/schema/note-favorite.js'; +import { packedChannelSchema } from '@/models/schema/channel.js'; +import { packedAntennaSchema } from '@/models/schema/antenna.js'; +import { packedClipSchema } from '@/models/schema/clip.js'; +import { packedFederationInstanceSchema } from '@/models/schema/federation-instance.js'; +import { packedQueueCountSchema } from '@/models/schema/queue.js'; +import { packedGalleryPostSchema } from '@/models/schema/gallery-post.js'; +import { packedEmojiSchema } from '@/models/schema/emoji.js'; + +export const refs = { + UserLite: packedUserLiteSchema, + UserDetailedNotMeOnly: packedUserDetailedNotMeOnlySchema, + MeDetailedOnly: packedMeDetailedOnlySchema, + UserDetailedNotMe: packedUserDetailedNotMeSchema, + MeDetailed: packedMeDetailedSchema, + UserDetailed: packedUserDetailedSchema, + User: packedUserSchema, + + UserList: packedUserListSchema, + UserGroup: packedUserGroupSchema, + App: packedAppSchema, + MessagingMessage: packedMessagingMessageSchema, + Note: packedNoteSchema, + NoteReaction: packedNoteReactionSchema, + NoteFavorite: packedNoteFavoriteSchema, + Notification: packedNotificationSchema, + DriveFile: packedDriveFileSchema, + DriveFolder: packedDriveFolderSchema, + Following: packedFollowingSchema, + Muting: packedMutingSchema, + Blocking: packedBlockingSchema, + Hashtag: packedHashtagSchema, + Page: packedPageSchema, + Channel: packedChannelSchema, + QueueCount: packedQueueCountSchema, + Antenna: packedAntennaSchema, + Clip: packedClipSchema, + FederationInstance: packedFederationInstanceSchema, + GalleryPost: packedGalleryPostSchema, + Emoji: packedEmojiSchema, +}; + +export type Packed = SchemaType; + +type TypeStringef = 'null' | 'boolean' | 'integer' | 'number' | 'string' | 'array' | 'object' | 'any'; +type StringDefToType = + T extends 'null' ? null : + T extends 'boolean' ? boolean : + T extends 'integer' ? number : + T extends 'number' ? number : + T extends 'string' ? string | Date : + T extends 'array' ? ReadonlyArray : + T extends 'object' ? Record : + any; + +// https://swagger.io/specification/?sbsearch=optional#schema-object +type OfSchema = { + readonly anyOf?: ReadonlyArray; + readonly oneOf?: ReadonlyArray; + readonly allOf?: ReadonlyArray; +} + +export interface Schema extends OfSchema { + readonly type?: TypeStringef; + readonly nullable?: boolean; + readonly optional?: boolean; + readonly items?: Schema; + readonly properties?: Obj; + readonly required?: ReadonlyArray, string>>; + readonly description?: string; + readonly example?: any; + readonly format?: string; + readonly ref?: keyof typeof refs; + readonly enum?: ReadonlyArray; + readonly default?: (this['type'] extends TypeStringef ? StringDefToType : any) | null; + readonly maxLength?: number; + readonly minLength?: number; + readonly maximum?: number; + readonly minimum?: number; + readonly pattern?: string; +} + +type RequiredPropertyNames = { + [K in keyof s]: + // K is not optional + s[K]['optional'] extends false ? K : + // K has default value + s[K]['default'] extends null | string | number | boolean | Record ? K : + never +}[keyof s]; + +export type Obj = Record; + +// https://github.com/misskey-dev/misskey/issues/8535 +// To avoid excessive stack depth error, +// deceive TypeScript with UnionToIntersection (or more precisely, `infer` expression within it). +export type ObjType = + UnionToIntersection< + { -readonly [R in RequiredPropertyNames]-?: SchemaType } & + { -readonly [R in RequiredProps]-?: SchemaType } & + { -readonly [P in keyof s]?: SchemaType } + >; + +type NullOrUndefined

= + | (p['nullable'] extends true ? null : never) + | (p['optional'] extends true ? undefined : never) + | T; + +// https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection +// Get intersection from union +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; + +// https://github.com/misskey-dev/misskey/pull/8144#discussion_r785287552 +// To get union, we use `Foo extends any ? Hoge : never` +type UnionSchemaType = X extends any ? SchemaType : never; +type ArrayUnion = T extends any ? Array : never; + +export type SchemaTypeDef

= + p['type'] extends 'null' ? null : + p['type'] extends 'integer' ? number : + p['type'] extends 'number' ? number : + p['type'] extends 'string' ? ( + p['enum'] extends readonly string[] ? + p['enum'][number] : + p['format'] extends 'date-time' ? string : // Dateにする?? + string + ) : + p['type'] extends 'boolean' ? boolean : + p['type'] extends 'object' ? ( + p['ref'] extends keyof typeof refs ? Packed : + p['properties'] extends NonNullable ? ObjType[number]> : + p['anyOf'] extends ReadonlyArray ? UnionSchemaType & Partial>> : + p['allOf'] extends ReadonlyArray ? UnionToIntersection> : + any + ) : + p['type'] extends 'array' ? ( + p['items'] extends OfSchema ? ( + p['items']['anyOf'] extends ReadonlyArray ? UnionSchemaType>[] : + p['items']['oneOf'] extends ReadonlyArray ? ArrayUnion>> : + p['items']['allOf'] extends ReadonlyArray ? UnionToIntersection>>[] : + never + ) : + p['items'] extends NonNullable ? SchemaTypeDef[] : + any[] + ) : + p['oneOf'] extends ReadonlyArray ? UnionSchemaType : + any; + +export type SchemaType

= NullOrUndefined>; diff --git a/packages/backend/src/misc/secure-rndstr.ts b/packages/backend/src/misc/secure-rndstr.ts index 01368d808a..8d4fcb1ba9 100644 --- a/packages/backend/src/misc/secure-rndstr.ts +++ b/packages/backend/src/misc/secure-rndstr.ts @@ -1,14 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import * as crypto from 'node:crypto'; -export const L_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz'; +const L_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz'; const LU_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; -export function secureRndstr(length = 32, { chars = LU_CHARS } = {}): string { +export function secureRndstr(length = 32, useLU = true): string { + const chars = useLU ? LU_CHARS : L_CHARS; const chars_len = chars.length; let str = ''; diff --git a/packages/backend/src/misc/show-machine-info.ts b/packages/backend/src/misc/show-machine-info.ts index ed0fa651f1..bc71cfbe96 100644 --- a/packages/backend/src/misc/show-machine-info.ts +++ b/packages/backend/src/misc/show-machine-info.ts @@ -1,11 +1,6 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import * as os from 'node:os'; import sysUtils from 'systeminformation'; -import type Logger from '@/logger.js'; +import Logger from '@/services/logger.js'; export async function showMachineInfo(parentLogger: Logger) { const logger = parentLogger.createSubLogger('machine'); diff --git a/packages/backend/src/misc/sql-like-escape.ts b/packages/backend/src/misc/sql-like-escape.ts deleted file mode 100644 index 85cc7405e1..0000000000 --- a/packages/backend/src/misc/sql-like-escape.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export function sqlLikeEscape(s: string) { - return s.replace(/([%_])/g, '\\$1'); -} diff --git a/packages/backend/src/misc/status-error.ts b/packages/backend/src/misc/status-error.ts deleted file mode 100644 index 4285685d24..0000000000 --- a/packages/backend/src/misc/status-error.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export class StatusError extends Error { - public statusCode: number; - public statusMessage?: string; - public isClientError: boolean; - - constructor(message: string, statusCode: number, statusMessage?: string) { - super(message); - this.name = 'StatusError'; - this.statusCode = statusCode; - this.statusMessage = statusMessage; - this.isClientError = typeof this.statusCode === 'number' && this.statusCode >= 400 && this.statusCode < 500; - } -} diff --git a/packages/backend/src/misc/truncate.ts b/packages/backend/src/misc/truncate.ts index b65202fbd4..cb120331a1 100644 --- a/packages/backend/src/misc/truncate.ts +++ b/packages/backend/src/misc/truncate.ts @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { substring } from 'stringz'; export function truncate(input: string, size: number): string; diff --git a/packages/backend/src/misc/webhook-cache.ts b/packages/backend/src/misc/webhook-cache.ts new file mode 100644 index 0000000000..4bd2333661 --- /dev/null +++ b/packages/backend/src/misc/webhook-cache.ts @@ -0,0 +1,49 @@ +import { Webhooks } from '@/models/index.js'; +import { Webhook } from '@/models/entities/webhook.js'; +import { subsdcriber } from '../db/redis.js'; + +let webhooksFetched = false; +let webhooks: Webhook[] = []; + +export async function getActiveWebhooks() { + if (!webhooksFetched) { + webhooks = await Webhooks.findBy({ + active: true, + }); + webhooksFetched = true; + } + + return webhooks; +} + +subsdcriber.on('message', async (_, data) => { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message; + switch (type) { + case 'webhookCreated': + if (body.active) { + webhooks.push(body); + } + break; + case 'webhookUpdated': + if (body.active) { + const i = webhooks.findIndex(a => a.id === body.id); + if (i > -1) { + webhooks[i] = body; + } else { + webhooks.push(body); + } + } else { + webhooks = webhooks.filter(a => a.id !== body.id); + } + break; + case 'webhookDeleted': + webhooks = webhooks.filter(a => a.id !== body.id); + break; + default: + break; + } + } +}); diff --git a/packages/backend/src/models/AbuseUserReport.ts b/packages/backend/src/models/AbuseUserReport.ts deleted file mode 100644 index 593c44f66b..0000000000 --- a/packages/backend/src/models/AbuseUserReport.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('abuse_user_report') -export class MiAbuseUserReport { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column(id()) - public targetUserId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public targetUser: MiUser | null; - - @Index() - @Column(id()) - public reporterId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public reporter: MiUser | null; - - @Column({ - ...id(), - nullable: true, - }) - public assigneeId: MiUser['id'] | null; - - @ManyToOne(type => MiUser, { - onDelete: 'SET NULL', - }) - @JoinColumn() - public assignee: MiUser | null; - - @Index() - @Column('boolean', { - default: false, - }) - public resolved: boolean; - - @Column('boolean', { - default: false, - }) - public forwarded: boolean; - - @Column('varchar', { - length: 2048, - }) - public comment: string; - - //#region Denormalized fields - @Index() - @Column('varchar', { - length: 128, nullable: true, - comment: '[Denormalized]', - }) - public targetUserHost: string | null; - - @Index() - @Column('varchar', { - length: 128, nullable: true, - comment: '[Denormalized]', - }) - public reporterHost: string | null; - //#endregion -} diff --git a/packages/backend/src/models/AccessToken.ts b/packages/backend/src/models/AccessToken.ts deleted file mode 100644 index 452711eb8c..0000000000 --- a/packages/backend/src/models/AccessToken.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, PrimaryColumn, Index, Column, ManyToOne, JoinColumn } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiApp } from './App.js'; - -@Entity('access_token') -export class MiAccessToken { - @PrimaryColumn(id()) - public id: string; - - @Column('timestamp with time zone', { - nullable: true, - }) - public lastUsedAt: Date | null; - - @Index() - @Column('varchar', { - length: 128, - }) - public token: string; - - @Index() - @Column('varchar', { - length: 128, - nullable: true, - }) - public session: string | null; - - @Index() - @Column('varchar', { - length: 128, - }) - public hash: string; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column({ - ...id(), - nullable: true, - }) - public appId: MiApp['id'] | null; - - @ManyToOne(type => MiApp, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public app: MiApp | null; - - @Column('varchar', { - length: 128, - nullable: true, - }) - public name: string | null; - - @Column('varchar', { - length: 512, - nullable: true, - }) - public description: string | null; - - @Column('varchar', { - length: 512, - nullable: true, - }) - public iconUrl: string | null; - - @Column('varchar', { - length: 64, array: true, - default: '{}', - }) - public permission: string[]; - - @Column('boolean', { - default: false, - }) - public fetched: boolean; -} diff --git a/packages/backend/src/models/Ad.ts b/packages/backend/src/models/Ad.ts deleted file mode 100644 index b1d7d7d79e..0000000000 --- a/packages/backend/src/models/Ad.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, Index, Column, PrimaryColumn } from 'typeorm'; -import { id } from './util/id.js'; - -@Entity('ad') -export class MiAd { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column('timestamp with time zone', { - comment: 'The expired date of the Ad.', - }) - public expiresAt: Date; - - @Index() - @Column('timestamp with time zone', { - comment: 'The expired date of the Ad.', - default: () => 'now()', - }) - public startsAt: Date; - - @Column('varchar', { - length: 32, nullable: false, - }) - public place: string; - - // 今は使われていないが将来的に活用される可能性はある - @Column('varchar', { - length: 32, nullable: false, - }) - public priority: string; - - @Column('integer', { - default: 1, nullable: false, - }) - public ratio: number; - - @Column('varchar', { - length: 1024, nullable: false, - }) - public url: string; - - @Column('varchar', { - length: 1024, nullable: false, - }) - public imageUrl: string; - - @Column('varchar', { - length: 8192, nullable: false, - }) - public memo: string; - @Column('integer', { - default: 0, nullable: false, - }) - public dayOfWeek: number; - constructor(data: Partial) { - if (data == null) return; - - for (const [k, v] of Object.entries(data)) { - (this as any)[k] = v; - } - } -} diff --git a/packages/backend/src/models/Announcement.ts b/packages/backend/src/models/Announcement.ts deleted file mode 100644 index 8f8be88fed..0000000000 --- a/packages/backend/src/models/Announcement.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, Index, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('announcement') -export class MiAnnouncement { - @PrimaryColumn(id()) - public id: string; - - @Column('timestamp with time zone', { - comment: 'The updated date of the Announcement.', - nullable: true, - }) - public updatedAt: Date | null; - - @Column('varchar', { - length: 8192, nullable: false, - }) - public text: string; - - @Column('varchar', { - length: 256, nullable: false, - }) - public title: string; - - @Column('varchar', { - length: 1024, nullable: true, - }) - public imageUrl: string | null; - - // info, warning, error, success - @Column('varchar', { - length: 256, nullable: false, - default: 'info', - }) - public icon: string; - - // normal ... お知らせページ掲載 - // banner ... お知らせページ掲載 + バナー表示 - // dialog ... お知らせページ掲載 + ダイアログ表示 - @Column('varchar', { - length: 256, nullable: false, - default: 'normal', - }) - public display: string; - - @Column('boolean', { - default: false, - }) - public needConfirmationToRead: boolean; - - @Index() - @Column('boolean', { - default: true, - }) - public isActive: boolean; - - @Index() - @Column('boolean', { - default: false, - }) - public forExistingUsers: boolean; - - @Index() - @Column('boolean', { - default: false, - }) - public silence: boolean; - - @Index() - @Column({ - ...id(), - nullable: true, - }) - public userId: MiUser['id'] | null; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - constructor(data: Partial) { - if (data == null) return; - - for (const [k, v] of Object.entries(data)) { - (this as any)[k] = v; - } - } -} diff --git a/packages/backend/src/models/AnnouncementRead.ts b/packages/backend/src/models/AnnouncementRead.ts deleted file mode 100644 index db09e65f50..0000000000 --- a/packages/backend/src/models/AnnouncementRead.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiAnnouncement } from './Announcement.js'; - -@Entity('announcement_read') -@Index(['userId', 'announcementId'], { unique: true }) -export class MiAnnouncementRead { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Index() - @Column(id()) - public announcementId: MiAnnouncement['id']; - - @ManyToOne(type => MiAnnouncement, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public announcement: MiAnnouncement | null; -} diff --git a/packages/backend/src/models/Antenna.ts b/packages/backend/src/models/Antenna.ts deleted file mode 100644 index b74c61b728..0000000000 --- a/packages/backend/src/models/Antenna.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiUserList } from './UserList.js'; - -@Entity('antenna') -export class MiAntenna { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column('timestamp with time zone') - public lastUsedAt: Date; - - @Index() - @Column({ - ...id(), - comment: 'The owner ID.', - }) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column('varchar', { - length: 128, - comment: 'The name of the Antenna.', - }) - public name: string; - - @Column('enum', { enum: ['home', 'all', 'users', 'list', 'users_blacklist'] }) - public src: 'home' | 'all' | 'users' | 'list' | 'users_blacklist'; - - @Column({ - ...id(), - nullable: true, - }) - public userListId: MiUserList['id'] | null; - - @ManyToOne(type => MiUserList, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public userList: MiUserList | null; - - @Column('varchar', { - length: 1024, array: true, - default: '{}', - }) - public users: string[]; - - @Column('jsonb', { - default: [], - }) - public keywords: string[][]; - - @Column('jsonb', { - default: [], - }) - public excludeKeywords: string[][]; - - @Column('boolean', { - default: false, - }) - public caseSensitive: boolean; - - @Column('boolean', { - default: false, - }) - public withReplies: boolean; - - @Column('boolean') - public withFile: boolean; - - @Column('varchar', { - length: 2048, nullable: true, - }) - public expression: string | null; - - @Column('boolean') - public notify: boolean; - - @Index() - @Column('boolean', { - default: true, - }) - public isActive: boolean; - - @Column('boolean', { - default: false, - }) - public localOnly: boolean; -} diff --git a/packages/backend/src/models/App.ts b/packages/backend/src/models/App.ts deleted file mode 100644 index 5c56a224a2..0000000000 --- a/packages/backend/src/models/App.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, PrimaryColumn, Column, Index, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('app') -export class MiApp { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - nullable: true, - comment: 'The owner ID.', - }) - public userId: MiUser['id'] | null; - - @ManyToOne(type => MiUser, { - onDelete: 'SET NULL', - nullable: true, - }) - public user: MiUser | null; - - @Index() - @Column('varchar', { - length: 64, - comment: 'The secret key of the App.', - }) - public secret: string; - - @Column('varchar', { - length: 128, - comment: 'The name of the App.', - }) - public name: string; - - @Column('varchar', { - length: 512, - comment: 'The description of the App.', - }) - public description: string; - - @Column('varchar', { - length: 64, array: true, - comment: 'The permission of the App.', - }) - public permission: string[]; - - @Column('varchar', { - length: 512, nullable: true, - comment: 'The callbackUrl of the App.', - }) - public callbackUrl: string | null; -} diff --git a/packages/backend/src/models/AuthSession.ts b/packages/backend/src/models/AuthSession.ts deleted file mode 100644 index 81bed21211..0000000000 --- a/packages/backend/src/models/AuthSession.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, PrimaryColumn, Index, Column, ManyToOne, JoinColumn } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiApp } from './App.js'; - -@Entity('auth_session') -export class MiAuthSession { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column('varchar', { - length: 128, - }) - public token: string; - - @Column({ - ...id(), - nullable: true, - }) - public userId: MiUser['id'] | null; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - nullable: true, - }) - @JoinColumn() - public user: MiUser | null; - - @Column(id()) - public appId: MiApp['id']; - - @ManyToOne(type => MiApp, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public app: MiApp | null; -} diff --git a/packages/backend/src/models/AvatarDecoration.ts b/packages/backend/src/models/AvatarDecoration.ts deleted file mode 100644 index 08ebbdeac1..0000000000 --- a/packages/backend/src/models/AvatarDecoration.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, PrimaryColumn, Index, Column, ManyToOne, JoinColumn } from 'typeorm'; -import { id } from './util/id.js'; - -@Entity('avatar_decoration') -export class MiAvatarDecoration { - @PrimaryColumn(id()) - public id: string; - - @Column('timestamp with time zone', { - nullable: true, - }) - public updatedAt: Date | null; - - @Column('varchar', { - length: 1024, - }) - public url: string; - - @Column('varchar', { - length: 256, - }) - public name: string; - - @Column('varchar', { - length: 2048, - }) - public description: string; - - // TODO: 定期ジョブで存在しなくなったロールIDを除去するようにする - @Column('varchar', { - array: true, length: 128, default: '{}', - }) - public roleIdsThatCanBeUsedThisDecoration: string[]; -} diff --git a/packages/backend/src/models/Blocking.ts b/packages/backend/src/models/Blocking.ts deleted file mode 100644 index 9bf7a63b6e..0000000000 --- a/packages/backend/src/models/Blocking.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('blocking') -@Index(['blockerId', 'blockeeId'], { unique: true }) -export class MiBlocking { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: 'The blockee user ID.', - }) - public blockeeId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public blockee: MiUser | null; - - @Index() - @Column({ - ...id(), - comment: 'The blocker user ID.', - }) - public blockerId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public blocker: MiUser | null; -} diff --git a/packages/backend/src/models/Channel.ts b/packages/backend/src/models/Channel.ts deleted file mode 100644 index a7f9e262b1..0000000000 --- a/packages/backend/src/models/Channel.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiDriveFile } from './DriveFile.js'; - -@Entity('channel') -export class MiChannel { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column('timestamp with time zone', { - nullable: true, - }) - public lastNotedAt: Date | null; - - @Index() - @Column({ - ...id(), - nullable: true, - comment: 'The owner ID.', - }) - public userId: MiUser['id'] | null; - - @ManyToOne(type => MiUser, { - onDelete: 'SET NULL', - }) - @JoinColumn() - public user: MiUser | null; - - @Column('varchar', { - length: 128, - comment: 'The name of the Channel.', - }) - public name: string; - - @Column('varchar', { - length: 2048, nullable: true, - comment: 'The description of the Channel.', - }) - public description: string | null; - - @Column({ - ...id(), - nullable: true, - comment: 'The ID of banner Channel.', - }) - public bannerId: MiDriveFile['id'] | null; - - @ManyToOne(type => MiDriveFile, { - onDelete: 'SET NULL', - }) - @JoinColumn() - public banner: MiDriveFile | null; - - @Column('varchar', { - array: true, length: 128, default: '{}', - }) - public pinnedNoteIds: string[]; - - @Column('varchar', { - length: 16, - default: '#86b300', - }) - public color: string; - - @Index() - @Column('boolean', { - default: false, - }) - public isArchived: boolean; - - @Index() - @Column('integer', { - default: 0, - comment: 'The count of notes.', - }) - public notesCount: number; - - @Index() - @Column('integer', { - default: 0, - comment: 'The count of users.', - }) - public usersCount: number; - - @Column('boolean', { - default: false, - }) - public isSensitive: boolean; - - @Column('boolean', { - default: true, - }) - public allowRenoteToExternal: boolean; -} diff --git a/packages/backend/src/models/ChannelFavorite.ts b/packages/backend/src/models/ChannelFavorite.ts deleted file mode 100644 index fc25ffe260..0000000000 --- a/packages/backend/src/models/ChannelFavorite.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiChannel } from './Channel.js'; - -@Entity('channel_favorite') -@Index(['userId', 'channelId'], { unique: true }) -export class MiChannelFavorite { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - }) - public channelId: MiChannel['id']; - - @ManyToOne(type => MiChannel, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public channel: MiChannel | null; - - @Index() - @Column({ - ...id(), - }) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; -} diff --git a/packages/backend/src/models/ChannelFollowing.ts b/packages/backend/src/models/ChannelFollowing.ts deleted file mode 100644 index 4dd391a082..0000000000 --- a/packages/backend/src/models/ChannelFollowing.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiChannel } from './Channel.js'; - -@Entity('channel_following') -@Index(['followerId', 'followeeId'], { unique: true }) -export class MiChannelFollowing { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: 'The followee channel ID.', - }) - public followeeId: MiChannel['id']; - - @ManyToOne(type => MiChannel, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public followee: MiChannel | null; - - @Index() - @Column({ - ...id(), - comment: 'The follower user ID.', - }) - public followerId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public follower: MiUser | null; -} diff --git a/packages/backend/src/models/Clip.ts b/packages/backend/src/models/Clip.ts deleted file mode 100644 index 2483b0925a..0000000000 --- a/packages/backend/src/models/Clip.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('clip') -export class MiClip { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column('timestamp with time zone', { - nullable: true, - }) - public lastClippedAt: Date | null; - - @Index() - @Column({ - ...id(), - comment: 'The owner ID.', - }) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column('varchar', { - length: 128, - comment: 'The name of the Clip.', - }) - public name: string; - - @Column('boolean', { - default: false, - }) - public isPublic: boolean; - - @Column('varchar', { - length: 2048, nullable: true, - comment: 'The description of the Clip.', - }) - public description: string | null; -} diff --git a/packages/backend/src/models/ClipFavorite.ts b/packages/backend/src/models/ClipFavorite.ts deleted file mode 100644 index aa949b3ea8..0000000000 --- a/packages/backend/src/models/ClipFavorite.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiClip } from './Clip.js'; - -@Entity('clip_favorite') -@Index(['userId', 'clipId'], { unique: true }) -export class MiClipFavorite { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column(id()) - public clipId: MiClip['id']; - - @ManyToOne(type => MiClip, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public clip: MiClip | null; -} diff --git a/packages/backend/src/models/ClipNote.ts b/packages/backend/src/models/ClipNote.ts deleted file mode 100644 index b7cc5ee39b..0000000000 --- a/packages/backend/src/models/ClipNote.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm'; -import { id } from './util/id.js'; -import { MiNote } from './Note.js'; -import { MiClip } from './Clip.js'; - -@Entity('clip_note') -@Index(['noteId', 'clipId'], { unique: true }) -export class MiClipNote { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: 'The note ID.', - }) - public noteId: MiNote['id']; - - @ManyToOne(type => MiNote, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: MiNote | null; - - @Index() - @Column({ - ...id(), - comment: 'The clip ID.', - }) - public clipId: MiClip['id']; - - @ManyToOne(type => MiClip, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public clip: MiClip | null; -} diff --git a/packages/backend/src/models/DriveFile.ts b/packages/backend/src/models/DriveFile.ts deleted file mode 100644 index ca564f6f0c..0000000000 --- a/packages/backend/src/models/DriveFile.ts +++ /dev/null @@ -1,192 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiDriveFolder } from './DriveFolder.js'; - -@Entity('drive_file') -@Index(['userId', 'folderId', 'id']) -export class MiDriveFile { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - nullable: true, - comment: 'The owner ID.', - }) - public userId: MiUser['id'] | null; - - @ManyToOne(type => MiUser, { - onDelete: 'SET NULL', - }) - @JoinColumn() - public user: MiUser | null; - - @Index() - @Column('varchar', { - length: 128, nullable: true, - comment: 'The host of owner. It will be null if the user in local.', - }) - public userHost: string | null; - - @Index() - @Column('varchar', { - length: 32, - comment: 'The MD5 hash of the DriveFile.', - }) - public md5: string; - - @Column('varchar', { - length: 256, - comment: 'The file name of the DriveFile.', - }) - public name: string; - - @Index() - @Column('varchar', { - length: 128, - comment: 'The content type (MIME) of the DriveFile.', - }) - public type: string; - - @Column('integer', { - comment: 'The file size (bytes) of the DriveFile.', - }) - public size: number; - - @Column('varchar', { - length: 8192, - nullable: true, - comment: 'The comment of the DriveFile.', - }) - public comment: string | null; - - @Column('varchar', { - length: 128, nullable: true, - comment: 'The BlurHash string.', - }) - public blurhash: string | null; - - @Column('jsonb', { - default: {}, - comment: 'The any properties of the DriveFile. For example, it includes image width/height.', - }) - public properties: { width?: number; height?: number; orientation?: number; avgColor?: string }; - - @Column('boolean') - public storedInternal: boolean; - - @Column('varchar', { - length: 512, - comment: 'The URL of the DriveFile.', - }) - public url: string; - - @Column('varchar', { - length: 512, nullable: true, - comment: 'The URL of the thumbnail of the DriveFile.', - }) - public thumbnailUrl: string | null; - - @Column('varchar', { - length: 512, nullable: true, - comment: 'The URL of the webpublic of the DriveFile.', - }) - public webpublicUrl: string | null; - - @Column('varchar', { - length: 128, nullable: true, - }) - public webpublicType: string | null; - - @Index({ unique: true }) - @Column('varchar', { - length: 256, nullable: true, - }) - public accessKey: string | null; - - @Index({ unique: true }) - @Column('varchar', { - length: 256, nullable: true, - }) - public thumbnailAccessKey: string | null; - - @Index({ unique: true }) - @Column('varchar', { - length: 256, nullable: true, - }) - public webpublicAccessKey: string | null; - - @Index() - @Column('varchar', { - length: 512, nullable: true, - comment: 'The URI of the DriveFile. it will be null when the DriveFile is local.', - }) - public uri: string | null; - - @Column('varchar', { - length: 512, nullable: true, - }) - public src: string | null; - - @Index() - @Column({ - ...id(), - nullable: true, - comment: 'The parent folder ID. If null, it means the DriveFile is located in root.', - }) - public folderId: MiDriveFolder['id'] | null; - - @ManyToOne(type => MiDriveFolder, { - onDelete: 'SET NULL', - }) - @JoinColumn() - public folder: MiDriveFolder | null; - - @Index() - @Column('boolean', { - default: false, - comment: 'Whether the DriveFile is NSFW.', - }) - public isSensitive: boolean; - - @Index() - @Column('boolean', { - default: false, - comment: 'Whether the DriveFile is NSFW. (predict)', - }) - public maybeSensitive: boolean; - - @Index() - @Column('boolean', { - default: false, - }) - public maybePorn: boolean; - - /** - * 外部の(信頼されていない)URLへの直リンクか否か - */ - @Index() - @Column('boolean', { - default: false, - comment: 'Whether the DriveFile is direct link to remote server.', - }) - public isLink: boolean; - - @Column('jsonb', { - default: {}, - nullable: true, - }) - public requestHeaders: Record | null; - - @Column('varchar', { - length: 128, nullable: true, - }) - public requestIp: string | null; -} diff --git a/packages/backend/src/models/DriveFolder.ts b/packages/backend/src/models/DriveFolder.ts deleted file mode 100644 index 18f6d17709..0000000000 --- a/packages/backend/src/models/DriveFolder.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { JoinColumn, ManyToOne, Entity, PrimaryColumn, Index, Column } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('drive_folder') -export class MiDriveFolder { - @PrimaryColumn(id()) - public id: string; - - @Column('varchar', { - length: 128, - comment: 'The name of the DriveFolder.', - }) - public name: string; - - @Index() - @Column({ - ...id(), - nullable: true, - comment: 'The owner ID.', - }) - public userId: MiUser['id'] | null; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Index() - @Column({ - ...id(), - nullable: true, - comment: 'The parent folder ID. If null, it means the DriveFolder is located in root.', - }) - public parentId: MiDriveFolder['id'] | null; - - @ManyToOne(type => MiDriveFolder, { - onDelete: 'SET NULL', - }) - @JoinColumn() - public parent: MiDriveFolder | null; -} diff --git a/packages/backend/src/models/Emoji.ts b/packages/backend/src/models/Emoji.ts deleted file mode 100644 index 563ac1d9d3..0000000000 --- a/packages/backend/src/models/Emoji.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, Column } from 'typeorm'; -import { id } from './util/id.js'; - -@Entity('emoji') -@Index(['name', 'host'], { unique: true }) -export class MiEmoji { - @PrimaryColumn(id()) - public id: string; - - @Column('timestamp with time zone', { - nullable: true, - }) - public updatedAt: Date | null; - - @Index() - @Column('varchar', { - length: 128, - }) - public name: string; - - @Index() - @Column('varchar', { - length: 128, nullable: true, - }) - public host: string | null; - - @Column('varchar', { - length: 128, nullable: true, - }) - public category: string | null; - - @Column('varchar', { - length: 512, - }) - public originalUrl: string; - - @Column('varchar', { - length: 512, - default: '', - }) - public publicUrl: string; - - @Column('varchar', { - length: 512, nullable: true, - }) - public uri: string | null; - - // publicUrlの方のtypeが入る - @Column('varchar', { - length: 64, nullable: true, - }) - public type: string | null; - - @Column('varchar', { - array: true, length: 128, default: '{}', - }) - public aliases: string[]; - - @Column('varchar', { - length: 1024, nullable: true, - }) - public license: string | null; - - @Column('boolean', { - default: false, - }) - public localOnly: boolean; - - @Column('boolean', { - default: false, - }) - public isSensitive: boolean; - - // TODO: 定期ジョブで存在しなくなったロールIDを除去するようにする - @Column('varchar', { - array: true, length: 128, default: '{}', - }) - public roleIdsThatCanBeUsedThisEmojiAsReaction: string[]; -} diff --git a/packages/backend/src/models/Flash.ts b/packages/backend/src/models/Flash.ts deleted file mode 100644 index ac880843b0..0000000000 --- a/packages/backend/src/models/Flash.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('flash') -export class MiFlash { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column('timestamp with time zone', { - comment: 'The updated date of the Flash.', - }) - public updatedAt: Date; - - @Column('varchar', { - length: 256, - }) - public title: string; - - @Column('varchar', { - length: 1024, - }) - public summary: string; - - @Index() - @Column({ - ...id(), - comment: 'The ID of author.', - }) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column('varchar', { - length: 65536, - }) - public script: string; - - @Column('varchar', { - length: 256, array: true, default: '{}', - }) - public permissions: string[]; - - @Column('integer', { - default: 0, - }) - public likedCount: number; - - /** - * public ... 公開 - * private ... プロフィールには表示しない - */ - @Column('varchar', { - length: 512, default: 'public', - }) - public visibility: 'public' | 'private'; -} diff --git a/packages/backend/src/models/FlashLike.ts b/packages/backend/src/models/FlashLike.ts deleted file mode 100644 index ad7f4966b4..0000000000 --- a/packages/backend/src/models/FlashLike.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiFlash } from './Flash.js'; - -@Entity('flash_like') -@Index(['userId', 'flashId'], { unique: true }) -export class MiFlashLike { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column(id()) - public flashId: MiFlash['id']; - - @ManyToOne(type => MiFlash, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public flash: MiFlash | null; -} diff --git a/packages/backend/src/models/FollowRequest.ts b/packages/backend/src/models/FollowRequest.ts deleted file mode 100644 index 9899694dd6..0000000000 --- a/packages/backend/src/models/FollowRequest.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('follow_request') -@Index(['followerId', 'followeeId'], { unique: true }) -export class MiFollowRequest { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: 'The followee user ID.', - }) - public followeeId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public followee: MiUser | null; - - @Index() - @Column({ - ...id(), - comment: 'The follower user ID.', - }) - public followerId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public follower: MiUser | null; - - @Column('varchar', { - length: 128, nullable: true, - comment: 'id of Follow Activity.', - }) - public requestId: string | null; - - @Column('boolean', { - default: false, - }) - public withReplies: boolean; - - //#region Denormalized fields - @Column('varchar', { - length: 128, nullable: true, - comment: '[Denormalized]', - }) - public followerHost: string | null; - - @Column('varchar', { - length: 512, nullable: true, - comment: '[Denormalized]', - }) - public followerInbox: string | null; - - @Column('varchar', { - length: 512, nullable: true, - comment: '[Denormalized]', - }) - public followerSharedInbox: string | null; - - @Column('varchar', { - length: 128, nullable: true, - comment: '[Denormalized]', - }) - public followeeHost: string | null; - - @Column('varchar', { - length: 512, nullable: true, - comment: '[Denormalized]', - }) - public followeeInbox: string | null; - - @Column('varchar', { - length: 512, nullable: true, - comment: '[Denormalized]', - }) - public followeeSharedInbox: string | null; - //#endregion -} diff --git a/packages/backend/src/models/Following.ts b/packages/backend/src/models/Following.ts deleted file mode 100644 index e320911a1d..0000000000 --- a/packages/backend/src/models/Following.ts +++ /dev/null @@ -1,100 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('following') -@Index(['followerId', 'followeeId'], { unique: true }) -@Index(['followeeId', 'followerHost', 'isFollowerHibernated']) -export class MiFollowing { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: 'The followee user ID.', - }) - public followeeId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public followee: MiUser | null; - - @Index() - @Column({ - ...id(), - comment: 'The follower user ID.', - }) - public followerId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public follower: MiUser | null; - - @Column('boolean', { - default: false, - }) - public isFollowerHibernated: boolean; - - // タイムラインにその人のリプライまで含めるかどうか - @Column('boolean', { - default: false, - }) - public withReplies: boolean; - - @Index() - @Column('varchar', { - length: 32, - nullable: true, - }) - public notify: 'normal' | null; - - //#region Denormalized fields - @Index() - @Column('varchar', { - length: 128, nullable: true, - comment: '[Denormalized]', - }) - public followerHost: string | null; - - @Column('varchar', { - length: 512, nullable: true, - comment: '[Denormalized]', - }) - public followerInbox: string | null; - - @Column('varchar', { - length: 512, nullable: true, - comment: '[Denormalized]', - }) - public followerSharedInbox: string | null; - - @Index() - @Column('varchar', { - length: 128, nullable: true, - comment: '[Denormalized]', - }) - public followeeHost: string | null; - - @Column('varchar', { - length: 512, nullable: true, - comment: '[Denormalized]', - }) - public followeeInbox: string | null; - - @Column('varchar', { - length: 512, nullable: true, - comment: '[Denormalized]', - }) - public followeeSharedInbox: string | null; - //#endregion -} diff --git a/packages/backend/src/models/GalleryLike.ts b/packages/backend/src/models/GalleryLike.ts deleted file mode 100644 index 84d4ce9c3e..0000000000 --- a/packages/backend/src/models/GalleryLike.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiGalleryPost } from './GalleryPost.js'; - -@Entity('gallery_like') -@Index(['userId', 'postId'], { unique: true }) -export class MiGalleryLike { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column(id()) - public postId: MiGalleryPost['id']; - - @ManyToOne(type => MiGalleryPost, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public post: MiGalleryPost | null; -} diff --git a/packages/backend/src/models/GalleryPost.ts b/packages/backend/src/models/GalleryPost.ts deleted file mode 100644 index b72220caf9..0000000000 --- a/packages/backend/src/models/GalleryPost.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import type { MiDriveFile } from './DriveFile.js'; - -@Entity('gallery_post') -export class MiGalleryPost { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column('timestamp with time zone', { - comment: 'The updated date of the GalleryPost.', - }) - public updatedAt: Date; - - @Column('varchar', { - length: 256, - }) - public title: string; - - @Column('varchar', { - length: 2048, nullable: true, - }) - public description: string | null; - - @Index() - @Column({ - ...id(), - comment: 'The ID of author.', - }) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Index() - @Column({ - ...id(), - array: true, default: '{}', - }) - public fileIds: MiDriveFile['id'][]; - - @Index() - @Column('boolean', { - default: false, - comment: 'Whether the post is sensitive.', - }) - public isSensitive: boolean; - - @Index() - @Column('integer', { - default: 0, - }) - public likedCount: number; - - @Index() - @Column('varchar', { - length: 128, array: true, default: '{}', - }) - public tags: string[]; - - constructor(data: Partial) { - if (data == null) return; - - for (const [k, v] of Object.entries(data)) { - (this as any)[k] = v; - } - } -} diff --git a/packages/backend/src/models/Hashtag.ts b/packages/backend/src/models/Hashtag.ts deleted file mode 100644 index 1493774752..0000000000 --- a/packages/backend/src/models/Hashtag.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, PrimaryColumn, Index, Column } from 'typeorm'; -import { id } from './util/id.js'; -import type { MiUser } from './User.js'; - -@Entity('hashtag') -export class MiHashtag { - @PrimaryColumn(id()) - public id: string; - - @Index({ unique: true }) - @Column('varchar', { - length: 128, - }) - public name: string; - - @Column({ - ...id(), - array: true, - }) - public mentionedUserIds: MiUser['id'][]; - - @Index() - @Column('integer', { - default: 0, - }) - public mentionedUsersCount: number; - - @Column({ - ...id(), - array: true, - }) - public mentionedLocalUserIds: MiUser['id'][]; - - @Index() - @Column('integer', { - default: 0, - }) - public mentionedLocalUsersCount: number; - - @Column({ - ...id(), - array: true, - }) - public mentionedRemoteUserIds: MiUser['id'][]; - - @Index() - @Column('integer', { - default: 0, - }) - public mentionedRemoteUsersCount: number; - - @Column({ - ...id(), - array: true, - }) - public attachedUserIds: MiUser['id'][]; - - @Index() - @Column('integer', { - default: 0, - }) - public attachedUsersCount: number; - - @Column({ - ...id(), - array: true, - }) - public attachedLocalUserIds: MiUser['id'][]; - - @Index() - @Column('integer', { - default: 0, - }) - public attachedLocalUsersCount: number; - - @Column({ - ...id(), - array: true, - }) - public attachedRemoteUserIds: MiUser['id'][]; - - @Index() - @Column('integer', { - default: 0, - }) - public attachedRemoteUsersCount: number; -} diff --git a/packages/backend/src/models/Instance.ts b/packages/backend/src/models/Instance.ts deleted file mode 100644 index 4200b1b461..0000000000 --- a/packages/backend/src/models/Instance.ts +++ /dev/null @@ -1,152 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, PrimaryColumn, Index, Column } from 'typeorm'; -import { id } from './util/id.js'; - -@Entity('instance') -export class MiInstance { - @PrimaryColumn(id()) - public id: string; - - /** - * このインスタンスを捕捉した日時 - */ - @Index() - @Column('timestamp with time zone', { - comment: 'The caught date of the Instance.', - }) - public firstRetrievedAt: Date; - - /** - * ホスト - */ - @Index({ unique: true }) - @Column('varchar', { - length: 128, - comment: 'The host of the Instance.', - }) - public host: string; - - /** - * インスタンスのユーザー数 - */ - @Column('integer', { - default: 0, - comment: 'The count of the users of the Instance.', - }) - public usersCount: number; - - /** - * インスタンスの投稿数 - */ - @Column('integer', { - default: 0, - comment: 'The count of the notes of the Instance.', - }) - public notesCount: number; - - /** - * このインスタンスのユーザーからフォローされている、自インスタンスのユーザーの数 - */ - @Column('integer', { - default: 0, - }) - public followingCount: number; - - /** - * このインスタンスのユーザーをフォローしている、自インスタンスのユーザーの数 - */ - @Column('integer', { - default: 0, - }) - public followersCount: number; - - /** - * 直近のリクエスト受信日時 - */ - @Column('timestamp with time zone', { - nullable: true, - }) - public latestRequestReceivedAt: Date | null; - - /** - * このインスタンスと不通かどうか - */ - @Column('boolean', { - default: false, - }) - public isNotResponding: boolean; - - /** - * このインスタンスへの配信を停止するか - */ - @Index() - @Column('boolean', { - default: false, - }) - public isSuspended: boolean; - - @Column('varchar', { - length: 64, nullable: true, - comment: 'The software of the Instance.', - }) - public softwareName: string | null; - - @Column('varchar', { - length: 64, nullable: true, - }) - public softwareVersion: string | null; - - @Column('boolean', { - nullable: true, - }) - public openRegistrations: boolean | null; - - @Column('varchar', { - length: 256, nullable: true, - }) - public name: string | null; - - @Column('varchar', { - length: 4096, nullable: true, - }) - public description: string | null; - - @Column('varchar', { - length: 128, nullable: true, - }) - public maintainerName: string | null; - - @Column('varchar', { - length: 256, nullable: true, - }) - public maintainerEmail: string | null; - - @Column('varchar', { - length: 256, nullable: true, - }) - public iconUrl: string | null; - - @Column('varchar', { - length: 256, nullable: true, - }) - public faviconUrl: string | null; - - @Column('varchar', { - length: 64, nullable: true, - }) - public themeColor: string | null; - - @Column('timestamp with time zone', { - nullable: true, - }) - public infoUpdatedAt: Date | null; - - @Column('boolean', { - default: false, - }) - public isNSFW: boolean; -} diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts deleted file mode 100644 index 84ab18759b..0000000000 --- a/packages/backend/src/models/Meta.ts +++ /dev/null @@ -1,580 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('meta') -export class MiMeta { - @PrimaryColumn({ - type: 'varchar', - length: 32, - }) - public id: string; - - @Column('varchar', { - length: 1024, nullable: true, - }) - public name: string | null; - - @Column('varchar', { - length: 64, nullable: true, - }) - public shortName: string | null; - - @Column('varchar', { - length: 1024, nullable: true, - }) - public description: string | null; - - /** - * メンテナの名前 - */ - @Column('varchar', { - length: 1024, nullable: true, - }) - public maintainerName: string | null; - - /** - * メンテナの連絡先 - */ - @Column('varchar', { - length: 1024, nullable: true, - }) - public maintainerEmail: string | null; - - @Column('boolean', { - default: false, - }) - public disableRegistration: boolean; - - @Column('varchar', { - length: 1024, array: true, default: '{}', - }) - public langs: string[]; - - @Column('varchar', { - length: 1024, array: true, default: '{}', - }) - public pinnedUsers: string[]; - - @Column('varchar', { - length: 1024, array: true, default: '{}', - }) - public hiddenTags: string[]; - - @Column('varchar', { - length: 1024, array: true, default: '{}', - }) - public blockedHosts: string[]; - - @Column('varchar', { - length: 1024, array: true, default: '{}', - }) - public allowedHosts: string[]; - - @Column('boolean', { - default: false, - }) - public allowlistMode: boolean; - - @Column('varchar', { - length: 1024, array: true, default: '{}', - }) - public sensitiveWords: string[]; - - @Column('varchar', { - length: 1024, array: true, default: '{}', - }) - public silencedHosts: string[]; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public themeColor: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public mascotImageUrl: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public bannerUrl: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public backgroundImageUrl: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public logoImageUrl: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public iconUrl: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public app192IconUrl: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public app512IconUrl: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public serverErrorImageUrl: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public notFoundImageUrl: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public infoImageUrl: string | null; - - @Column('boolean', { - default: false, - }) - public cacheRemoteFiles: boolean; - - @Column('boolean', { - default: true, - }) - public cacheRemoteSensitiveFiles: boolean; - - @Column({ - ...id(), - nullable: true, - }) - public proxyAccountId: MiUser['id'] | null; - - @ManyToOne(type => MiUser, { - onDelete: 'SET NULL', - }) - @JoinColumn() - public proxyAccount: MiUser | null; - - @Column('boolean', { - default: false, - }) - public emailRequiredForSignup: boolean; - - @Column('boolean', { - default: false, - }) - public approvalRequiredForSignup: boolean; - - @Column('boolean', { - default: false, - }) - public enableHcaptcha: boolean; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public hcaptchaSiteKey: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public hcaptchaSecretKey: string | null; - - @Column('boolean', { - default: false, - }) - public enableRecaptcha: boolean; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public recaptchaSiteKey: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public recaptchaSecretKey: string | null; - - @Column('boolean', { - default: false, - }) - public enableTurnstile: boolean; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public turnstileSiteKey: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public turnstileSecretKey: string | null; - - @Column('enum', { - enum: ['none', 'all', 'local', 'remote'], - default: 'none', - }) - public sensitiveMediaDetection: 'none' | 'all' | 'local' | 'remote'; - - @Column('enum', { - enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'], - default: 'medium', - }) - public sensitiveMediaDetectionSensitivity: 'medium' | 'low' | 'high' | 'veryLow' | 'veryHigh'; - - @Column('boolean', { - default: false, - }) - public setSensitiveFlagAutomatically: boolean; - - @Column('boolean', { - default: false, - }) - public enableSensitiveMediaDetectionForVideos: boolean; - - @Column('boolean', { - default: true, - }) - public enableBotTrending: boolean; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public summalyProxy: string | null; - - @Column('boolean', { - default: false, - }) - public enableEmail: boolean; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public email: string | null; - - @Column('boolean', { - default: false, - }) - public smtpSecure: boolean; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public smtpHost: string | null; - - @Column('integer', { - nullable: true, - }) - public smtpPort: number | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public smtpUser: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public smtpPass: string | null; - - @Column('boolean', { - default: false, - }) - public enableServiceWorker: boolean; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public swPublicKey: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public swPrivateKey: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public deeplAuthKey: string | null; - - @Column('boolean', { - default: false, - }) - public deeplIsPro: boolean; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public termsOfServiceUrl: string | null; - - @Column('varchar', { - length: 1024, - default: 'https://github.com/misskey-dev/misskey', - nullable: false, - }) - public repositoryUrl: string; - - @Column('varchar', { - length: 1024, - default: 'https://github.com/misskey-dev/misskey/issues/new', - nullable: true, - }) - public feedbackUrl: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public impressumUrl: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public privacyPolicyUrl: string | null; - - @Column('varchar', { - length: 8192, - nullable: true, - }) - public defaultLightTheme: string | null; - - @Column('varchar', { - length: 8192, - nullable: true, - }) - public defaultDarkTheme: string | null; - - @Column('boolean', { - default: false, - }) - public useObjectStorage: boolean; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public objectStorageBucket: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public objectStoragePrefix: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public objectStorageBaseUrl: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public objectStorageEndpoint: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public objectStorageRegion: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public objectStorageAccessKey: string | null; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public objectStorageSecretKey: string | null; - - @Column('integer', { - nullable: true, - }) - public objectStoragePort: number | null; - - @Column('boolean', { - default: true, - }) - public objectStorageUseSSL: boolean; - - @Column('boolean', { - default: true, - }) - public objectStorageUseProxy: boolean; - - @Column('boolean', { - default: false, - }) - public objectStorageSetPublicRead: boolean; - - @Column('boolean', { - default: true, - }) - public objectStorageS3ForcePathStyle: boolean; - - @Column('boolean', { - default: false, - }) - public enableIpLogging: boolean; - - @Column('boolean', { - default: true, - }) - public enableActiveEmailValidation: boolean; - - @Column('boolean', { - default: false, - }) - public enableVerifymailApi: boolean; - - @Column('varchar', { - length: 1024, - nullable: true, - }) - public verifymailAuthKey: string | null; - - @Column('boolean', { - default: true, - }) - public enableChartsForRemoteUser: boolean; - - @Column('boolean', { - default: true, - }) - public enableChartsForFederatedInstances: boolean; - - @Column('boolean', { - default: false, - }) - public enableServerMachineStats: boolean; - - @Column('boolean', { - default: true, - }) - public enableIdenticonGeneration: boolean; - - @Column('boolean', { - default: true, - }) - public enableAchievements: boolean; - - @Column('jsonb', { - default: { }, - }) - public policies: Record; - - @Column('varchar', { - length: 280, - array: true, - default: '{}', - }) - public serverRules: string[]; - - @Column('varchar', { - length: 8192, - default: '{}', - }) - public manifestJsonOverride: string; - - @Column('varchar', { - length: 1024, - array: true, - default: '{}', - }) - public bannedEmailDomains: string[]; - - @Column('varchar', { - length: 1024, array: true, default: '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }', - }) - public preservedUsernames: string[]; - - @Column('boolean', { - default: true, - }) - public enableFanoutTimeline: boolean; - - @Column('boolean', { - default: true, - }) - public enableFanoutTimelineDbFallback: boolean; - - @Column('integer', { - default: 300, - }) - public perLocalUserUserTimelineCacheMax: number; - - @Column('integer', { - default: 100, - }) - public perRemoteUserUserTimelineCacheMax: number; - - @Column('integer', { - default: 300, - }) - public perUserHomeTimelineCacheMax: number; - - @Column('integer', { - default: 300, - }) - public perUserListTimelineCacheMax: number; - - @Column('integer', { - default: 0, - }) - public notesPerOneAd: number; - - @Column('varchar', { - length: 500, - nullable: true, - }) - public defaultLike: string | null; - - @Column('varchar', { - length: 256, array: true, default: '{}', - }) - public bubbleInstances: string[]; -} diff --git a/packages/backend/src/models/ModerationLog.ts b/packages/backend/src/models/ModerationLog.ts deleted file mode 100644 index 71b33c3e47..0000000000 --- a/packages/backend/src/models/ModerationLog.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('moderation_log') -export class MiModerationLog { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column('varchar', { - length: 128, - }) - public type: string; - - @Column('jsonb') - public info: Record; -} diff --git a/packages/backend/src/models/Muting.ts b/packages/backend/src/models/Muting.ts deleted file mode 100644 index a528e1e7d7..0000000000 --- a/packages/backend/src/models/Muting.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('muting') -@Index(['muterId', 'muteeId'], { unique: true }) -export class MiMuting { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column('timestamp with time zone', { - nullable: true, - }) - public expiresAt: Date | null; - - @Index() - @Column({ - ...id(), - comment: 'The mutee user ID.', - }) - public muteeId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public mutee: MiUser | null; - - @Index() - @Column({ - ...id(), - comment: 'The muter user ID.', - }) - public muterId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public muter: MiUser | null; -} diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts deleted file mode 100644 index 2705282880..0000000000 --- a/packages/backend/src/models/Note.ts +++ /dev/null @@ -1,257 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; -import { noteVisibilities } from '@/types.js'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiChannel } from './Channel.js'; -import type { MiDriveFile } from './DriveFile.js'; - -@Entity('note') -@Index('IDX_NOTE_TAGS', { synchronize: false }) -@Index('IDX_NOTE_MENTIONS', { synchronize: false }) -@Index('IDX_NOTE_VISIBLE_USER_IDS', { synchronize: false }) -export class MiNote { - @PrimaryColumn(id()) - public id: string; - - @Column('timestamp with time zone', { - comment: 'The update time of the Note.', - default: null, - }) - public updatedAt: Date | null; - - @Index() - @Column({ - ...id(), - nullable: true, - comment: 'The ID of reply target.', - }) - public replyId: MiNote['id'] | null; - - @ManyToOne(type => MiNote, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public reply: MiNote | null; - - @Index() - @Column({ - ...id(), - nullable: true, - comment: 'The ID of renote target.', - }) - public renoteId: MiNote['id'] | null; - - @ManyToOne(type => MiNote, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public renote: MiNote | null; - - @Index() - @Column('varchar', { - length: 256, nullable: true, - }) - public threadId: string | null; - - // TODO: varcharにしたい - @Column('text', { - nullable: true, - }) - public text: string | null; - - @Column('varchar', { - length: 256, nullable: true, - }) - public name: string | null; - - @Column('varchar', { - length: 512, nullable: true, - }) - public cw: string | null; - - @Index() - @Column({ - ...id(), - comment: 'The ID of author.', - }) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column('boolean', { - default: false, - }) - public localOnly: boolean; - - @Column('varchar', { - length: 64, nullable: true, - }) - public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null; - - @Column('smallint', { - default: 0, - }) - public renoteCount: number; - - @Column('smallint', { - default: 0, - }) - public repliesCount: number; - - @Column('smallint', { - default: 0, - }) - public clippedCount: number; - - @Column('jsonb', { - default: {}, - }) - public reactions: Record; - - /** - * public ... 公開 - * home ... ホームタイムライン(ユーザーページのタイムライン含む)のみに流す - * followers ... フォロワーのみ - * specified ... visibleUserIds で指定したユーザーのみ - */ - @Column('enum', { enum: noteVisibilities }) - public visibility: typeof noteVisibilities[number]; - - @Index({ unique: true }) - @Column('varchar', { - length: 512, nullable: true, - comment: 'The URI of a note. it will be null when the note is local.', - }) - public uri: string | null; - - @Column('varchar', { - length: 512, nullable: true, - comment: 'The human readable url of a note. it will be null when the note is local.', - }) - public url: string | null; - - @Index() - @Column({ - ...id(), - array: true, default: '{}', - }) - public fileIds: MiDriveFile['id'][]; - - @Column('varchar', { - length: 256, array: true, default: '{}', - }) - public attachedFileTypes: string[]; - - @Index() - @Column({ - ...id(), - array: true, default: '{}', - }) - public visibleUserIds: MiUser['id'][]; - - @Index() - @Column({ - ...id(), - array: true, default: '{}', - }) - public mentions: MiUser['id'][]; - - @Column('text', { - default: '[]', - }) - public mentionedRemoteUsers: string; - - @Column('varchar', { - length: 1024, array: true, default: '{}', - }) - public reactionAndUserPairCache: string[]; - - @Column('varchar', { - length: 128, array: true, default: '{}', - }) - public emojis: string[]; - - @Index() - @Column('varchar', { - length: 128, array: true, default: '{}', - }) - public tags: string[]; - - @Column('boolean', { - default: false, - }) - public hasPoll: boolean; - - @Index() - @Column({ - ...id(), - nullable: true, - comment: 'The ID of source channel.', - }) - public channelId: MiChannel['id'] | null; - - @ManyToOne(type => MiChannel, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public channel: MiChannel | null; - - //#region Denormalized fields - @Index() - @Column('varchar', { - length: 128, nullable: true, - comment: '[Denormalized]', - }) - public userHost: string | null; - - @Column({ - ...id(), - nullable: true, - comment: '[Denormalized]', - }) - public replyUserId: MiUser['id'] | null; - - @Column('varchar', { - length: 128, nullable: true, - comment: '[Denormalized]', - }) - public replyUserHost: string | null; - - @Column({ - ...id(), - nullable: true, - comment: '[Denormalized]', - }) - public renoteUserId: MiUser['id'] | null; - - @Column('varchar', { - length: 128, nullable: true, - comment: '[Denormalized]', - }) - public renoteUserHost: string | null; - //#endregion - - constructor(data: Partial) { - if (data == null) return; - - for (const [k, v] of Object.entries(data)) { - (this as any)[k] = v; - } - } -} - -export type IMentionedRemoteUsers = { - uri: string; - url?: string; - username: string; - host: string; -}[]; diff --git a/packages/backend/src/models/NoteEdit.ts b/packages/backend/src/models/NoteEdit.ts deleted file mode 100644 index 9ec989dd50..0000000000 --- a/packages/backend/src/models/NoteEdit.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Entity, JoinColumn, Column, ManyToOne, PrimaryColumn, Index } from "typeorm"; -import { id } from './util/id.js'; -import { MiNote } from './Note.js'; -import type { MiDriveFile } from './DriveFile.js'; - -@Entity() -export class NoteEdit { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: "The ID of note.", - }) - public noteId: MiNote["id"]; - - @ManyToOne((type) => MiNote, { - onDelete: "CASCADE", - }) - @JoinColumn() - public note: MiNote | null; - - @Column("text", { - nullable: true, - }) - public oldText: string | null; - - @Column("text", { - nullable: true, - }) - public newText: string | null; - - @Column("varchar", { - length: 512, - nullable: true, - }) - public cw: string | null; - - @Column({ - ...id(), - array: true, - default: "{}", - }) - public fileIds: MiDriveFile["id"][]; - - @Column("timestamp with time zone", { - comment: "The updated date of the Note.", - }) - public updatedAt: Date; - - @Column("timestamp with time zone", { - comment: "The old date from before the edit", - }) - public oldDate: Date; -} diff --git a/packages/backend/src/models/NoteFavorite.ts b/packages/backend/src/models/NoteFavorite.ts deleted file mode 100644 index 364eaabd98..0000000000 --- a/packages/backend/src/models/NoteFavorite.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiNote } from './Note.js'; -import { MiUser } from './User.js'; - -@Entity('note_favorite') -@Index(['userId', 'noteId'], { unique: true }) -export class MiNoteFavorite { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column(id()) - public noteId: MiNote['id']; - - @ManyToOne(type => MiNote, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: MiNote | null; -} diff --git a/packages/backend/src/models/NoteReaction.ts b/packages/backend/src/models/NoteReaction.ts deleted file mode 100644 index ee3a447464..0000000000 --- a/packages/backend/src/models/NoteReaction.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiNote } from './Note.js'; - -@Entity('note_reaction') -@Index(['userId', 'noteId'], { unique: true }) -export class MiNoteReaction { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user?: MiUser | null; - - @Index() - @Column(id()) - public noteId: MiNote['id']; - - @ManyToOne(type => MiNote, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note?: MiNote | null; - - // TODO: 対象noteのuserIdを非正規化したい(「受け取ったリアクション一覧」のようなものを(JOIN無しで)実装したいため) - - @Column('varchar', { - length: 260, - }) - public reaction: string; -} diff --git a/packages/backend/src/models/NoteThreadMuting.ts b/packages/backend/src/models/NoteThreadMuting.ts deleted file mode 100644 index 00311aa570..0000000000 --- a/packages/backend/src/models/NoteThreadMuting.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('note_thread_muting') -@Index(['userId', 'threadId'], { unique: true }) -export class MiNoteThreadMuting { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - }) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Index() - @Column('varchar', { - length: 256, - }) - public threadId: string; -} diff --git a/packages/backend/src/models/NoteUnread.ts b/packages/backend/src/models/NoteUnread.ts deleted file mode 100644 index d86a474553..0000000000 --- a/packages/backend/src/models/NoteUnread.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiNote } from './Note.js'; -import type { MiChannel } from './Channel.js'; - -@Entity('note_unread') -@Index(['userId', 'noteId'], { unique: true }) -export class MiNoteUnread { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Index() - @Column(id()) - public noteId: MiNote['id']; - - @ManyToOne(type => MiNote, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: MiNote | null; - - /** - * メンションか否か - */ - @Index() - @Column('boolean') - public isMentioned: boolean; - - /** - * ダイレクト投稿か否か - */ - @Index() - @Column('boolean') - public isSpecified: boolean; - - //#region Denormalized fields - @Index() - @Column({ - ...id(), - comment: '[Denormalized]', - }) - public noteUserId: MiUser['id']; - - @Index() - @Column({ - ...id(), - nullable: true, - comment: '[Denormalized]', - }) - public noteChannelId: MiChannel['id'] | null; - //#endregion -} diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts deleted file mode 100644 index 3bc2edaa0d..0000000000 --- a/packages/backend/src/models/Notification.ts +++ /dev/null @@ -1,127 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { MiUser } from './User.js'; -import { MiNote } from './Note.js'; -import { MiAccessToken } from './AccessToken.js'; -import { MiRole } from './Role.js'; - -export type MiNotification = { - type: 'note'; - id: string; - createdAt: string; - notifierId: MiUser['id']; - noteId: MiNote['id']; -} | { - type: 'follow'; - id: string; - createdAt: string; - notifierId: MiUser['id']; -} | { - type: 'mention'; - id: string; - createdAt: string; - notifierId: MiUser['id']; - noteId: MiNote['id']; -} | { - type: 'reply'; - id: string; - createdAt: string; - notifierId: MiUser['id']; - noteId: MiNote['id']; -} | { - type: 'renote'; - id: string; - createdAt: string; - notifierId: MiUser['id']; - noteId: MiNote['id']; - targetNoteId: MiNote['id']; -} | { - type: 'quote'; - id: string; - createdAt: string; - notifierId: MiUser['id']; - noteId: MiNote['id']; -} | { - type: 'reaction'; - id: string; - createdAt: string; - notifierId: MiUser['id']; - noteId: MiNote['id']; - reaction: string; -} | { - type: 'pollEnded'; - id: string; - createdAt: string; - notifierId: MiUser['id']; - noteId: MiNote['id']; -} | { - type: 'receiveFollowRequest'; - id: string; - createdAt: string; - notifierId: MiUser['id']; -} | { - type: 'followRequestAccepted'; - id: string; - createdAt: string; - notifierId: MiUser['id']; -} | { - type: 'roleAssigned'; - id: string; - createdAt: string; - roleId: MiRole['id']; -} | { - type: 'achievementEarned'; - id: string; - createdAt: string; - achievement: string; -} | { - type: 'app'; - id: string; - createdAt: string; - - /** - * アプリ通知のbody - */ - customBody: string | null; - - /** - * アプリ通知のheader - * (省略時はアプリ名で表示されることを期待) - */ - customHeader: string | null; - - /** - * アプリ通知のicon(URL) - * (省略時はアプリアイコンで表示されることを期待) - */ - customIcon: string | null; - - /** - * アプリ通知のアプリ(のトークン) - */ - appAccessTokenId: MiAccessToken['id'] | null; -} | { - type: 'test'; - id: string; - createdAt: string; -}; - -export type MiGroupedNotification = MiNotification | { - type: 'reaction:grouped'; - id: string; - createdAt: string; - noteId: MiNote['id']; - reactions: { - userId: string; - reaction: string; - }[]; -} | { - type: 'renote:grouped'; - id: string; - createdAt: string; - noteId: MiNote['id']; - userIds: string[]; -}; diff --git a/packages/backend/src/models/Page.ts b/packages/backend/src/models/Page.ts deleted file mode 100644 index 9cab875499..0000000000 --- a/packages/backend/src/models/Page.ts +++ /dev/null @@ -1,120 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiDriveFile } from './DriveFile.js'; - -@Entity('page') -@Index(['userId', 'name'], { unique: true }) -export class MiPage { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column('timestamp with time zone', { - comment: 'The updated date of the Page.', - }) - public updatedAt: Date; - - @Column('varchar', { - length: 256, - }) - public title: string; - - @Index() - @Column('varchar', { - length: 256, - }) - public name: string; - - @Column('varchar', { - length: 256, nullable: true, - }) - public summary: string | null; - - @Column('boolean') - public alignCenter: boolean; - - @Column('boolean', { - default: false, - }) - public hideTitleWhenPinned: boolean; - - @Column('varchar', { - length: 32, - }) - public font: string; - - @Index() - @Column({ - ...id(), - comment: 'The ID of author.', - }) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column({ - ...id(), - nullable: true, - }) - public eyeCatchingImageId: MiDriveFile['id'] | null; - - @ManyToOne(type => MiDriveFile, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public eyeCatchingImage: MiDriveFile | null; - - @Column('jsonb', { - default: [], - }) - public content: Record[]; - - @Column('jsonb', { - default: [], - }) - public variables: Record[]; - - @Column('varchar', { - length: 16384, - default: '', - }) - public script: string; - - /** - * public ... 公開 - * followers ... フォロワーのみ - * specified ... visibleUserIds で指定したユーザーのみ - */ - @Column('enum', { enum: ['public', 'followers', 'specified'] }) - public visibility: 'public' | 'followers' | 'specified'; - - @Index() - @Column({ - ...id(), - array: true, default: '{}', - }) - public visibleUserIds: MiUser['id'][]; - - @Column('integer', { - default: 0, - }) - public likedCount: number; - - constructor(data: Partial) { - if (data == null) return; - - for (const [k, v] of Object.entries(data)) { - (this as any)[k] = v; - } - } -} diff --git a/packages/backend/src/models/PageLike.ts b/packages/backend/src/models/PageLike.ts deleted file mode 100644 index b845f58b7d..0000000000 --- a/packages/backend/src/models/PageLike.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiPage } from './Page.js'; - -@Entity('page_like') -@Index(['userId', 'pageId'], { unique: true }) -export class MiPageLike { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column(id()) - public pageId: MiPage['id']; - - @ManyToOne(type => MiPage, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public page: MiPage | null; -} diff --git a/packages/backend/src/models/PasswordResetRequest.ts b/packages/backend/src/models/PasswordResetRequest.ts deleted file mode 100644 index 5be439511f..0000000000 --- a/packages/backend/src/models/PasswordResetRequest.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, Column, ManyToOne, JoinColumn } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('password_reset_request') -export class MiPasswordResetRequest { - @PrimaryColumn(id()) - public id: string; - - @Index({ unique: true }) - @Column('varchar', { - length: 256, - }) - public token: string; - - @Index() - @Column({ - ...id(), - }) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; -} diff --git a/packages/backend/src/models/Poll.ts b/packages/backend/src/models/Poll.ts deleted file mode 100644 index 5ce0b9a2fc..0000000000 --- a/packages/backend/src/models/Poll.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, OneToOne } from 'typeorm'; -import { noteVisibilities } from '@/types.js'; -import { id } from './util/id.js'; -import { MiNote } from './Note.js'; -import type { MiUser } from './User.js'; - -@Entity('poll') -export class MiPoll { - @PrimaryColumn(id()) - public noteId: MiNote['id']; - - @OneToOne(type => MiNote, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: MiNote | null; - - @Column('timestamp with time zone', { - nullable: true, - }) - public expiresAt: Date | null; - - @Column('boolean') - public multiple: boolean; - - @Column('varchar', { - length: 256, array: true, default: '{}', - }) - public choices: string[]; - - @Column('integer', { - array: true, - }) - public votes: number[]; - - //#region Denormalized fields - @Column('enum', { - enum: noteVisibilities, - comment: '[Denormalized]', - }) - public noteVisibility: typeof noteVisibilities[number]; - - @Index() - @Column({ - ...id(), - comment: '[Denormalized]', - }) - public userId: MiUser['id']; - - @Index() - @Column('varchar', { - length: 128, nullable: true, - comment: '[Denormalized]', - }) - public userHost: string | null; - //#endregion - - constructor(data: Partial) { - if (data == null) return; - - for (const [k, v] of Object.entries(data)) { - (this as any)[k] = v; - } - } -} - -export type IPoll = { - choices: string[]; - votes?: number[]; - multiple: boolean; - expiresAt: Date | null; -}; diff --git a/packages/backend/src/models/PollVote.ts b/packages/backend/src/models/PollVote.ts deleted file mode 100644 index 751be8a32b..0000000000 --- a/packages/backend/src/models/PollVote.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiNote } from './Note.js'; - -@Entity('poll_vote') -@Index(['userId', 'noteId', 'choice'], { unique: true }) -export class MiPollVote { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Index() - @Column(id()) - public noteId: MiNote['id']; - - @ManyToOne(type => MiNote, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: MiNote | null; - - @Column('integer') - public choice: number; -} diff --git a/packages/backend/src/models/PromoNote.ts b/packages/backend/src/models/PromoNote.ts deleted file mode 100644 index f4425fe88b..0000000000 --- a/packages/backend/src/models/PromoNote.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, OneToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiNote } from './Note.js'; -import type { MiUser } from './User.js'; - -@Entity('promo_note') -export class MiPromoNote { - @PrimaryColumn(id()) - public noteId: MiNote['id']; - - @OneToOne(type => MiNote, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: MiNote | null; - - @Column('timestamp with time zone') - public expiresAt: Date; - - //#region Denormalized fields - @Index() - @Column({ - ...id(), - comment: '[Denormalized]', - }) - public userId: MiUser['id']; - //#endregion -} diff --git a/packages/backend/src/models/PromoRead.ts b/packages/backend/src/models/PromoRead.ts deleted file mode 100644 index d9f3075416..0000000000 --- a/packages/backend/src/models/PromoRead.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiNote } from './Note.js'; -import { MiUser } from './User.js'; - -@Entity('promo_read') -@Index(['userId', 'noteId'], { unique: true }) -export class MiPromoRead { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column(id()) - public noteId: MiNote['id']; - - @ManyToOne(type => MiNote, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: MiNote | null; -} diff --git a/packages/backend/src/models/RegistrationTicket.ts b/packages/backend/src/models/RegistrationTicket.ts deleted file mode 100644 index 730cedffba..0000000000 --- a/packages/backend/src/models/RegistrationTicket.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, Column, ManyToOne, JoinColumn, OneToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('registration_ticket') -export class MiRegistrationTicket { - @PrimaryColumn(id()) - public id: string; - - @Index({ unique: true }) - @Column('varchar', { - length: 64, - }) - public code: string; - - @Column('timestamp with time zone', { - nullable: true, - }) - public expiresAt: Date | null; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public createdBy: MiUser | null; - - @Index() - @Column({ - ...id(), - nullable: true, - }) - public createdById: MiUser['id'] | null; - - @OneToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public usedBy: MiUser | null; - - @Index() - @Column({ - ...id(), - nullable: true, - }) - public usedById: MiUser['id'] | null; - - @Column('timestamp with time zone', { - nullable: true, - }) - public usedAt: Date | null; - - @Column('varchar', { - length: 32, - nullable: true, - }) - public pendingUserId: string | null; -} diff --git a/packages/backend/src/models/RegistryItem.ts b/packages/backend/src/models/RegistryItem.ts deleted file mode 100644 index 60bdced957..0000000000 --- a/packages/backend/src/models/RegistryItem.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -// TODO: 同じdomain、同じscope、同じkeyのレコードは二つ以上存在しないように制約付けたい -@Entity('registry_item') -export class MiRegistryItem { - @PrimaryColumn(id()) - public id: string; - - @Column('timestamp with time zone', { - comment: 'The updated date of the RegistryItem.', - }) - public updatedAt: Date; - - @Index() - @Column({ - ...id(), - comment: 'The owner ID.', - }) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column('varchar', { - length: 1024, - comment: 'The key of the RegistryItem.', - }) - public key: string; - - @Column('jsonb', { - default: {}, nullable: true, - comment: 'The value of the RegistryItem.', - }) - public value: any | null; - - @Index() - @Column('varchar', { - length: 1024, array: true, default: '{}', - }) - public scope: string[]; - - // サードパーティアプリに開放するときのためのカラム - @Index() - @Column('varchar', { - length: 512, nullable: true, - }) - public domain: string | null; -} diff --git a/packages/backend/src/models/Relay.ts b/packages/backend/src/models/Relay.ts deleted file mode 100644 index 293fccecfc..0000000000 --- a/packages/backend/src/models/Relay.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, Column } from 'typeorm'; -import { id } from './util/id.js'; - -@Entity('relay') -export class MiRelay { - @PrimaryColumn(id()) - public id: string; - - @Index({ unique: true }) - @Column('varchar', { - length: 512, nullable: false, - }) - public inbox: string; - - @Column('enum', { - enum: ['requesting', 'accepted', 'rejected'], - }) - public status: 'requesting' | 'accepted' | 'rejected'; -} diff --git a/packages/backend/src/models/RenoteMuting.ts b/packages/backend/src/models/RenoteMuting.ts deleted file mode 100644 index 17df43ea31..0000000000 --- a/packages/backend/src/models/RenoteMuting.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('renote_muting') -@Index(['muterId', 'muteeId'], { unique: true }) -export class MiRenoteMuting { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: 'The mutee user ID.', - }) - public muteeId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public mutee: MiUser | null; - - @Index() - @Column({ - ...id(), - comment: 'The muter user ID.', - }) - public muterId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public muter: MiUser | null; -} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts deleted file mode 100644 index 0b5d3b640f..0000000000 --- a/packages/backend/src/models/RepositoryModule.ts +++ /dev/null @@ -1,548 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Module } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiAvatarDecoration, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, NoteEdit } from './_.js'; -import type { DataSource } from 'typeorm'; -import type { Provider } from '@nestjs/common'; - -const $usersRepository: Provider = { - provide: DI.usersRepository, - useFactory: (db: DataSource) => db.getRepository(MiUser), - inject: [DI.db], -}; - -const $notesRepository: Provider = { - provide: DI.notesRepository, - useFactory: (db: DataSource) => db.getRepository(MiNote), - inject: [DI.db], -}; - -const $announcementsRepository: Provider = { - provide: DI.announcementsRepository, - useFactory: (db: DataSource) => db.getRepository(MiAnnouncement), - inject: [DI.db], -}; - -const $announcementReadsRepository: Provider = { - provide: DI.announcementReadsRepository, - useFactory: (db: DataSource) => db.getRepository(MiAnnouncementRead), - inject: [DI.db], -}; - -const $appsRepository: Provider = { - provide: DI.appsRepository, - useFactory: (db: DataSource) => db.getRepository(MiApp), - inject: [DI.db], -}; - -const $avatarDecorationsRepository: Provider = { - provide: DI.avatarDecorationsRepository, - useFactory: (db: DataSource) => db.getRepository(MiAvatarDecoration), - inject: [DI.db], -}; - -const $noteFavoritesRepository: Provider = { - provide: DI.noteFavoritesRepository, - useFactory: (db: DataSource) => db.getRepository(MiNoteFavorite), - inject: [DI.db], -}; - -const $noteThreadMutingsRepository: Provider = { - provide: DI.noteThreadMutingsRepository, - useFactory: (db: DataSource) => db.getRepository(MiNoteThreadMuting), - inject: [DI.db], -}; - -const $noteReactionsRepository: Provider = { - provide: DI.noteReactionsRepository, - useFactory: (db: DataSource) => db.getRepository(MiNoteReaction), - inject: [DI.db], -}; - -const $noteUnreadsRepository: Provider = { - provide: DI.noteUnreadsRepository, - useFactory: (db: DataSource) => db.getRepository(MiNoteUnread), - inject: [DI.db], -}; - -const $pollsRepository: Provider = { - provide: DI.pollsRepository, - useFactory: (db: DataSource) => db.getRepository(MiPoll), - inject: [DI.db], -}; - -const $pollVotesRepository: Provider = { - provide: DI.pollVotesRepository, - useFactory: (db: DataSource) => db.getRepository(MiPollVote), - inject: [DI.db], -}; - -const $userProfilesRepository: Provider = { - provide: DI.userProfilesRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserProfile), - inject: [DI.db], -}; - -const $userKeypairsRepository: Provider = { - provide: DI.userKeypairsRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserKeypair), - inject: [DI.db], -}; - -const $userPendingsRepository: Provider = { - provide: DI.userPendingsRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserPending), - inject: [DI.db], -}; - -const $userSecurityKeysRepository: Provider = { - provide: DI.userSecurityKeysRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserSecurityKey), - inject: [DI.db], -}; - -const $userPublickeysRepository: Provider = { - provide: DI.userPublickeysRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserPublickey), - inject: [DI.db], -}; - -const $userListsRepository: Provider = { - provide: DI.userListsRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserList), - inject: [DI.db], -}; - -const $userListFavoritesRepository: Provider = { - provide: DI.userListFavoritesRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserListFavorite), - inject: [DI.db], -}; - -const $userListMembershipsRepository: Provider = { - provide: DI.userListMembershipsRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserListMembership), - inject: [DI.db], -}; - -const $userNotePiningsRepository: Provider = { - provide: DI.userNotePiningsRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserNotePining), - inject: [DI.db], -}; - -const $userIpsRepository: Provider = { - provide: DI.userIpsRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserIp), - inject: [DI.db], -}; - -const $usedUsernamesRepository: Provider = { - provide: DI.usedUsernamesRepository, - useFactory: (db: DataSource) => db.getRepository(MiUsedUsername), - inject: [DI.db], -}; - -const $followingsRepository: Provider = { - provide: DI.followingsRepository, - useFactory: (db: DataSource) => db.getRepository(MiFollowing), - inject: [DI.db], -}; - -const $followRequestsRepository: Provider = { - provide: DI.followRequestsRepository, - useFactory: (db: DataSource) => db.getRepository(MiFollowRequest), - inject: [DI.db], -}; - -const $instancesRepository: Provider = { - provide: DI.instancesRepository, - useFactory: (db: DataSource) => db.getRepository(MiInstance), - inject: [DI.db], -}; - -const $emojisRepository: Provider = { - provide: DI.emojisRepository, - useFactory: (db: DataSource) => db.getRepository(MiEmoji), - inject: [DI.db], -}; - -const $driveFilesRepository: Provider = { - provide: DI.driveFilesRepository, - useFactory: (db: DataSource) => db.getRepository(MiDriveFile), - inject: [DI.db], -}; - -const $driveFoldersRepository: Provider = { - provide: DI.driveFoldersRepository, - useFactory: (db: DataSource) => db.getRepository(MiDriveFolder), - inject: [DI.db], -}; - -const $metasRepository: Provider = { - provide: DI.metasRepository, - useFactory: (db: DataSource) => db.getRepository(MiMeta), - inject: [DI.db], -}; - -const $mutingsRepository: Provider = { - provide: DI.mutingsRepository, - useFactory: (db: DataSource) => db.getRepository(MiMuting), - inject: [DI.db], -}; - -const $renoteMutingsRepository: Provider = { - provide: DI.renoteMutingsRepository, - useFactory: (db: DataSource) => db.getRepository(MiRenoteMuting), - inject: [DI.db], -}; - -const $blockingsRepository: Provider = { - provide: DI.blockingsRepository, - useFactory: (db: DataSource) => db.getRepository(MiBlocking), - inject: [DI.db], -}; - -const $swSubscriptionsRepository: Provider = { - provide: DI.swSubscriptionsRepository, - useFactory: (db: DataSource) => db.getRepository(MiSwSubscription), - inject: [DI.db], -}; - -const $hashtagsRepository: Provider = { - provide: DI.hashtagsRepository, - useFactory: (db: DataSource) => db.getRepository(MiHashtag), - inject: [DI.db], -}; - -const $abuseUserReportsRepository: Provider = { - provide: DI.abuseUserReportsRepository, - useFactory: (db: DataSource) => db.getRepository(MiAbuseUserReport), - inject: [DI.db], -}; - -const $registrationTicketsRepository: Provider = { - provide: DI.registrationTicketsRepository, - useFactory: (db: DataSource) => db.getRepository(MiRegistrationTicket), - inject: [DI.db], -}; - -const $authSessionsRepository: Provider = { - provide: DI.authSessionsRepository, - useFactory: (db: DataSource) => db.getRepository(MiAuthSession), - inject: [DI.db], -}; - -const $accessTokensRepository: Provider = { - provide: DI.accessTokensRepository, - useFactory: (db: DataSource) => db.getRepository(MiAccessToken), - inject: [DI.db], -}; - -const $signinsRepository: Provider = { - provide: DI.signinsRepository, - useFactory: (db: DataSource) => db.getRepository(MiSignin), - inject: [DI.db], -}; - -const $pagesRepository: Provider = { - provide: DI.pagesRepository, - useFactory: (db: DataSource) => db.getRepository(MiPage), - inject: [DI.db], -}; - -const $pageLikesRepository: Provider = { - provide: DI.pageLikesRepository, - useFactory: (db: DataSource) => db.getRepository(MiPageLike), - inject: [DI.db], -}; - -const $galleryPostsRepository: Provider = { - provide: DI.galleryPostsRepository, - useFactory: (db: DataSource) => db.getRepository(MiGalleryPost), - inject: [DI.db], -}; - -const $galleryLikesRepository: Provider = { - provide: DI.galleryLikesRepository, - useFactory: (db: DataSource) => db.getRepository(MiGalleryLike), - inject: [DI.db], -}; - -const $moderationLogsRepository: Provider = { - provide: DI.moderationLogsRepository, - useFactory: (db: DataSource) => db.getRepository(MiModerationLog), - inject: [DI.db], -}; - -const $clipsRepository: Provider = { - provide: DI.clipsRepository, - useFactory: (db: DataSource) => db.getRepository(MiClip), - inject: [DI.db], -}; - -const $clipNotesRepository: Provider = { - provide: DI.clipNotesRepository, - useFactory: (db: DataSource) => db.getRepository(MiClipNote), - inject: [DI.db], -}; - -const $clipFavoritesRepository: Provider = { - provide: DI.clipFavoritesRepository, - useFactory: (db: DataSource) => db.getRepository(MiClipFavorite), - inject: [DI.db], -}; - -const $antennasRepository: Provider = { - provide: DI.antennasRepository, - useFactory: (db: DataSource) => db.getRepository(MiAntenna), - inject: [DI.db], -}; - -const $promoNotesRepository: Provider = { - provide: DI.promoNotesRepository, - useFactory: (db: DataSource) => db.getRepository(MiPromoNote), - inject: [DI.db], -}; - -const $promoReadsRepository: Provider = { - provide: DI.promoReadsRepository, - useFactory: (db: DataSource) => db.getRepository(MiPromoRead), - inject: [DI.db], -}; - -const $relaysRepository: Provider = { - provide: DI.relaysRepository, - useFactory: (db: DataSource) => db.getRepository(MiRelay), - inject: [DI.db], -}; - -const $channelsRepository: Provider = { - provide: DI.channelsRepository, - useFactory: (db: DataSource) => db.getRepository(MiChannel), - inject: [DI.db], -}; - -const $channelFollowingsRepository: Provider = { - provide: DI.channelFollowingsRepository, - useFactory: (db: DataSource) => db.getRepository(MiChannelFollowing), - inject: [DI.db], -}; - -const $channelFavoritesRepository: Provider = { - provide: DI.channelFavoritesRepository, - useFactory: (db: DataSource) => db.getRepository(MiChannelFavorite), - inject: [DI.db], -}; - -const $registryItemsRepository: Provider = { - provide: DI.registryItemsRepository, - useFactory: (db: DataSource) => db.getRepository(MiRegistryItem), - inject: [DI.db], -}; - -const $webhooksRepository: Provider = { - provide: DI.webhooksRepository, - useFactory: (db: DataSource) => db.getRepository(MiWebhook), - inject: [DI.db], -}; - -const $adsRepository: Provider = { - provide: DI.adsRepository, - useFactory: (db: DataSource) => db.getRepository(MiAd), - inject: [DI.db], -}; - -const $passwordResetRequestsRepository: Provider = { - provide: DI.passwordResetRequestsRepository, - useFactory: (db: DataSource) => db.getRepository(MiPasswordResetRequest), - inject: [DI.db], -}; - -const $retentionAggregationsRepository: Provider = { - provide: DI.retentionAggregationsRepository, - useFactory: (db: DataSource) => db.getRepository(MiRetentionAggregation), - inject: [DI.db], -}; - -const $flashsRepository: Provider = { - provide: DI.flashsRepository, - useFactory: (db: DataSource) => db.getRepository(MiFlash), - inject: [DI.db], -}; - -const $flashLikesRepository: Provider = { - provide: DI.flashLikesRepository, - useFactory: (db: DataSource) => db.getRepository(MiFlashLike), - inject: [DI.db], -}; - -const $rolesRepository: Provider = { - provide: DI.rolesRepository, - useFactory: (db: DataSource) => db.getRepository(MiRole), - inject: [DI.db], -}; - -const $roleAssignmentsRepository: Provider = { - provide: DI.roleAssignmentsRepository, - useFactory: (db: DataSource) => db.getRepository(MiRoleAssignment), - inject: [DI.db], -}; - -const $userMemosRepository: Provider = { - provide: DI.userMemosRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserMemo), - inject: [DI.db], -}; - -const $noteEditRepository: Provider = { - provide: DI.noteEditRepository, - useFactory: (db: DataSource) => db.getRepository(NoteEdit), - inject: [DI.db], -}; - -@Module({ - imports: [ - ], - providers: [ - $usersRepository, - $notesRepository, - $announcementsRepository, - $announcementReadsRepository, - $appsRepository, - $avatarDecorationsRepository, - $noteFavoritesRepository, - $noteThreadMutingsRepository, - $noteReactionsRepository, - $noteUnreadsRepository, - $pollsRepository, - $pollVotesRepository, - $userProfilesRepository, - $userKeypairsRepository, - $userPendingsRepository, - $userSecurityKeysRepository, - $userPublickeysRepository, - $userListsRepository, - $userListFavoritesRepository, - $userListMembershipsRepository, - $userNotePiningsRepository, - $userIpsRepository, - $usedUsernamesRepository, - $followingsRepository, - $followRequestsRepository, - $instancesRepository, - $emojisRepository, - $driveFilesRepository, - $driveFoldersRepository, - $metasRepository, - $mutingsRepository, - $renoteMutingsRepository, - $blockingsRepository, - $swSubscriptionsRepository, - $hashtagsRepository, - $abuseUserReportsRepository, - $registrationTicketsRepository, - $authSessionsRepository, - $accessTokensRepository, - $signinsRepository, - $pagesRepository, - $pageLikesRepository, - $galleryPostsRepository, - $galleryLikesRepository, - $moderationLogsRepository, - $clipsRepository, - $clipNotesRepository, - $clipFavoritesRepository, - $antennasRepository, - $promoNotesRepository, - $promoReadsRepository, - $relaysRepository, - $channelsRepository, - $channelFollowingsRepository, - $channelFavoritesRepository, - $registryItemsRepository, - $webhooksRepository, - $adsRepository, - $passwordResetRequestsRepository, - $retentionAggregationsRepository, - $rolesRepository, - $roleAssignmentsRepository, - $flashsRepository, - $flashLikesRepository, - $userMemosRepository, - $noteEditRepository, - ], - exports: [ - $usersRepository, - $notesRepository, - $announcementsRepository, - $announcementReadsRepository, - $appsRepository, - $avatarDecorationsRepository, - $noteFavoritesRepository, - $noteThreadMutingsRepository, - $noteReactionsRepository, - $noteUnreadsRepository, - $pollsRepository, - $pollVotesRepository, - $userProfilesRepository, - $userKeypairsRepository, - $userPendingsRepository, - $userSecurityKeysRepository, - $userPublickeysRepository, - $userListsRepository, - $userListFavoritesRepository, - $userListMembershipsRepository, - $userNotePiningsRepository, - $userIpsRepository, - $usedUsernamesRepository, - $followingsRepository, - $followRequestsRepository, - $instancesRepository, - $emojisRepository, - $driveFilesRepository, - $driveFoldersRepository, - $metasRepository, - $mutingsRepository, - $renoteMutingsRepository, - $blockingsRepository, - $swSubscriptionsRepository, - $hashtagsRepository, - $abuseUserReportsRepository, - $registrationTicketsRepository, - $authSessionsRepository, - $accessTokensRepository, - $signinsRepository, - $pagesRepository, - $pageLikesRepository, - $galleryPostsRepository, - $galleryLikesRepository, - $moderationLogsRepository, - $clipsRepository, - $clipNotesRepository, - $clipFavoritesRepository, - $antennasRepository, - $promoNotesRepository, - $promoReadsRepository, - $relaysRepository, - $channelsRepository, - $channelFollowingsRepository, - $channelFavoritesRepository, - $registryItemsRepository, - $webhooksRepository, - $adsRepository, - $passwordResetRequestsRepository, - $retentionAggregationsRepository, - $rolesRepository, - $roleAssignmentsRepository, - $flashsRepository, - $flashLikesRepository, - $userMemosRepository, - $noteEditRepository, - ], -}) -export class RepositoryModule {} diff --git a/packages/backend/src/models/RetentionAggregation.ts b/packages/backend/src/models/RetentionAggregation.ts deleted file mode 100644 index 9da401597c..0000000000 --- a/packages/backend/src/models/RetentionAggregation.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, PrimaryColumn, Index, Column } from 'typeorm'; -import { id } from './util/id.js'; -import type { MiUser } from './User.js'; - -@Entity('retention_aggregation') -export class MiRetentionAggregation { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column('timestamp with time zone', { - comment: 'The created date of the Note.', - }) - public createdAt: Date; - - @Column('timestamp with time zone', { - comment: 'The updated date of the GalleryPost.', - }) - public updatedAt: Date; - - @Index({ unique: true }) - @Column('varchar', { - length: 512, nullable: false, - }) - public dateKey: string; - - @Column({ - ...id(), - array: true, - }) - public userIds: MiUser['id'][]; - - @Column('integer', { - }) - public usersCount: number; - - @Column('jsonb', { - default: {}, - }) - public data: Record; -} diff --git a/packages/backend/src/models/Role.ts b/packages/backend/src/models/Role.ts deleted file mode 100644 index 6976956e13..0000000000 --- a/packages/backend/src/models/Role.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, Column, PrimaryColumn } from 'typeorm'; -import { id } from './util/id.js'; - -type CondFormulaValueAnd = { - type: 'and'; - values: RoleCondFormulaValue[]; -}; - -type CondFormulaValueOr = { - type: 'or'; - values: RoleCondFormulaValue[]; -}; - -type CondFormulaValueNot = { - type: 'not'; - value: RoleCondFormulaValue; -}; - -type CondFormulaValueIsLocal = { - type: 'isLocal'; -}; - -type CondFormulaValueIsRemote = { - type: 'isRemote'; -}; - -type CondFormulaValueCreatedLessThan = { - type: 'createdLessThan'; - sec: number; -}; - -type CondFormulaValueCreatedMoreThan = { - type: 'createdMoreThan'; - sec: number; -}; - -type CondFormulaValueFollowersLessThanOrEq = { - type: 'followersLessThanOrEq'; - value: number; -}; - -type CondFormulaValueFollowersMoreThanOrEq = { - type: 'followersMoreThanOrEq'; - value: number; -}; - -type CondFormulaValueFollowingLessThanOrEq = { - type: 'followingLessThanOrEq'; - value: number; -}; - -type CondFormulaValueFollowingMoreThanOrEq = { - type: 'followingMoreThanOrEq'; - value: number; -}; - -type CondFormulaValueNotesLessThanOrEq = { - type: 'notesLessThanOrEq'; - value: number; -}; - -type CondFormulaValueNotesMoreThanOrEq = { - type: 'notesMoreThanOrEq'; - value: number; -}; - -export type RoleCondFormulaValue = - CondFormulaValueAnd | - CondFormulaValueOr | - CondFormulaValueNot | - CondFormulaValueIsLocal | - CondFormulaValueIsRemote | - CondFormulaValueCreatedLessThan | - CondFormulaValueCreatedMoreThan | - CondFormulaValueFollowersLessThanOrEq | - CondFormulaValueFollowersMoreThanOrEq | - CondFormulaValueFollowingLessThanOrEq | - CondFormulaValueFollowingMoreThanOrEq | - CondFormulaValueNotesLessThanOrEq | - CondFormulaValueNotesMoreThanOrEq; - -@Entity('role') -export class MiRole { - @PrimaryColumn(id()) - public id: string; - - @Column('timestamp with time zone', { - comment: 'The updated date of the Role.', - }) - public updatedAt: Date; - - @Column('timestamp with time zone', { - comment: 'The last used date of the Role.', - }) - public lastUsedAt: Date; - - @Column('varchar', { - length: 256, - }) - public name: string; - - @Column('varchar', { - length: 1024, - }) - public description: string; - - @Column('varchar', { - length: 256, nullable: true, - }) - public color: string | null; - - @Column('varchar', { - length: 512, nullable: true, - }) - public iconUrl: string | null; - - @Column('enum', { - enum: ['manual', 'conditional'], - default: 'manual', - }) - public target: 'manual' | 'conditional'; - - @Column('jsonb', { - default: { }, - }) - public condFormula: RoleCondFormulaValue; - - @Column('boolean', { - default: false, - }) - public isPublic: boolean; - - // trueの場合ユーザー名の横にバッジとして表示 - @Column('boolean', { - default: false, - }) - public asBadge: boolean; - - @Column('boolean', { - default: false, - }) - public isModerator: boolean; - - @Column('boolean', { - default: false, - }) - public isAdministrator: boolean; - - @Column('boolean', { - default: false, - }) - public isExplorable: boolean; - - @Column('boolean', { - default: false, - }) - public canEditMembersByModerator: boolean; - - // UIに表示する際の並び順用(大きいほど先頭) - @Column('integer', { - default: 0, - }) - public displayOrder: number; - - @Column('jsonb', { - default: { }, - }) - public policies: Record; -} diff --git a/packages/backend/src/models/RoleAssignment.ts b/packages/backend/src/models/RoleAssignment.ts deleted file mode 100644 index 30c7e19f2a..0000000000 --- a/packages/backend/src/models/RoleAssignment.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiRole } from './Role.js'; -import { MiUser } from './User.js'; - -@Entity('role_assignment') -@Index(['userId', 'roleId'], { unique: true }) -export class MiRoleAssignment { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: 'The user ID.', - }) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Index() - @Column({ - ...id(), - comment: 'The role ID.', - }) - public roleId: MiRole['id']; - - @ManyToOne(type => MiRole, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public role: MiRole | null; - - @Index() - @Column('timestamp with time zone', { - nullable: true, - }) - public expiresAt: Date | null; -} diff --git a/packages/backend/src/models/Signin.ts b/packages/backend/src/models/Signin.ts deleted file mode 100644 index 656b44dfe0..0000000000 --- a/packages/backend/src/models/Signin.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('signin') -export class MiSignin { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column('varchar', { - length: 128, - }) - public ip: string; - - @Column('jsonb') - public headers: Record; - - @Column('boolean') - public success: boolean; -} diff --git a/packages/backend/src/models/SwSubscription.ts b/packages/backend/src/models/SwSubscription.ts deleted file mode 100644 index f685a8ff3e..0000000000 --- a/packages/backend/src/models/SwSubscription.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('sw_subscription') -export class MiSwSubscription { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column('varchar', { - length: 512, - }) - public endpoint: string; - - @Column('varchar', { - length: 256, - }) - public auth: string; - - @Column('varchar', { - length: 128, - }) - public publickey: string; - - @Column('boolean', { - default: false, - }) - public sendReadMessage: boolean; -} diff --git a/packages/backend/src/models/UsedUsername.ts b/packages/backend/src/models/UsedUsername.ts deleted file mode 100644 index c75bf424c1..0000000000 --- a/packages/backend/src/models/UsedUsername.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Column } from 'typeorm'; - -@Entity('used_username') -export class MiUsedUsername { - @PrimaryColumn('varchar', { - length: 128, - }) - public username: string; - - @Column('timestamp with time zone') - public createdAt: Date; - - constructor(data: Partial) { - if (data == null) return; - - for (const [k, v] of Object.entries(data)) { - (this as any)[k] = v; - } - } -} diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts deleted file mode 100644 index 3db8b398fd..0000000000 --- a/packages/backend/src/models/User.ts +++ /dev/null @@ -1,345 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; -import { id } from './util/id.js'; -import { MiDriveFile } from './DriveFile.js'; - -@Entity('user') -@Index(['usernameLower', 'host'], { unique: true }) -export class MiUser { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column('timestamp with time zone', { - nullable: true, - comment: 'The updated date of the User.', - }) - public updatedAt: Date | null; - - @Column('timestamp with time zone', { - nullable: true, - }) - public lastFetchedAt: Date | null; - - @Index() - @Column('timestamp with time zone', { - nullable: true, - }) - public lastActiveDate: Date | null; - - @Column('boolean', { - default: false, - }) - public hideOnlineStatus: boolean; - - @Column('varchar', { - length: 128, - comment: 'The username of the User.', - }) - public username: string; - - @Index() - @Column('varchar', { - length: 128, select: false, - comment: 'The username (lowercased) of the User.', - }) - public usernameLower: string; - - @Column('varchar', { - length: 128, nullable: true, - comment: 'The name of the User.', - }) - public name: string | null; - - @Column('integer', { - default: 0, - comment: 'The count of followers.', - }) - public followersCount: number; - - @Column('integer', { - default: 0, - comment: 'The count of following.', - }) - public followingCount: number; - - @Column('varchar', { - length: 512, - nullable: true, - comment: 'The URI of the new account of the User', - }) - public movedToUri: string | null; - - @Column('timestamp with time zone', { - nullable: true, - comment: 'When the user moved to another account', - }) - public movedAt: Date | null; - - @Column('simple-array', { - nullable: true, - comment: 'URIs the user is known as too', - }) - public alsoKnownAs: string[] | null; - - @Column('integer', { - default: 0, - comment: 'The count of notes.', - }) - public notesCount: number; - - @Column({ - ...id(), - nullable: true, - comment: 'The ID of avatar DriveFile.', - }) - public avatarId: MiDriveFile['id'] | null; - - @OneToOne(type => MiDriveFile, { - onDelete: 'SET NULL', - }) - @JoinColumn() - public avatar: MiDriveFile | null; - - @Column({ - ...id(), - nullable: true, - comment: 'The ID of banner DriveFile.', - }) - public bannerId: MiDriveFile['id'] | null; - - @OneToOne(type => MiDriveFile, { - onDelete: 'SET NULL', - }) - @JoinColumn() - public banner: MiDriveFile | null; - - @Column({ - ...id(), - nullable: true, - comment: 'The ID of background DriveFile.', - }) - public backgroundId: MiDriveFile['id'] | null; - - @OneToOne(type => MiDriveFile, { - onDelete: 'SET NULL', - }) - @JoinColumn() - public background: MiDriveFile | null; - - @Column('varchar', { - length: 512, nullable: true, - }) - public avatarUrl: string | null; - - @Column('varchar', { - length: 512, nullable: true, - }) - public bannerUrl: string | null; - - @Column('varchar', { - length: 512, nullable: true, - }) - public backgroundUrl: string | null; - - @Column('varchar', { - length: 128, nullable: true, - }) - public avatarBlurhash: string | null; - - @Column('varchar', { - length: 128, nullable: true, - }) - public bannerBlurhash: string | null; - - @Column('varchar', { - length: 128, nullable: true, - }) - public backgroundBlurhash: string | null; - - @Column('jsonb', { - default: [], - }) - public avatarDecorations: { - id: string; - angle?: number; - flipH?: boolean; - offsetX?: number; - offsetY?: number; - }[]; - - @Index() - @Column('varchar', { - length: 128, array: true, default: '{}', - }) - public tags: string[]; - - @Column('boolean', { - default: false, - comment: 'Whether the User is suspended.', - }) - public isSuspended: boolean; - - @Column('boolean', { - default: false, - comment: 'Whether the User is silenced.', - }) - public isSilenced: boolean; - - @Column('boolean', { - default: false, - comment: 'Whether the User\'s notes dont get indexed.', - }) - public noindex: boolean; - - @Column('boolean', { - default: false, - comment: 'Whether the User is locked.', - }) - public isLocked: boolean; - - @Column('boolean', { - default: false, - comment: 'Whether the User is a bot.', - }) - public isBot: boolean; - - @Column('boolean', { - default: false, - comment: 'Whether the User is a cat.', - }) - public isCat: boolean; - - @Column('boolean', { - default: true, - comment: 'Whether the User speaks in nya.', - }) - public speakAsCat: boolean; - - @Column('boolean', { - default: false, - comment: 'Whether the User is the root.', - }) - public isRoot: boolean; - - @Index() - @Column('boolean', { - default: true, - comment: 'Whether the User is explorable.', - }) - public isExplorable: boolean; - - @Column('boolean', { - default: false, - }) - public isHibernated: boolean; - - // アカウントが削除されたかどうかのフラグだが、完全に削除される際は物理削除なので実質削除されるまでの「削除が進行しているかどうか」のフラグ - @Column('boolean', { - default: false, - comment: 'Whether the User is deleted.', - }) - public isDeleted: boolean; - - @Column('varchar', { - length: 128, array: true, default: '{}', - }) - public emojis: string[]; - - @Index() - @Column('varchar', { - length: 128, nullable: true, - comment: 'The host of the User. It will be null if the origin of the user is local.', - }) - public host: string | null; - - @Column('varchar', { - length: 512, nullable: true, - comment: 'The inbox URL of the User. It will be null if the origin of the user is local.', - }) - public inbox: string | null; - - @Column('varchar', { - length: 512, nullable: true, - comment: 'The sharedInbox URL of the User. It will be null if the origin of the user is local.', - }) - public sharedInbox: string | null; - - @Column('varchar', { - length: 512, nullable: true, - comment: 'The featured URL of the User. It will be null if the origin of the user is local.', - }) - public featured: string | null; - - @Index() - @Column('varchar', { - length: 512, nullable: true, - comment: 'The URI of the User. It will be null if the origin of the user is local.', - }) - public uri: string | null; - - @Column('varchar', { - length: 512, nullable: true, - comment: 'The URI of the user Follower Collection. It will be null if the origin of the user is local.', - }) - public followersUri: string | null; - - @Index({ unique: true }) - @Column('char', { - length: 16, nullable: true, unique: true, - comment: 'The native access token of the User. It will be null if the origin of the user is local.', - }) - public token: string | null; - - @Column('boolean', { - default: false, - }) - public approved: boolean; - - @Column('varchar', { - length: 1000, nullable: true, - }) - public signupReason: string | null; - - constructor(data: Partial) { - if (data == null) return; - - for (const [k, v] of Object.entries(data)) { - (this as any)[k] = v; - } - } -} - -export type MiLocalUser = MiUser & { - host: null; - uri: null; -} - -export type MiPartialLocalUser = Partial & { - id: MiUser['id']; - host: null; - uri: null; -} - -export type MiRemoteUser = MiUser & { - host: string; - uri: string; -} - -export type MiPartialRemoteUser = Partial & { - id: MiUser['id']; - host: string; - uri: string; -} - -export const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const; -export const passwordSchema = { type: 'string', minLength: 1 } as const; -export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; -export const descriptionSchema = { type: 'string', minLength: 1, maxLength: 1500 } as const; -export const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; -export const listenbrainzSchema = { type: "string", minLength: 1, maxLength: 128 } as const; -export const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1) } as const; diff --git a/packages/backend/src/models/UserIp.ts b/packages/backend/src/models/UserIp.ts deleted file mode 100644 index 60a7bc8b01..0000000000 --- a/packages/backend/src/models/UserIp.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, Index, Column, PrimaryGeneratedColumn } from 'typeorm'; -import { id } from './util/id.js'; -import type { MiUser } from './User.js'; - -@Entity('user_ip') -@Index(['userId', 'ip'], { unique: true }) -export class MiUserIp { - @PrimaryGeneratedColumn() - public id: string; - - @Column('timestamp with time zone', { - }) - public createdAt: Date; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @Column('varchar', { - length: 128, - }) - public ip: string; -} diff --git a/packages/backend/src/models/UserKeypair.ts b/packages/backend/src/models/UserKeypair.ts deleted file mode 100644 index a316dbaeb4..0000000000 --- a/packages/backend/src/models/UserKeypair.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, JoinColumn, Column, OneToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('user_keypair') -export class MiUserKeypair { - @PrimaryColumn(id()) - public userId: MiUser['id']; - - @OneToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column('varchar', { - length: 4096, - }) - public publicKey: string; - - @Column('varchar', { - length: 4096, - }) - public privateKey: string; - - constructor(data: Partial) { - if (data == null) return; - - for (const [k, v] of Object.entries(data)) { - (this as any)[k] = v; - } - } -} diff --git a/packages/backend/src/models/UserList.ts b/packages/backend/src/models/UserList.ts deleted file mode 100644 index 7ad15419d7..0000000000 --- a/packages/backend/src/models/UserList.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('user_list') -export class MiUserList { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: 'The owner ID.', - }) - public userId: MiUser['id']; - - @Index() - @Column('boolean', { - default: false, - }) - public isPublic: boolean; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column('varchar', { - length: 128, - comment: 'The name of the UserList.', - }) - public name: string; -} diff --git a/packages/backend/src/models/UserListFavorite.ts b/packages/backend/src/models/UserListFavorite.ts deleted file mode 100644 index a18ed9253a..0000000000 --- a/packages/backend/src/models/UserListFavorite.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiUserList } from './UserList.js'; - -@Entity('user_list_favorite') -@Index(['userId', 'userListId'], { unique: true }) -export class MiUserListFavorite { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column(id()) - public userListId: MiUserList['id']; - - @ManyToOne(type => MiUserList, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public userList: MiUserList | null; -} diff --git a/packages/backend/src/models/UserListMembership.ts b/packages/backend/src/models/UserListMembership.ts deleted file mode 100644 index fa8287f17a..0000000000 --- a/packages/backend/src/models/UserListMembership.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiUserList } from './UserList.js'; - -@Entity('user_list_membership') -@Index(['userId', 'userListId'], { unique: true }) -export class MiUserListMembership { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: 'The user ID.', - }) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Index() - @Column({ - ...id(), - comment: 'The list ID.', - }) - public userListId: MiUserList['id']; - - @ManyToOne(type => MiUserList, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public userList: MiUserList | null; - - // タイムラインにその人のリプライまで含めるかどうか - @Column('boolean', { - default: false, - }) - public withReplies: boolean; - - //#region Denormalized fields - @Column({ - ...id(), - }) - public userListUserId: MiUser['id']; - //#endregion -} diff --git a/packages/backend/src/models/UserMemo.ts b/packages/backend/src/models/UserMemo.ts deleted file mode 100644 index ab5e812c44..0000000000 --- a/packages/backend/src/models/UserMemo.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('user_memo') -@Index(['userId', 'targetUserId'], { unique: true }) -export class MiUserMemo { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: 'The ID of author.', - }) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Index() - @Column({ - ...id(), - comment: 'The ID of target user.', - }) - public targetUserId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public targetUser: MiUser | null; - - @Column('varchar', { - length: 2048, - comment: 'Memo.', - }) - public memo: string; -} diff --git a/packages/backend/src/models/UserNotePining.ts b/packages/backend/src/models/UserNotePining.ts deleted file mode 100644 index ae5977aa56..0000000000 --- a/packages/backend/src/models/UserNotePining.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiNote } from './Note.js'; -import { MiUser } from './User.js'; - -@Entity('user_note_pining') -@Index(['userId', 'noteId'], { unique: true }) -export class MiUserNotePining { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column(id()) - public noteId: MiNote['id']; - - @ManyToOne(type => MiNote, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: MiNote | null; -} diff --git a/packages/backend/src/models/UserPending.ts b/packages/backend/src/models/UserPending.ts deleted file mode 100644 index 6b26bd228c..0000000000 --- a/packages/backend/src/models/UserPending.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, Column } from 'typeorm'; -import { id } from './util/id.js'; - -@Entity('user_pending') -export class MiUserPending { - @PrimaryColumn(id()) - public id: string; - - @Index({ unique: true }) - @Column('varchar', { - length: 128, - }) - public code: string; - - @Column('varchar', { - length: 128, - }) - public username: string; - - @Column('varchar', { - length: 128, - }) - public email: string; - - @Column('varchar', { - length: 128, - }) - public password: string; - - @Column('varchar', { - length: 1000, - }) - public reason: string; -} diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts deleted file mode 100644 index ae46fbc83c..0000000000 --- a/packages/backend/src/models/UserProfile.ts +++ /dev/null @@ -1,294 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; -import { obsoleteNotificationTypes, followingVisibilities, followersVisibilities, notificationTypes } from '@/types.js'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiPage } from './Page.js'; -import { MiUserList } from './UserList.js'; - -// TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも -// ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン -@Entity('user_profile') -export class MiUserProfile { - @PrimaryColumn(id()) - public userId: MiUser['id']; - - @OneToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column('varchar', { - length: 128, nullable: true, - comment: 'The location of the User.', - }) - public location: string | null; - - @Index() - @Column('char', { - length: 10, nullable: true, - comment: 'The birthday (YYYY-MM-DD) of the User.', - }) - public birthday: string | null; - - @Column("varchar", { - length: 128, - nullable: true, - comment: "The ListenBrainz username of the User.", - }) - public listenbrainz: string | null; - - @Column('varchar', { - length: 2048, nullable: true, - comment: 'The description (bio) of the User.', - }) - public description: string | null; - - @Column('jsonb', { - default: [], - }) - public fields: { - name: string; - value: string; - }[]; - - @Column('varchar', { - array: true, - default: '{}', - }) - public verifiedLinks: string[]; - - @Column('varchar', { - length: 32, nullable: true, - }) - public lang: string | null; - - @Column('varchar', { - length: 512, nullable: true, - comment: 'Remote URL of the user.', - }) - public url: string | null; - - @Column('varchar', { - length: 128, nullable: true, - comment: 'The email address of the User.', - }) - public email: string | null; - - @Column('varchar', { - length: 128, nullable: true, - }) - public emailVerifyCode: string | null; - - @Column('boolean', { - default: false, - }) - public emailVerified: boolean; - - @Column('jsonb', { - default: ['follow', 'receiveFollowRequest'], - }) - public emailNotificationTypes: string[]; - - @Column('boolean', { - default: true, - }) - public publicReactions: boolean; - - @Column('enum', { - enum: followingVisibilities, - default: 'public', - }) - public followingVisibility: typeof followingVisibilities[number]; - - @Column('enum', { - enum: followersVisibilities, - default: 'public', - }) - public followersVisibility: typeof followersVisibilities[number]; - - @Column('varchar', { - length: 128, nullable: true, - }) - public twoFactorTempSecret: string | null; - - @Column('varchar', { - length: 128, nullable: true, - }) - public twoFactorSecret: string | null; - - @Column('varchar', { - nullable: true, array: true, - }) - public twoFactorBackupSecret: string[] | null; - - @Column('boolean', { - default: false, - }) - public twoFactorEnabled: boolean; - - @Column('boolean', { - default: false, - }) - public securityKeysAvailable: boolean; - - @Column('boolean', { - default: false, - }) - public usePasswordLessLogin: boolean; - - @Column('varchar', { - length: 128, nullable: true, - comment: 'The password hash of the User. It will be null if the origin of the user is local.', - }) - public password: string | null; - - @Column('varchar', { - length: 8192, default: '', - }) - public moderationNote: string | null; - - // TODO: そのうち消す - @Column('jsonb', { - default: {}, - comment: 'The client-specific data of the User.', - }) - public clientData: Record; - - // TODO: そのうち消す - @Column('jsonb', { - default: {}, - comment: 'The room data of the User.', - }) - public room: Record; - - @Column('boolean', { - default: false, - }) - public autoAcceptFollowed: boolean; - - @Column('boolean', { - default: false, - comment: 'Whether reject index by crawler.', - }) - public noCrawle: boolean; - - @Column('boolean', { - default: true, - }) - public preventAiLearning: boolean; - - @Column('boolean', { - default: false, - }) - public alwaysMarkNsfw: boolean; - - @Column('boolean', { - default: false, - }) - public autoSensitive: boolean; - - @Column('boolean', { - default: false, - }) - public carefulBot: boolean; - - @Column('boolean', { - default: true, - }) - public injectFeaturedNote: boolean; - - @Column('boolean', { - default: true, - }) - public receiveAnnouncementEmail: boolean; - - @Column({ - ...id(), - nullable: true, - }) - public pinnedPageId: MiPage['id'] | null; - - @OneToOne(type => MiPage, { - onDelete: 'SET NULL', - }) - @JoinColumn() - public pinnedPage: MiPage | null; - - @Index() - @Column('boolean', { - default: false, select: false, - }) - public enableWordMute: boolean; - - @Column('jsonb', { - default: [], - }) - public mutedWords: (string[] | string)[]; - - @Column('jsonb', { - default: [], - }) - public hardMutedWords: (string[] | string)[]; - - @Column('jsonb', { - default: [], - comment: 'List of instances muted by the user.', - }) - public mutedInstances: string[]; - - @Column('jsonb', { - default: {}, - }) - public notificationRecieveConfig: { - [notificationType in typeof notificationTypes[number]]?: { - type: 'all'; - } | { - type: 'never'; - } | { - type: 'following'; - } | { - type: 'follower'; - } | { - type: 'mutualFollow'; - } | { - type: 'list'; - userListId: MiUserList['id']; - }; - }; - - @Column('varchar', { - length: 32, array: true, default: '{}', - }) - public loggedInDates: string[]; - - @Column('jsonb', { - default: [], - }) - public achievements: { - name: string; - unlockedAt: number; - }[]; - - //#region Denormalized fields - @Index() - @Column('varchar', { - length: 128, nullable: true, - comment: '[Denormalized]', - }) - public userHost: string | null; - //#endregion - - constructor(data: Partial) { - if (data == null) return; - - for (const [k, v] of Object.entries(data)) { - (this as any)[k] = v; - } - } -} diff --git a/packages/backend/src/models/UserPublickey.ts b/packages/backend/src/models/UserPublickey.ts deleted file mode 100644 index 33de73c636..0000000000 --- a/packages/backend/src/models/UserPublickey.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, OneToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('user_publickey') -export class MiUserPublickey { - @PrimaryColumn(id()) - public userId: MiUser['id']; - - @OneToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Index({ unique: true }) - @Column('varchar', { - length: 256, - }) - public keyId: string; - - @Column('varchar', { - length: 4096, - }) - public keyPem: string; - - constructor(data: Partial) { - if (data == null) return; - - for (const [k, v] of Object.entries(data)) { - (this as any)[k] = v; - } - } -} diff --git a/packages/backend/src/models/UserSecurityKey.ts b/packages/backend/src/models/UserSecurityKey.ts deleted file mode 100644 index 02c29bfbb5..0000000000 --- a/packages/backend/src/models/UserSecurityKey.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -@Entity('user_security_key') -export class MiUserSecurityKey { - @PrimaryColumn('varchar', { - comment: 'Variable-length id given to navigator.credentials.get()', - }) - public id: string; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column('varchar', { - comment: 'User-defined name for this key', - length: 30, - }) - public name: string; - - @Index() - @Column('varchar', { - comment: 'The public key of the UserSecurityKey, hex-encoded.', - }) - public publicKey: string; - - @Column('bigint', { - comment: 'The number of times the UserSecurityKey was validated.', - default: 0, - }) - public counter: number; - - @Column('timestamp with time zone', { - comment: 'Timestamp of the last time the UserSecurityKey was used.', - default: () => 'now()', - }) - public lastUsed: Date; - - @Column('varchar', { - comment: 'The type of Backup Eligibility in authenticator data', - length: 32, nullable: true, - }) - public credentialDeviceType: string | null; - - @Column('boolean', { - comment: 'Whether or not the credential has been backed up', - nullable: true, - }) - public credentialBackedUp: boolean | null; - - @Column('varchar', { - comment: 'The type of the credential returned by the browser', - length: 32, array: true, nullable: true, - }) - public transports: string[] | null; - - constructor(data: Partial) { - if (data == null) return; - - for (const [k, v] of Object.entries(data)) { - (this as any)[k] = v; - } - } -} diff --git a/packages/backend/src/models/Webhook.ts b/packages/backend/src/models/Webhook.ts deleted file mode 100644 index ec4e13cc76..0000000000 --- a/packages/backend/src/models/Webhook.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; - -export const webhookEventTypes = ['mention', 'unfollow', 'follow', 'followed', 'note', 'reply', 'renote', 'reaction'] as const; - -@Entity('webhook') -export class MiWebhook { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: 'The owner ID.', - }) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Column('varchar', { - length: 128, - comment: 'The name of the Antenna.', - }) - public name: string; - - @Index() - @Column('varchar', { - length: 128, array: true, default: '{}', - }) - public on: (typeof webhookEventTypes)[number][]; - - @Column('varchar', { - length: 1024, - }) - public url: string; - - @Column('varchar', { - length: 1024, - }) - public secret: string; - - @Index() - @Column('boolean', { - default: true, - }) - public active: boolean; - - /** - * 直近のリクエスト送信日時 - */ - @Column('timestamp with time zone', { - nullable: true, - }) - public latestSentAt: Date | null; - - /** - * 直近のリクエスト送信時のHTTPステータスコード - */ - @Column('integer', { - nullable: true, - }) - public latestStatus: number | null; -} diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts deleted file mode 100644 index 2a7810235e..0000000000 --- a/packages/backend/src/models/_.ts +++ /dev/null @@ -1,208 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; -import { MiAccessToken } from '@/models/AccessToken.js'; -import { MiAd } from '@/models/Ad.js'; -import { MiAnnouncement } from '@/models/Announcement.js'; -import { MiAnnouncementRead } from '@/models/AnnouncementRead.js'; -import { MiAntenna } from '@/models/Antenna.js'; -import { MiApp } from '@/models/App.js'; -import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; -import { MiAuthSession } from '@/models/AuthSession.js'; -import { MiBlocking } from '@/models/Blocking.js'; -import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; -import { MiChannelFavorite } from '@/models/ChannelFavorite.js'; -import { MiClip } from '@/models/Clip.js'; -import { MiClipNote } from '@/models/ClipNote.js'; -import { MiClipFavorite } from '@/models/ClipFavorite.js'; -import { MiDriveFile } from '@/models/DriveFile.js'; -import { MiDriveFolder } from '@/models/DriveFolder.js'; -import { MiEmoji } from '@/models/Emoji.js'; -import { MiFollowing } from '@/models/Following.js'; -import { MiFollowRequest } from '@/models/FollowRequest.js'; -import { MiGalleryLike } from '@/models/GalleryLike.js'; -import { MiGalleryPost } from '@/models/GalleryPost.js'; -import { MiHashtag } from '@/models/Hashtag.js'; -import { MiInstance } from '@/models/Instance.js'; -import { MiMeta } from '@/models/Meta.js'; -import { MiModerationLog } from '@/models/ModerationLog.js'; -import { MiMuting } from '@/models/Muting.js'; -import { MiRenoteMuting } from '@/models/RenoteMuting.js'; -import { MiNote } from '@/models/Note.js'; -import { MiNoteFavorite } from '@/models/NoteFavorite.js'; -import { MiNoteReaction } from '@/models/NoteReaction.js'; -import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js'; -import { MiNoteUnread } from '@/models/NoteUnread.js'; -import { MiPage } from '@/models/Page.js'; -import { MiPageLike } from '@/models/PageLike.js'; -import { MiPasswordResetRequest } from '@/models/PasswordResetRequest.js'; -import { MiPoll } from '@/models/Poll.js'; -import { MiPollVote } from '@/models/PollVote.js'; -import { MiPromoNote } from '@/models/PromoNote.js'; -import { MiPromoRead } from '@/models/PromoRead.js'; -import { MiRegistrationTicket } from '@/models/RegistrationTicket.js'; -import { MiRegistryItem } from '@/models/RegistryItem.js'; -import { MiRelay } from '@/models/Relay.js'; -import { MiSignin } from '@/models/Signin.js'; -import { MiSwSubscription } from '@/models/SwSubscription.js'; -import { MiUsedUsername } from '@/models/UsedUsername.js'; -import { MiUser } from '@/models/User.js'; -import { MiUserIp } from '@/models/UserIp.js'; -import { MiUserKeypair } from '@/models/UserKeypair.js'; -import { MiUserList } from '@/models/UserList.js'; -import { MiUserListMembership } from '@/models/UserListMembership.js'; -import { MiUserNotePining } from '@/models/UserNotePining.js'; -import { MiUserPending } from '@/models/UserPending.js'; -import { MiUserProfile } from '@/models/UserProfile.js'; -import { MiUserPublickey } from '@/models/UserPublickey.js'; -import { MiUserSecurityKey } from '@/models/UserSecurityKey.js'; -import { MiUserMemo } from '@/models/UserMemo.js'; -import { MiWebhook } from '@/models/Webhook.js'; -import { MiChannel } from '@/models/Channel.js'; -import { MiRetentionAggregation } from '@/models/RetentionAggregation.js'; -import { MiRole } from '@/models/Role.js'; -import { MiRoleAssignment } from '@/models/RoleAssignment.js'; -import { MiFlash } from '@/models/Flash.js'; -import { MiFlashLike } from '@/models/FlashLike.js'; -import { MiUserListFavorite } from '@/models/UserListFavorite.js'; -import { NoteEdit } from '@/models/NoteEdit.js'; -import type { Repository } from 'typeorm'; - -export { - MiAbuseUserReport, - MiAccessToken, - MiAd, - MiAnnouncement, - MiAnnouncementRead, - MiAntenna, - MiApp, - MiAvatarDecoration, - MiAuthSession, - MiBlocking, - MiChannelFollowing, - MiChannelFavorite, - MiClip, - MiClipNote, - MiClipFavorite, - MiDriveFile, - MiDriveFolder, - MiEmoji, - MiFollowing, - MiFollowRequest, - MiGalleryLike, - MiGalleryPost, - MiHashtag, - MiInstance, - MiMeta, - MiModerationLog, - MiMuting, - MiRenoteMuting, - MiNote, - MiNoteFavorite, - MiNoteReaction, - MiNoteThreadMuting, - MiNoteUnread, - MiPage, - MiPageLike, - MiPasswordResetRequest, - MiPoll, - MiPollVote, - MiPromoNote, - MiPromoRead, - MiRegistrationTicket, - MiRegistryItem, - MiRelay, - MiSignin, - MiSwSubscription, - MiUsedUsername, - MiUser, - MiUserIp, - MiUserKeypair, - MiUserList, - MiUserListFavorite, - MiUserListMembership, - MiUserNotePining, - MiUserPending, - MiUserProfile, - MiUserPublickey, - MiUserSecurityKey, - MiWebhook, - MiChannel, - MiRetentionAggregation, - MiRole, - MiRoleAssignment, - MiFlash, - MiFlashLike, - MiUserMemo, - NoteEdit, -}; - -export type AbuseUserReportsRepository = Repository; -export type AccessTokensRepository = Repository; -export type AdsRepository = Repository; -export type AnnouncementsRepository = Repository; -export type AnnouncementReadsRepository = Repository; -export type AntennasRepository = Repository; -export type AppsRepository = Repository; -export type AvatarDecorationsRepository = Repository; -export type AuthSessionsRepository = Repository; -export type BlockingsRepository = Repository; -export type ChannelFollowingsRepository = Repository; -export type ChannelFavoritesRepository = Repository; -export type ClipsRepository = Repository; -export type ClipNotesRepository = Repository; -export type ClipFavoritesRepository = Repository; -export type DriveFilesRepository = Repository; -export type DriveFoldersRepository = Repository; -export type EmojisRepository = Repository; -export type FollowingsRepository = Repository; -export type FollowRequestsRepository = Repository; -export type GalleryLikesRepository = Repository; -export type GalleryPostsRepository = Repository; -export type HashtagsRepository = Repository; -export type InstancesRepository = Repository; -export type MetasRepository = Repository; -export type ModerationLogsRepository = Repository; -export type MutingsRepository = Repository; -export type RenoteMutingsRepository = Repository; -export type NotesRepository = Repository; -export type NoteFavoritesRepository = Repository; -export type NoteReactionsRepository = Repository; -export type NoteThreadMutingsRepository = Repository; -export type NoteUnreadsRepository = Repository; -export type PagesRepository = Repository; -export type PageLikesRepository = Repository; -export type PasswordResetRequestsRepository = Repository; -export type PollsRepository = Repository; -export type PollVotesRepository = Repository; -export type PromoNotesRepository = Repository; -export type PromoReadsRepository = Repository; -export type RegistrationTicketsRepository = Repository; -export type RegistryItemsRepository = Repository; -export type RelaysRepository = Repository; -export type SigninsRepository = Repository; -export type SwSubscriptionsRepository = Repository; -export type UsedUsernamesRepository = Repository; -export type UsersRepository = Repository; -export type UserIpsRepository = Repository; -export type UserKeypairsRepository = Repository; -export type UserListsRepository = Repository; -export type UserListFavoritesRepository = Repository; -export type UserListMembershipsRepository = Repository; -export type UserNotePiningsRepository = Repository; -export type UserPendingsRepository = Repository; -export type UserProfilesRepository = Repository; -export type UserPublickeysRepository = Repository; -export type UserSecurityKeysRepository = Repository; -export type WebhooksRepository = Repository; -export type ChannelsRepository = Repository; -export type RetentionAggregationsRepository = Repository; -export type RolesRepository = Repository; -export type RoleAssignmentsRepository = Repository; -export type FlashsRepository = Repository; -export type FlashLikesRepository = Repository; -export type UserMemoRepository = Repository; -export type NoteEditRepository = Repository; diff --git a/packages/backend/src/models/entities/abuse-user-report.ts b/packages/backend/src/models/entities/abuse-user-report.ts new file mode 100644 index 0000000000..6ac5635528 --- /dev/null +++ b/packages/backend/src/models/entities/abuse-user-report.ts @@ -0,0 +1,79 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +export class AbuseUserReport { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the AbuseUserReport.', + }) + public createdAt: Date; + + @Index() + @Column(id()) + public targetUserId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public targetUser: User | null; + + @Index() + @Column(id()) + public reporterId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public reporter: User | null; + + @Column({ + ...id(), + nullable: true, + }) + public assigneeId: User['id'] | null; + + @ManyToOne(type => User, { + onDelete: 'SET NULL', + }) + @JoinColumn() + public assignee: User | null; + + @Index() + @Column('boolean', { + default: false, + }) + public resolved: boolean; + + @Column('boolean', { + default: false + }) + public forwarded: boolean; + + @Column('varchar', { + length: 2048, + }) + public comment: string; + + //#region Denormalized fields + @Index() + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]', + }) + public targetUserHost: string | null; + + @Index() + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]', + }) + public reporterHost: string | null; + //#endregion +} diff --git a/packages/backend/src/models/entities/access-token.ts b/packages/backend/src/models/entities/access-token.ts new file mode 100644 index 0000000000..c6e2141a46 --- /dev/null +++ b/packages/backend/src/models/entities/access-token.ts @@ -0,0 +1,90 @@ +import { Entity, PrimaryColumn, Index, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { User } from './user.js'; +import { App } from './app.js'; +import { id } from '../id.js'; + +@Entity() +export class AccessToken { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the AccessToken.', + }) + public createdAt: Date; + + @Column('timestamp with time zone', { + nullable: true, + }) + public lastUsedAt: Date | null; + + @Index() + @Column('varchar', { + length: 128, + }) + public token: string; + + @Index() + @Column('varchar', { + length: 128, + nullable: true, + }) + public session: string | null; + + @Index() + @Column('varchar', { + length: 128, + }) + public hash: string; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column({ + ...id(), + nullable: true, + }) + public appId: App['id'] | null; + + @ManyToOne(type => App, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public app: App | null; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public name: string | null; + + @Column('varchar', { + length: 512, + nullable: true, + }) + public description: string | null; + + @Column('varchar', { + length: 512, + nullable: true, + }) + public iconUrl: string | null; + + @Column('varchar', { + length: 64, array: true, + default: '{}', + }) + public permission: string[]; + + @Column('boolean', { + default: false, + }) + public fetched: boolean; +} diff --git a/packages/backend/src/models/entities/ad.ts b/packages/backend/src/models/entities/ad.ts new file mode 100644 index 0000000000..36b758f205 --- /dev/null +++ b/packages/backend/src/models/entities/ad.ts @@ -0,0 +1,59 @@ +import { Entity, Index, Column, PrimaryColumn } from 'typeorm'; +import { id } from '../id.js'; + +@Entity() +export class Ad { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Ad.', + }) + public createdAt: Date; + + @Index() + @Column('timestamp with time zone', { + comment: 'The expired date of the Ad.', + }) + public expiresAt: Date; + + @Column('varchar', { + length: 32, nullable: false, + }) + public place: string; + + // 今は使われていないが将来的に活用される可能性はある + @Column('varchar', { + length: 32, nullable: false, + }) + public priority: string; + + @Column('integer', { + default: 1, nullable: false, + }) + public ratio: number; + + @Column('varchar', { + length: 1024, nullable: false, + }) + public url: string; + + @Column('varchar', { + length: 1024, nullable: false, + }) + public imageUrl: string; + + @Column('varchar', { + length: 8192, nullable: false, + }) + public memo: string; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/announcement-read.ts b/packages/backend/src/models/entities/announcement-read.ts new file mode 100644 index 0000000000..e4d256a864 --- /dev/null +++ b/packages/backend/src/models/entities/announcement-read.ts @@ -0,0 +1,36 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { Announcement } from './announcement.js'; +import { id } from '../id.js'; + +@Entity() +@Index(['userId', 'announcementId'], { unique: true }) +export class AnnouncementRead { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the AnnouncementRead.', + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column(id()) + public announcementId: Announcement['id']; + + @ManyToOne(type => Announcement, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public announcement: Announcement | null; +} diff --git a/packages/backend/src/models/entities/announcement.ts b/packages/backend/src/models/entities/announcement.ts new file mode 100644 index 0000000000..beb2f82462 --- /dev/null +++ b/packages/backend/src/models/entities/announcement.ts @@ -0,0 +1,43 @@ +import { Entity, Index, Column, PrimaryColumn } from 'typeorm'; +import { id } from '../id.js'; + +@Entity() +export class Announcement { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Announcement.', + }) + public createdAt: Date; + + @Column('timestamp with time zone', { + comment: 'The updated date of the Announcement.', + nullable: true, + }) + public updatedAt: Date | null; + + @Column('varchar', { + length: 8192, nullable: false, + }) + public text: string; + + @Column('varchar', { + length: 256, nullable: false, + }) + public title: string; + + @Column('varchar', { + length: 1024, nullable: true, + }) + public imageUrl: string | null; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/antenna-note.ts b/packages/backend/src/models/entities/antenna-note.ts new file mode 100644 index 0000000000..fcca493fe0 --- /dev/null +++ b/packages/backend/src/models/entities/antenna-note.ts @@ -0,0 +1,43 @@ +import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm'; +import { Note } from './note.js'; +import { Antenna } from './antenna.js'; +import { id } from '../id.js'; + +@Entity() +@Index(['noteId', 'antennaId'], { unique: true }) +export class AntennaNote { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + comment: 'The note ID.', + }) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note: Note | null; + + @Index() + @Column({ + ...id(), + comment: 'The antenna ID.', + }) + public antennaId: Antenna['id']; + + @ManyToOne(type => Antenna, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public antenna: Antenna | null; + + @Index() + @Column('boolean', { + default: false, + }) + public read: boolean; +} diff --git a/packages/backend/src/models/entities/antenna.ts b/packages/backend/src/models/entities/antenna.ts new file mode 100644 index 0000000000..6c8bb13e50 --- /dev/null +++ b/packages/backend/src/models/entities/antenna.ts @@ -0,0 +1,99 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; +import { UserList } from './user-list.js'; +import { UserGroupJoining } from './user-group-joining.js'; + +@Entity() +export class Antenna { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the Antenna.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The owner ID.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 128, + comment: 'The name of the Antenna.', + }) + public name: string; + + @Column('enum', { enum: ['home', 'all', 'users', 'list', 'group'] }) + public src: 'home' | 'all' | 'users' | 'list' | 'group'; + + @Column({ + ...id(), + nullable: true, + }) + public userListId: UserList['id'] | null; + + @ManyToOne(type => UserList, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public userList: UserList | null; + + @Column({ + ...id(), + nullable: true, + }) + public userGroupJoiningId: UserGroupJoining['id'] | null; + + @ManyToOne(type => UserGroupJoining, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public userGroupJoining: UserGroupJoining | null; + + @Column('varchar', { + length: 1024, array: true, + default: '{}', + }) + public users: string[]; + + @Column('jsonb', { + default: [], + }) + public keywords: string[][]; + + @Column('jsonb', { + default: [], + }) + public excludeKeywords: string[][]; + + @Column('boolean', { + default: false, + }) + public caseSensitive: boolean; + + @Column('boolean', { + default: false, + }) + public withReplies: boolean; + + @Column('boolean') + public withFile: boolean; + + @Column('varchar', { + length: 2048, nullable: true, + }) + public expression: string | null; + + @Column('boolean') + public notify: boolean; +} diff --git a/packages/backend/src/models/entities/app.ts b/packages/backend/src/models/entities/app.ts new file mode 100644 index 0000000000..46c11548a5 --- /dev/null +++ b/packages/backend/src/models/entities/app.ts @@ -0,0 +1,60 @@ +import { Entity, PrimaryColumn, Column, Index, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +export class App { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the App.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: 'The owner ID.', + }) + public userId: User['id'] | null; + + @ManyToOne(type => User, { + onDelete: 'SET NULL', + nullable: true, + }) + public user: User | null; + + @Index() + @Column('varchar', { + length: 64, + comment: 'The secret key of the App.', + }) + public secret: string; + + @Column('varchar', { + length: 128, + comment: 'The name of the App.', + }) + public name: string; + + @Column('varchar', { + length: 512, + comment: 'The description of the App.', + }) + public description: string; + + @Column('varchar', { + length: 64, array: true, + comment: 'The permission of the App.', + }) + public permission: string[]; + + @Column('varchar', { + length: 512, nullable: true, + comment: 'The callbackUrl of the App.', + }) + public callbackUrl: string | null; +} diff --git a/packages/backend/src/models/entities/attestation-challenge.ts b/packages/backend/src/models/entities/attestation-challenge.ts new file mode 100644 index 0000000000..c40df23293 --- /dev/null +++ b/packages/backend/src/models/entities/attestation-challenge.ts @@ -0,0 +1,46 @@ +import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +export class AttestationChallenge { + @PrimaryColumn(id()) + public id: string; + + @Index() + @PrimaryColumn(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column('varchar', { + length: 64, + comment: 'Hex-encoded sha256 hash of the challenge.', + }) + public challenge: string; + + @Column('timestamp with time zone', { + comment: 'The date challenge was created for expiry purposes.', + }) + public createdAt: Date; + + @Column('boolean', { + comment: + 'Indicates that the challenge is only for registration purposes if true to prevent the challenge for being used as authentication.', + default: false, + }) + public registrationChallenge: boolean; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/auth-session.ts b/packages/backend/src/models/entities/auth-session.ts new file mode 100644 index 0000000000..295d1b486c --- /dev/null +++ b/packages/backend/src/models/entities/auth-session.ts @@ -0,0 +1,43 @@ +import { Entity, PrimaryColumn, Index, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { User } from './user.js'; +import { App } from './app.js'; +import { id } from '../id.js'; + +@Entity() +export class AuthSession { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the AuthSession.', + }) + public createdAt: Date; + + @Index() + @Column('varchar', { + length: 128, + }) + public token: string; + + @Column({ + ...id(), + nullable: true, + }) + public userId: User['id'] | null; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public appId: App['id']; + + @ManyToOne(type => App, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public app: App | null; +} diff --git a/packages/backend/src/models/entities/blocking.ts b/packages/backend/src/models/entities/blocking.ts new file mode 100644 index 0000000000..4ac73a00b5 --- /dev/null +++ b/packages/backend/src/models/entities/blocking.ts @@ -0,0 +1,42 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +@Index(['blockerId', 'blockeeId'], { unique: true }) +export class Blocking { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Blocking.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The blockee user ID.', + }) + public blockeeId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public blockee: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The blocker user ID.', + }) + public blockerId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public blocker: User | null; +} diff --git a/packages/backend/src/models/entities/channel-following.ts b/packages/backend/src/models/entities/channel-following.ts new file mode 100644 index 0000000000..029dd6cf1a --- /dev/null +++ b/packages/backend/src/models/entities/channel-following.ts @@ -0,0 +1,43 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; +import { Channel } from './channel.js'; + +@Entity() +@Index(['followerId', 'followeeId'], { unique: true }) +export class ChannelFollowing { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the ChannelFollowing.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The followee channel ID.', + }) + public followeeId: Channel['id']; + + @ManyToOne(type => Channel, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public followee: Channel | null; + + @Index() + @Column({ + ...id(), + comment: 'The follower user ID.', + }) + public followerId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public follower: User | null; +} diff --git a/packages/backend/src/models/entities/channel-note-pining.ts b/packages/backend/src/models/entities/channel-note-pining.ts new file mode 100644 index 0000000000..23be3b69d4 --- /dev/null +++ b/packages/backend/src/models/entities/channel-note-pining.ts @@ -0,0 +1,35 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { Note } from './note.js'; +import { Channel } from './channel.js'; +import { id } from '../id.js'; + +@Entity() +@Index(['channelId', 'noteId'], { unique: true }) +export class ChannelNotePining { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the ChannelNotePining.', + }) + public createdAt: Date; + + @Index() + @Column(id()) + public channelId: Channel['id']; + + @ManyToOne(type => Channel, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public channel: Channel | null; + + @Column(id()) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note: Note | null; +} diff --git a/packages/backend/src/models/entities/channel.ts b/packages/backend/src/models/entities/channel.ts new file mode 100644 index 0000000000..abf6668bd2 --- /dev/null +++ b/packages/backend/src/models/entities/channel.ts @@ -0,0 +1,75 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; +import { DriveFile } from './drive-file.js'; + +@Entity() +export class Channel { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Channel.', + }) + public createdAt: Date; + + @Index() + @Column('timestamp with time zone', { + nullable: true, + }) + public lastNotedAt: Date | null; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: 'The owner ID.', + }) + public userId: User['id'] | null; + + @ManyToOne(type => User, { + onDelete: 'SET NULL', + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 128, + comment: 'The name of the Channel.', + }) + public name: string; + + @Column('varchar', { + length: 2048, nullable: true, + comment: 'The description of the Channel.', + }) + public description: string | null; + + @Column({ + ...id(), + nullable: true, + comment: 'The ID of banner Channel.', + }) + public bannerId: DriveFile['id'] | null; + + @ManyToOne(type => DriveFile, { + onDelete: 'SET NULL', + }) + @JoinColumn() + public banner: DriveFile | null; + + @Index() + @Column('integer', { + default: 0, + comment: 'The count of notes.', + }) + public notesCount: number; + + @Index() + @Column('integer', { + default: 0, + comment: 'The count of users.', + }) + public usersCount: number; +} diff --git a/packages/backend/src/models/entities/clip-note.ts b/packages/backend/src/models/entities/clip-note.ts new file mode 100644 index 0000000000..6f36885508 --- /dev/null +++ b/packages/backend/src/models/entities/clip-note.ts @@ -0,0 +1,37 @@ +import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm'; +import { Note } from './note.js'; +import { Clip } from './clip.js'; +import { id } from '../id.js'; + +@Entity() +@Index(['noteId', 'clipId'], { unique: true }) +export class ClipNote { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + comment: 'The note ID.', + }) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note: Note | null; + + @Index() + @Column({ + ...id(), + comment: 'The clip ID.', + }) + public clipId: Clip['id']; + + @ManyToOne(type => Clip, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public clip: Clip | null; +} diff --git a/packages/backend/src/models/entities/clip.ts b/packages/backend/src/models/entities/clip.ts new file mode 100644 index 0000000000..1386684c32 --- /dev/null +++ b/packages/backend/src/models/entities/clip.ts @@ -0,0 +1,44 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +export class Clip { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the Clip.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The owner ID.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 128, + comment: 'The name of the Clip.', + }) + public name: string; + + @Column('boolean', { + default: false, + }) + public isPublic: boolean; + + @Column('varchar', { + length: 2048, nullable: true, + comment: 'The description of the Clip.', + }) + public description: string | null; +} diff --git a/packages/backend/src/models/entities/drive-file.ts b/packages/backend/src/models/entities/drive-file.ts new file mode 100644 index 0000000000..d410b1d429 --- /dev/null +++ b/packages/backend/src/models/entities/drive-file.ts @@ -0,0 +1,192 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './user.js'; +import { DriveFolder } from './drive-folder.js'; + +@Entity() +@Index(['userId', 'folderId', 'id']) +export class DriveFile { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the DriveFile.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: 'The owner ID.', + }) + public userId: User['id'] | null; + + @ManyToOne(type => User, { + onDelete: 'SET NULL', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column('varchar', { + length: 128, nullable: true, + comment: 'The host of owner. It will be null if the user in local.', + }) + public userHost: string | null; + + @Index() + @Column('varchar', { + length: 32, + comment: 'The MD5 hash of the DriveFile.', + }) + public md5: string; + + @Column('varchar', { + length: 256, + comment: 'The file name of the DriveFile.', + }) + public name: string; + + @Index() + @Column('varchar', { + length: 128, + comment: 'The content type (MIME) of the DriveFile.', + }) + public type: string; + + @Column('integer', { + comment: 'The file size (bytes) of the DriveFile.', + }) + public size: number; + + @Column('varchar', { + length: 512, nullable: true, + comment: 'The comment of the DriveFile.', + }) + public comment: string | null; + + @Column('varchar', { + length: 128, nullable: true, + comment: 'The BlurHash string.', + }) + public blurhash: string | null; + + @Column('jsonb', { + default: {}, + comment: 'The any properties of the DriveFile. For example, it includes image width/height.', + }) + public properties: { width?: number; height?: number; orientation?: number; avgColor?: string }; + + @Column('boolean') + public storedInternal: boolean; + + @Column('varchar', { + length: 512, + comment: 'The URL of the DriveFile.', + }) + public url: string; + + @Column('varchar', { + length: 512, nullable: true, + comment: 'The URL of the thumbnail of the DriveFile.', + }) + public thumbnailUrl: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: 'The URL of the webpublic of the DriveFile.', + }) + public webpublicUrl: string | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public webpublicType: string | null; + + @Index({ unique: true }) + @Column('varchar', { + length: 256, nullable: true, + }) + public accessKey: string | null; + + @Index({ unique: true }) + @Column('varchar', { + length: 256, nullable: true, + }) + public thumbnailAccessKey: string | null; + + @Index({ unique: true }) + @Column('varchar', { + length: 256, nullable: true, + }) + public webpublicAccessKey: string | null; + + @Index() + @Column('varchar', { + length: 512, nullable: true, + comment: 'The URI of the DriveFile. it will be null when the DriveFile is local.', + }) + public uri: string | null; + + @Column('varchar', { + length: 512, nullable: true, + }) + public src: string | null; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: 'The parent folder ID. If null, it means the DriveFile is located in root.', + }) + public folderId: DriveFolder['id'] | null; + + @ManyToOne(type => DriveFolder, { + onDelete: 'SET NULL', + }) + @JoinColumn() + public folder: DriveFolder | null; + + @Index() + @Column('boolean', { + default: false, + comment: 'Whether the DriveFile is NSFW.', + }) + public isSensitive: boolean; + + @Index() + @Column('boolean', { + default: false, + comment: 'Whether the DriveFile is NSFW. (predict)', + }) + public maybeSensitive: boolean; + + @Index() + @Column('boolean', { + default: false, + }) + public maybePorn: boolean; + + /** + * 外部の(信頼されていない)URLへの直リンクか否か + */ + @Index() + @Column('boolean', { + default: false, + comment: 'Whether the DriveFile is direct link to remote server.', + }) + public isLink: boolean; + + @Column('jsonb', { + default: {}, + nullable: true, + }) + public requestHeaders: Record | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public requestIp: string | null; +} diff --git a/packages/backend/src/models/entities/drive-folder.ts b/packages/backend/src/models/entities/drive-folder.ts new file mode 100644 index 0000000000..d4022c6ebc --- /dev/null +++ b/packages/backend/src/models/entities/drive-folder.ts @@ -0,0 +1,49 @@ +import { JoinColumn, ManyToOne, Entity, PrimaryColumn, Index, Column } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +export class DriveFolder { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the DriveFolder.', + }) + public createdAt: Date; + + @Column('varchar', { + length: 128, + comment: 'The name of the DriveFolder.', + }) + public name: string; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: 'The owner ID.', + }) + public userId: User['id'] | null; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: 'The parent folder ID. If null, it means the DriveFolder is located in root.', + }) + public parentId: DriveFolder['id'] | null; + + @ManyToOne(type => DriveFolder, { + onDelete: 'SET NULL', + }) + @JoinColumn() + public parent: DriveFolder | null; +} diff --git a/packages/backend/src/models/entities/emoji.ts b/packages/backend/src/models/entities/emoji.ts new file mode 100644 index 0000000000..7332dd1857 --- /dev/null +++ b/packages/backend/src/models/entities/emoji.ts @@ -0,0 +1,58 @@ +import { PrimaryColumn, Entity, Index, Column } from 'typeorm'; +import { id } from '../id.js'; + +@Entity() +@Index(['name', 'host'], { unique: true }) +export class Emoji { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + nullable: true, + }) + public updatedAt: Date | null; + + @Index() + @Column('varchar', { + length: 128, + }) + public name: string; + + @Index() + @Column('varchar', { + length: 128, nullable: true, + }) + public host: string | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public category: string | null; + + @Column('varchar', { + length: 512, + }) + public originalUrl: string; + + @Column('varchar', { + length: 512, + default: '', + }) + public publicUrl: string; + + @Column('varchar', { + length: 512, nullable: true, + }) + public uri: string | null; + + // publicUrlの方のtypeが入る + @Column('varchar', { + length: 64, nullable: true, + }) + public type: string | null; + + @Column('varchar', { + array: true, length: 128, default: '{}', + }) + public aliases: string[]; +} diff --git a/packages/backend/src/models/entities/follow-request.ts b/packages/backend/src/models/entities/follow-request.ts new file mode 100644 index 0000000000..89946f6d35 --- /dev/null +++ b/packages/backend/src/models/entities/follow-request.ts @@ -0,0 +1,85 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +@Index(['followerId', 'followeeId'], { unique: true }) +export class FollowRequest { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the FollowRequest.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The followee user ID.', + }) + public followeeId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public followee: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The follower user ID.', + }) + public followerId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public follower: User | null; + + @Column('varchar', { + length: 128, nullable: true, + comment: 'id of Follow Activity.', + }) + public requestId: string | null; + + //#region Denormalized fields + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]', + }) + public followerHost: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: '[Denormalized]', + }) + public followerInbox: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: '[Denormalized]', + }) + public followerSharedInbox: string | null; + + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]', + }) + public followeeHost: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: '[Denormalized]', + }) + public followeeInbox: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: '[Denormalized]', + }) + public followeeSharedInbox: string | null; + //#endregion +} diff --git a/packages/backend/src/models/entities/following.ts b/packages/backend/src/models/entities/following.ts new file mode 100644 index 0000000000..b283ca7e8a --- /dev/null +++ b/packages/backend/src/models/entities/following.ts @@ -0,0 +1,82 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +@Index(['followerId', 'followeeId'], { unique: true }) +export class Following { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Following.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The followee user ID.', + }) + public followeeId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public followee: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The follower user ID.', + }) + public followerId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public follower: User | null; + + //#region Denormalized fields + @Index() + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]', + }) + public followerHost: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: '[Denormalized]', + }) + public followerInbox: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: '[Denormalized]', + }) + public followerSharedInbox: string | null; + + @Index() + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]', + }) + public followeeHost: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: '[Denormalized]', + }) + public followeeInbox: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: '[Denormalized]', + }) + public followeeSharedInbox: string | null; + //#endregion +} diff --git a/packages/backend/src/models/entities/gallery-like.ts b/packages/backend/src/models/entities/gallery-like.ts new file mode 100644 index 0000000000..4ce166d194 --- /dev/null +++ b/packages/backend/src/models/entities/gallery-like.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; +import { GalleryPost } from './gallery-post.js'; + +@Entity() +@Index(['userId', 'postId'], { unique: true }) +export class GalleryLike { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public postId: GalleryPost['id']; + + @ManyToOne(type => GalleryPost, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public post: GalleryPost | null; +} diff --git a/packages/backend/src/models/entities/gallery-post.ts b/packages/backend/src/models/entities/gallery-post.ts new file mode 100644 index 0000000000..774cb946e9 --- /dev/null +++ b/packages/backend/src/models/entities/gallery-post.ts @@ -0,0 +1,79 @@ +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; +import { DriveFile } from './drive-file.js'; + +@Entity() +export class GalleryPost { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the GalleryPost.', + }) + public createdAt: Date; + + @Index() + @Column('timestamp with time zone', { + comment: 'The updated date of the GalleryPost.', + }) + public updatedAt: Date; + + @Column('varchar', { + length: 256, + }) + public title: string; + + @Column('varchar', { + length: 2048, nullable: true, + }) + public description: string | null; + + @Index() + @Column({ + ...id(), + comment: 'The ID of author.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + array: true, default: '{}', + }) + public fileIds: DriveFile['id'][]; + + @Index() + @Column('boolean', { + default: false, + comment: 'Whether the post is sensitive.', + }) + public isSensitive: boolean; + + @Index() + @Column('integer', { + default: 0, + }) + public likedCount: number; + + @Index() + @Column('varchar', { + length: 128, array: true, default: '{}', + }) + public tags: string[]; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/hashtag.ts b/packages/backend/src/models/entities/hashtag.ts new file mode 100644 index 0000000000..6bd991f629 --- /dev/null +++ b/packages/backend/src/models/entities/hashtag.ts @@ -0,0 +1,87 @@ +import { Entity, PrimaryColumn, Index, Column } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +export class Hashtag { + @PrimaryColumn(id()) + public id: string; + + @Index({ unique: true }) + @Column('varchar', { + length: 128, + }) + public name: string; + + @Column({ + ...id(), + array: true, + }) + public mentionedUserIds: User['id'][]; + + @Index() + @Column('integer', { + default: 0, + }) + public mentionedUsersCount: number; + + @Column({ + ...id(), + array: true, + }) + public mentionedLocalUserIds: User['id'][]; + + @Index() + @Column('integer', { + default: 0, + }) + public mentionedLocalUsersCount: number; + + @Column({ + ...id(), + array: true, + }) + public mentionedRemoteUserIds: User['id'][]; + + @Index() + @Column('integer', { + default: 0, + }) + public mentionedRemoteUsersCount: number; + + @Column({ + ...id(), + array: true, + }) + public attachedUserIds: User['id'][]; + + @Index() + @Column('integer', { + default: 0, + }) + public attachedUsersCount: number; + + @Column({ + ...id(), + array: true, + }) + public attachedLocalUserIds: User['id'][]; + + @Index() + @Column('integer', { + default: 0, + }) + public attachedLocalUsersCount: number; + + @Column({ + ...id(), + array: true, + }) + public attachedRemoteUserIds: User['id'][]; + + @Index() + @Column('integer', { + default: 0, + }) + public attachedRemoteUsersCount: number; +} diff --git a/packages/backend/src/models/entities/instance.ts b/packages/backend/src/models/entities/instance.ts new file mode 100644 index 0000000000..7ea9234384 --- /dev/null +++ b/packages/backend/src/models/entities/instance.ts @@ -0,0 +1,164 @@ +import { Entity, PrimaryColumn, Index, Column } from 'typeorm'; +import { id } from '../id.js'; + +@Entity() +export class Instance { + @PrimaryColumn(id()) + public id: string; + + /** + * このインスタンスを捕捉した日時 + */ + @Index() + @Column('timestamp with time zone', { + comment: 'The caught date of the Instance.', + }) + public caughtAt: Date; + + /** + * ホスト + */ + @Index({ unique: true }) + @Column('varchar', { + length: 128, + comment: 'The host of the Instance.', + }) + public host: string; + + /** + * インスタンスのユーザー数 + */ + @Column('integer', { + default: 0, + comment: 'The count of the users of the Instance.', + }) + public usersCount: number; + + /** + * インスタンスの投稿数 + */ + @Column('integer', { + default: 0, + comment: 'The count of the notes of the Instance.', + }) + public notesCount: number; + + /** + * このインスタンスのユーザーからフォローされている、自インスタンスのユーザーの数 + */ + @Column('integer', { + default: 0, + }) + public followingCount: number; + + /** + * このインスタンスのユーザーをフォローしている、自インスタンスのユーザーの数 + */ + @Column('integer', { + default: 0, + }) + public followersCount: number; + + /** + * 直近のリクエスト送信日時 + */ + @Column('timestamp with time zone', { + nullable: true, + }) + public latestRequestSentAt: Date | null; + + /** + * 直近のリクエスト送信時のHTTPステータスコード + */ + @Column('integer', { + nullable: true, + }) + public latestStatus: number | null; + + /** + * 直近のリクエスト受信日時 + */ + @Column('timestamp with time zone', { + nullable: true, + }) + public latestRequestReceivedAt: Date | null; + + /** + * このインスタンスと最後にやり取りした日時 + */ + @Column('timestamp with time zone') + public lastCommunicatedAt: Date; + + /** + * このインスタンスと不通かどうか + */ + @Column('boolean', { + default: false, + }) + public isNotResponding: boolean; + + /** + * このインスタンスへの配信を停止するか + */ + @Index() + @Column('boolean', { + default: false, + }) + public isSuspended: boolean; + + @Column('varchar', { + length: 64, nullable: true, + comment: 'The software of the Instance.', + }) + public softwareName: string | null; + + @Column('varchar', { + length: 64, nullable: true, + }) + public softwareVersion: string | null; + + @Column('boolean', { + nullable: true, + }) + public openRegistrations: boolean | null; + + @Column('varchar', { + length: 256, nullable: true, + }) + public name: string | null; + + @Column('varchar', { + length: 4096, nullable: true, + }) + public description: string | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public maintainerName: string | null; + + @Column('varchar', { + length: 256, nullable: true, + }) + public maintainerEmail: string | null; + + @Column('varchar', { + length: 256, nullable: true, + }) + public iconUrl: string | null; + + @Column('varchar', { + length: 256, nullable: true, + }) + public faviconUrl: string | null; + + @Column('varchar', { + length: 64, nullable: true, + }) + public themeColor: string | null; + + @Column('timestamp with time zone', { + nullable: true, + }) + public infoUpdatedAt: Date | null; +} diff --git a/packages/backend/src/models/entities/messaging-message.ts b/packages/backend/src/models/entities/messaging-message.ts new file mode 100644 index 0000000000..099fb7aa01 --- /dev/null +++ b/packages/backend/src/models/entities/messaging-message.ts @@ -0,0 +1,89 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { DriveFile } from './drive-file.js'; +import { id } from '../id.js'; +import { UserGroup } from './user-group.js'; + +@Entity() +export class MessagingMessage { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the MessagingMessage.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The sender user ID.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), nullable: true, + comment: 'The recipient user ID.', + }) + public recipientId: User['id'] | null; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public recipient: User | null; + + @Index() + @Column({ + ...id(), nullable: true, + comment: 'The recipient group ID.', + }) + public groupId: UserGroup['id'] | null; + + @ManyToOne(type => UserGroup, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public group: UserGroup | null; + + @Column('varchar', { + length: 4096, nullable: true, + }) + public text: string | null; + + @Column('boolean', { + default: false, + }) + public isRead: boolean; + + @Column('varchar', { + length: 512, nullable: true, + }) + public uri: string | null; + + @Column({ + ...id(), + array: true, default: '{}', + }) + public reads: User['id'][]; + + @Column({ + ...id(), + nullable: true, + }) + public fileId: DriveFile['id'] | null; + + @ManyToOne(type => DriveFile, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public file: DriveFile | null; +} diff --git a/packages/backend/src/models/entities/meta.ts b/packages/backend/src/models/entities/meta.ts new file mode 100644 index 0000000000..d33ff2519e --- /dev/null +++ b/packages/backend/src/models/entities/meta.ts @@ -0,0 +1,462 @@ +import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './user.js'; +import { Clip } from './clip.js'; + +@Entity() +export class Meta { + @PrimaryColumn({ + type: 'varchar', + length: 32, + }) + public id: string; + + @Column('varchar', { + length: 128, nullable: true, + }) + public name: string | null; + + @Column('varchar', { + length: 1024, nullable: true, + }) + public description: string | null; + + /** + * メンテナの名前 + */ + @Column('varchar', { + length: 128, nullable: true, + }) + public maintainerName: string | null; + + /** + * メンテナの連絡先 + */ + @Column('varchar', { + length: 128, nullable: true, + }) + public maintainerEmail: string | null; + + @Column('boolean', { + default: false, + }) + public disableRegistration: boolean; + + @Column('boolean', { + default: false, + }) + public disableLocalTimeline: boolean; + + @Column('boolean', { + default: false, + }) + public disableGlobalTimeline: boolean; + + @Column('boolean', { + default: false, + }) + public useStarForReactionFallback: boolean; + + @Column('varchar', { + length: 64, array: true, default: '{}', + }) + public langs: string[]; + + @Column('varchar', { + length: 256, array: true, default: '{}', + }) + public pinnedUsers: string[]; + + @Column('varchar', { + length: 256, array: true, default: '{}', + }) + public hiddenTags: string[]; + + @Column('varchar', { + length: 256, array: true, default: '{}', + }) + public blockedHosts: string[]; + + @Column('varchar', { + length: 512, array: true, default: '{/featured,/channels,/explore,/pages,/about-misskey}', + }) + public pinnedPages: string[]; + + @Column({ + ...id(), + nullable: true, + }) + public pinnedClipId: Clip['id'] | null; + + @Column('varchar', { + length: 512, + nullable: true, + }) + public themeColor: string | null; + + @Column('varchar', { + length: 512, + nullable: true, + default: '/assets/ai.png', + }) + public mascotImageUrl: string | null; + + @Column('varchar', { + length: 512, + nullable: true, + }) + public bannerUrl: string | null; + + @Column('varchar', { + length: 512, + nullable: true, + }) + public backgroundImageUrl: string | null; + + @Column('varchar', { + length: 512, + nullable: true, + }) + public logoImageUrl: string | null; + + @Column('varchar', { + length: 512, + nullable: true, + default: 'https://xn--931a.moe/aiart/yubitun.png', + }) + public errorImageUrl: string | null; + + @Column('varchar', { + length: 512, + nullable: true, + }) + public iconUrl: string | null; + + @Column('boolean', { + default: true, + }) + public cacheRemoteFiles: boolean; + + @Column({ + ...id(), + nullable: true, + }) + public proxyAccountId: User['id'] | null; + + @ManyToOne(type => User, { + onDelete: 'SET NULL', + }) + @JoinColumn() + public proxyAccount: User | null; + + @Column('boolean', { + default: false, + }) + public emailRequiredForSignup: boolean; + + @Column('boolean', { + default: false, + }) + public enableHcaptcha: boolean; + + @Column('varchar', { + length: 64, + nullable: true, + }) + public hcaptchaSiteKey: string | null; + + @Column('varchar', { + length: 64, + nullable: true, + }) + public hcaptchaSecretKey: string | null; + + @Column('boolean', { + default: false, + }) + public enableRecaptcha: boolean; + + @Column('varchar', { + length: 64, + nullable: true, + }) + public recaptchaSiteKey: string | null; + + @Column('varchar', { + length: 64, + nullable: true, + }) + public recaptchaSecretKey: string | null; + + @Column('enum', { + enum: ['none', 'all', 'local', 'remote'], + default: 'none', + }) + public sensitiveMediaDetection: 'none' | 'all' | 'local' | 'remote'; + + @Column('enum', { + enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'], + default: 'medium', + }) + public sensitiveMediaDetectionSensitivity: 'medium' | 'low' | 'high' | 'veryLow' | 'veryHigh'; + + @Column('boolean', { + default: false, + }) + public setSensitiveFlagAutomatically: boolean; + + @Column('boolean', { + default: false, + }) + public enableSensitiveMediaDetectionForVideos: boolean; + + @Column('integer', { + default: 1024, + comment: 'Drive capacity of a local user (MB)', + }) + public localDriveCapacityMb: number; + + @Column('integer', { + default: 32, + comment: 'Drive capacity of a remote user (MB)', + }) + public remoteDriveCapacityMb: number; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public summalyProxy: string | null; + + @Column('boolean', { + default: false, + }) + public enableEmail: boolean; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public email: string | null; + + @Column('boolean', { + default: false, + }) + public smtpSecure: boolean; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public smtpHost: string | null; + + @Column('integer', { + nullable: true, + }) + public smtpPort: number | null; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public smtpUser: string | null; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public smtpPass: string | null; + + @Column('boolean', { + default: false, + }) + public enableServiceWorker: boolean; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public swPublicKey: string | null; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public swPrivateKey: string | null; + + @Column('boolean', { + default: false, + }) + public enableTwitterIntegration: boolean; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public twitterConsumerKey: string | null; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public twitterConsumerSecret: string | null; + + @Column('boolean', { + default: false, + }) + public enableGithubIntegration: boolean; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public githubClientId: string | null; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public githubClientSecret: string | null; + + @Column('boolean', { + default: false, + }) + public enableDiscordIntegration: boolean; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public discordClientId: string | null; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public discordClientSecret: string | null; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public deeplAuthKey: string | null; + + @Column('boolean', { + default: false, + }) + public deeplIsPro: boolean; + + @Column('varchar', { + length: 512, + nullable: true, + }) + public ToSUrl: string | null; + + @Column('varchar', { + length: 512, + default: 'https://github.com/misskey-dev/misskey', + nullable: false, + }) + public repositoryUrl: string; + + @Column('varchar', { + length: 512, + default: 'https://github.com/misskey-dev/misskey/issues/new', + nullable: true, + }) + public feedbackUrl: string | null; + + @Column('varchar', { + length: 8192, + nullable: true, + }) + public defaultLightTheme: string | null; + + @Column('varchar', { + length: 8192, + nullable: true, + }) + public defaultDarkTheme: string | null; + + @Column('boolean', { + default: false, + }) + public useObjectStorage: boolean; + + @Column('varchar', { + length: 512, + nullable: true, + }) + public objectStorageBucket: string | null; + + @Column('varchar', { + length: 512, + nullable: true, + }) + public objectStoragePrefix: string | null; + + @Column('varchar', { + length: 512, + nullable: true, + }) + public objectStorageBaseUrl: string | null; + + @Column('varchar', { + length: 512, + nullable: true, + }) + public objectStorageEndpoint: string | null; + + @Column('varchar', { + length: 512, + nullable: true, + }) + public objectStorageRegion: string | null; + + @Column('varchar', { + length: 512, + nullable: true, + }) + public objectStorageAccessKey: string | null; + + @Column('varchar', { + length: 512, + nullable: true, + }) + public objectStorageSecretKey: string | null; + + @Column('integer', { + nullable: true, + }) + public objectStoragePort: number | null; + + @Column('boolean', { + default: true, + }) + public objectStorageUseSSL: boolean; + + @Column('boolean', { + default: true, + }) + public objectStorageUseProxy: boolean; + + @Column('boolean', { + default: false, + }) + public objectStorageSetPublicRead: boolean; + + @Column('boolean', { + default: true, + }) + public objectStorageS3ForcePathStyle: boolean; + + @Column('boolean', { + default: false, + }) + public enableIpLogging: boolean; + + @Column('boolean', { + default: true, + }) + public enableActiveEmailValidation: boolean; +} diff --git a/packages/backend/src/models/entities/moderation-log.ts b/packages/backend/src/models/entities/moderation-log.ts new file mode 100644 index 0000000000..c99e550782 --- /dev/null +++ b/packages/backend/src/models/entities/moderation-log.ts @@ -0,0 +1,32 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +export class ModerationLog { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the ModerationLog.', + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 128, + }) + public type: string; + + @Column('jsonb') + public info: Record; +} diff --git a/packages/backend/src/models/entities/muted-note.ts b/packages/backend/src/models/entities/muted-note.ts new file mode 100644 index 0000000000..96a4fa8e33 --- /dev/null +++ b/packages/backend/src/models/entities/muted-note.ts @@ -0,0 +1,48 @@ +import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm'; +import { Note } from './note.js'; +import { User } from './user.js'; +import { id } from '../id.js'; +import { mutedNoteReasons } from '../../types.js'; + +@Entity() +@Index(['noteId', 'userId'], { unique: true }) +export class MutedNote { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + comment: 'The note ID.', + }) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note: Note | null; + + @Index() + @Column({ + ...id(), + comment: 'The user ID.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + /** + * ミュートされた理由。 + */ + @Index() + @Column('enum', { + enum: mutedNoteReasons, + comment: 'The reason of the MutedNote.', + }) + public reason: typeof mutedNoteReasons[number]; +} diff --git a/packages/backend/src/models/entities/muting.ts b/packages/backend/src/models/entities/muting.ts new file mode 100644 index 0000000000..8f9e69063a --- /dev/null +++ b/packages/backend/src/models/entities/muting.ts @@ -0,0 +1,48 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +@Index(['muterId', 'muteeId'], { unique: true }) +export class Muting { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Muting.', + }) + public createdAt: Date; + + @Index() + @Column('timestamp with time zone', { + nullable: true, + }) + public expiresAt: Date | null; + + @Index() + @Column({ + ...id(), + comment: 'The mutee user ID.', + }) + public muteeId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public mutee: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The muter user ID.', + }) + public muterId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public muter: User | null; +} diff --git a/packages/backend/src/models/entities/note-favorite.ts b/packages/backend/src/models/entities/note-favorite.ts new file mode 100644 index 0000000000..fe065b77a8 --- /dev/null +++ b/packages/backend/src/models/entities/note-favorite.ts @@ -0,0 +1,35 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { Note } from './note.js'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +@Index(['userId', 'noteId'], { unique: true }) +export class NoteFavorite { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the NoteFavorite.', + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note: Note | null; +} diff --git a/packages/backend/src/models/entities/note-reaction.ts b/packages/backend/src/models/entities/note-reaction.ts new file mode 100644 index 0000000000..d7bc609898 --- /dev/null +++ b/packages/backend/src/models/entities/note-reaction.ts @@ -0,0 +1,44 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { Note } from './note.js'; +import { id } from '../id.js'; + +@Entity() +@Index(['userId', 'noteId'], { unique: true }) +export class NoteReaction { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the NoteReaction.', + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user?: User | null; + + @Index() + @Column(id()) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note?: Note | null; + + // TODO: 対象noteのuserIdを非正規化したい(「受け取ったリアクション一覧」のようなものを(JOIN無しで)実装したいため) + + @Column('varchar', { + length: 260, + }) + public reaction: string; +} diff --git a/packages/backend/src/models/entities/note-thread-muting.ts b/packages/backend/src/models/entities/note-thread-muting.ts new file mode 100644 index 0000000000..8c5f7bbab4 --- /dev/null +++ b/packages/backend/src/models/entities/note-thread-muting.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { Note } from './note.js'; +import { id } from '../id.js'; + +@Entity() +@Index(['userId', 'threadId'], { unique: true }) +export class NoteThreadMuting { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column('varchar', { + length: 256, + }) + public threadId: string; +} diff --git a/packages/backend/src/models/entities/note-unread.ts b/packages/backend/src/models/entities/note-unread.ts new file mode 100644 index 0000000000..a7acf254d3 --- /dev/null +++ b/packages/backend/src/models/entities/note-unread.ts @@ -0,0 +1,63 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { Note } from './note.js'; +import { id } from '../id.js'; +import { Channel } from './channel.js'; + +@Entity() +@Index(['userId', 'noteId'], { unique: true }) +export class NoteUnread { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column(id()) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note: Note | null; + + /** + * メンションか否か + */ + @Index() + @Column('boolean') + public isMentioned: boolean; + + /** + * ダイレクト投稿か否か + */ + @Index() + @Column('boolean') + public isSpecified: boolean; + + //#region Denormalized fields + @Index() + @Column({ + ...id(), + comment: '[Denormalized]', + }) + public noteUserId: User['id']; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: '[Denormalized]', + }) + public noteChannelId: Channel['id'] | null; + //#endregion +} diff --git a/packages/backend/src/models/entities/note-watching.ts b/packages/backend/src/models/entities/note-watching.ts new file mode 100644 index 0000000000..ed82e7dfe7 --- /dev/null +++ b/packages/backend/src/models/entities/note-watching.ts @@ -0,0 +1,52 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { Note } from './note.js'; +import { id } from '../id.js'; + +@Entity() +@Index(['userId', 'noteId'], { unique: true }) +export class NoteWatching { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the NoteWatching.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The watcher ID.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The target Note ID.', + }) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note: Note | null; + + //#region Denormalized fields + @Index() + @Column({ + ...id(), + comment: '[Denormalized]', + }) + public noteUserId: Note['userId']; + //#endregion +} diff --git a/packages/backend/src/models/entities/note.ts b/packages/backend/src/models/entities/note.ts new file mode 100644 index 0000000000..0ffeb85f69 --- /dev/null +++ b/packages/backend/src/models/entities/note.ts @@ -0,0 +1,242 @@ +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { DriveFile } from './drive-file.js'; +import { id } from '../id.js'; +import { noteVisibilities } from '../../types.js'; +import { Channel } from './channel.js'; + +@Entity() +@Index('IDX_NOTE_TAGS', { synchronize: false }) +@Index('IDX_NOTE_MENTIONS', { synchronize: false }) +@Index('IDX_NOTE_VISIBLE_USER_IDS', { synchronize: false }) +export class Note { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Note.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: 'The ID of reply target.', + }) + public replyId: Note['id'] | null; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public reply: Note | null; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: 'The ID of renote target.', + }) + public renoteId: Note['id'] | null; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public renote: Note | null; + + @Index() + @Column('varchar', { + length: 256, nullable: true, + }) + public threadId: string | null; + + @Column('text', { + nullable: true, + }) + public text: string | null; + + @Column('varchar', { + length: 256, nullable: true, + }) + public name: string | null; + + @Column('varchar', { + length: 512, nullable: true, + }) + public cw: string | null; + + @Index() + @Column({ + ...id(), + comment: 'The ID of author.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column('boolean', { + default: false, + }) + public localOnly: boolean; + + @Column('smallint', { + default: 0, + }) + public renoteCount: number; + + @Column('smallint', { + default: 0, + }) + public repliesCount: number; + + @Column('jsonb', { + default: {}, + }) + public reactions: Record; + + /** + * public ... 公開 + * home ... ホームタイムライン(ユーザーページのタイムライン含む)のみに流す + * followers ... フォロワーのみ + * specified ... visibleUserIds で指定したユーザーのみ + */ + @Column('enum', { enum: noteVisibilities }) + public visibility: typeof noteVisibilities[number]; + + @Index({ unique: true }) + @Column('varchar', { + length: 512, nullable: true, + comment: 'The URI of a note. it will be null when the note is local.', + }) + public uri: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: 'The human readable url of a note. it will be null when the note is local.', + }) + public url: string | null; + + @Column('integer', { + default: 0, select: false, + }) + public score: number; + + @Index() + @Column({ + ...id(), + array: true, default: '{}', + }) + public fileIds: DriveFile['id'][]; + + @Index() + @Column('varchar', { + length: 256, array: true, default: '{}', + }) + public attachedFileTypes: string[]; + + @Index() + @Column({ + ...id(), + array: true, default: '{}', + }) + public visibleUserIds: User['id'][]; + + @Index() + @Column({ + ...id(), + array: true, default: '{}', + }) + public mentions: User['id'][]; + + @Column('text', { + default: '[]', + }) + public mentionedRemoteUsers: string; + + @Column('varchar', { + length: 128, array: true, default: '{}', + }) + public emojis: string[]; + + @Index() + @Column('varchar', { + length: 128, array: true, default: '{}', + }) + public tags: string[]; + + @Column('boolean', { + default: false, + }) + public hasPoll: boolean; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: 'The ID of source channel.', + }) + public channelId: Channel['id'] | null; + + @ManyToOne(type => Channel, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public channel: Channel | null; + + //#region Denormalized fields + @Index() + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]', + }) + public userHost: string | null; + + @Column({ + ...id(), + nullable: true, + comment: '[Denormalized]', + }) + public replyUserId: User['id'] | null; + + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]', + }) + public replyUserHost: string | null; + + @Column({ + ...id(), + nullable: true, + comment: '[Denormalized]', + }) + public renoteUserId: User['id'] | null; + + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]', + }) + public renoteUserHost: string | null; + //#endregion + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} + +export type IMentionedRemoteUsers = { + uri: string; + url?: string; + username: string; + host: string; +}[]; diff --git a/packages/backend/src/models/entities/notification.ts b/packages/backend/src/models/entities/notification.ts new file mode 100644 index 0000000000..db3dba3632 --- /dev/null +++ b/packages/backend/src/models/entities/notification.ts @@ -0,0 +1,173 @@ +import { Entity, Index, JoinColumn, ManyToOne, Column, PrimaryColumn } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; +import { Note } from './note.js'; +import { FollowRequest } from './follow-request.js'; +import { UserGroupInvitation } from './user-group-invitation.js'; +import { AccessToken } from './access-token.js'; +import { notificationTypes } from '@/types.js'; + +@Entity() +export class Notification { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Notification.', + }) + public createdAt: Date; + + /** + * 通知の受信者 + */ + @Index() + @Column({ + ...id(), + comment: 'The ID of recipient user of the Notification.', + }) + public notifieeId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public notifiee: User | null; + + /** + * 通知の送信者(initiator) + */ + @Index() + @Column({ + ...id(), + nullable: true, + comment: 'The ID of sender user of the Notification.', + }) + public notifierId: User['id'] | null; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public notifier: User | null; + + /** + * 通知の種類。 + * follow - フォローされた + * mention - 投稿で自分が言及された + * reply - (自分または自分がWatchしている)投稿が返信された + * renote - (自分または自分がWatchしている)投稿がRenoteされた + * quote - (自分または自分がWatchしている)投稿が引用Renoteされた + * reaction - (自分または自分がWatchしている)投稿にリアクションされた + * pollVote - (自分または自分がWatchしている)投稿のアンケートに投票された + * pollEnded - 自分のアンケートもしくは自分が投票したアンケートが終了した + * receiveFollowRequest - フォローリクエストされた + * followRequestAccepted - 自分の送ったフォローリクエストが承認された + * groupInvited - グループに招待された + * app - アプリ通知 + */ + @Index() + @Column('enum', { + enum: notificationTypes, + comment: 'The type of the Notification.', + }) + public type: typeof notificationTypes[number]; + + /** + * 通知が読まれたかどうか + */ + @Index() + @Column('boolean', { + default: false, + comment: 'Whether the Notification is read.', + }) + public isRead: boolean; + + @Column({ + ...id(), + nullable: true, + }) + public noteId: Note['id'] | null; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note: Note | null; + + @Column({ + ...id(), + nullable: true, + }) + public followRequestId: FollowRequest['id'] | null; + + @ManyToOne(type => FollowRequest, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public followRequest: FollowRequest | null; + + @Column({ + ...id(), + nullable: true, + }) + public userGroupInvitationId: UserGroupInvitation['id'] | null; + + @ManyToOne(type => UserGroupInvitation, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public userGroupInvitation: UserGroupInvitation | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public reaction: string | null; + + @Column('integer', { + nullable: true, + }) + public choice: number | null; + + /** + * アプリ通知のbody + */ + @Column('varchar', { + length: 2048, nullable: true, + }) + public customBody: string | null; + + /** + * アプリ通知のheader + * (省略時はアプリ名で表示されることを期待) + */ + @Column('varchar', { + length: 256, nullable: true, + }) + public customHeader: string | null; + + /** + * アプリ通知のicon(URL) + * (省略時はアプリアイコンで表示されることを期待) + */ + @Column('varchar', { + length: 1024, nullable: true, + }) + public customIcon: string | null; + + /** + * アプリ通知のアプリ(のトークン) + */ + @Index() + @Column({ + ...id(), + nullable: true, + }) + public appAccessTokenId: AccessToken['id'] | null; + + @ManyToOne(type => AccessToken, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public appAccessToken: AccessToken | null; +} diff --git a/packages/backend/src/models/entities/page-like.ts b/packages/backend/src/models/entities/page-like.ts new file mode 100644 index 0000000000..17f4ebf520 --- /dev/null +++ b/packages/backend/src/models/entities/page-like.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; +import { Page } from './page.js'; + +@Entity() +@Index(['userId', 'pageId'], { unique: true }) +export class PageLike { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public pageId: Page['id']; + + @ManyToOne(type => Page, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public page: Page | null; +} diff --git a/packages/backend/src/models/entities/page.ts b/packages/backend/src/models/entities/page.ts new file mode 100644 index 0000000000..baad3a36fa --- /dev/null +++ b/packages/backend/src/models/entities/page.ts @@ -0,0 +1,121 @@ +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; +import { DriveFile } from './drive-file.js'; + +@Entity() +@Index(['userId', 'name'], { unique: true }) +export class Page { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Page.', + }) + public createdAt: Date; + + @Index() + @Column('timestamp with time zone', { + comment: 'The updated date of the Page.', + }) + public updatedAt: Date; + + @Column('varchar', { + length: 256, + }) + public title: string; + + @Index() + @Column('varchar', { + length: 256, + }) + public name: string; + + @Column('varchar', { + length: 256, nullable: true, + }) + public summary: string | null; + + @Column('boolean') + public alignCenter: boolean; + + @Column('boolean', { + default: false, + }) + public hideTitleWhenPinned: boolean; + + @Column('varchar', { + length: 32, + }) + public font: string; + + @Index() + @Column({ + ...id(), + comment: 'The ID of author.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column({ + ...id(), + nullable: true, + }) + public eyeCatchingImageId: DriveFile['id'] | null; + + @ManyToOne(type => DriveFile, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public eyeCatchingImage: DriveFile | null; + + @Column('jsonb', { + default: [], + }) + public content: Record[]; + + @Column('jsonb', { + default: [], + }) + public variables: Record[]; + + @Column('varchar', { + length: 16384, + default: '', + }) + public script: string; + + /** + * public ... 公開 + * followers ... フォロワーのみ + * specified ... visibleUserIds で指定したユーザーのみ + */ + @Column('enum', { enum: ['public', 'followers', 'specified'] }) + public visibility: 'public' | 'followers' | 'specified'; + + @Index() + @Column({ + ...id(), + array: true, default: '{}', + }) + public visibleUserIds: User['id'][]; + + @Column('integer', { + default: 0, + }) + public likedCount: number; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/password-reset-request.ts b/packages/backend/src/models/entities/password-reset-request.ts new file mode 100644 index 0000000000..05e62cc5ab --- /dev/null +++ b/packages/backend/src/models/entities/password-reset-request.ts @@ -0,0 +1,30 @@ +import { PrimaryColumn, Entity, Index, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './user.js'; + +@Entity() +export class PasswordResetRequest { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + @Index({ unique: true }) + @Column('varchar', { + length: 256, + }) + public token: string; + + @Index() + @Column({ + ...id(), + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; +} diff --git a/packages/backend/src/models/entities/poll-vote.ts b/packages/backend/src/models/entities/poll-vote.ts new file mode 100644 index 0000000000..fca1cd0099 --- /dev/null +++ b/packages/backend/src/models/entities/poll-vote.ts @@ -0,0 +1,40 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { Note } from './note.js'; +import { id } from '../id.js'; + +@Entity() +@Index(['userId', 'noteId', 'choice'], { unique: true }) +export class PollVote { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the PollVote.', + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column(id()) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note: Note | null; + + @Column('integer') + public choice: number; +} diff --git a/packages/backend/src/models/entities/poll.ts b/packages/backend/src/models/entities/poll.ts new file mode 100644 index 0000000000..83d0873cc5 --- /dev/null +++ b/packages/backend/src/models/entities/poll.ts @@ -0,0 +1,72 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, OneToOne } from 'typeorm'; +import { id } from '../id.js'; +import { Note } from './note.js'; +import { User } from './user.js'; +import { noteVisibilities } from '../../types.js'; + +@Entity() +export class Poll { + @PrimaryColumn(id()) + public noteId: Note['id']; + + @OneToOne(type => Note, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note: Note | null; + + @Column('timestamp with time zone', { + nullable: true, + }) + public expiresAt: Date | null; + + @Column('boolean') + public multiple: boolean; + + @Column('varchar', { + length: 128, array: true, default: '{}', + }) + public choices: string[]; + + @Column('integer', { + array: true, + }) + public votes: number[]; + + //#region Denormalized fields + @Column('enum', { + enum: noteVisibilities, + comment: '[Denormalized]', + }) + public noteVisibility: typeof noteVisibilities[number]; + + @Index() + @Column({ + ...id(), + comment: '[Denormalized]', + }) + public userId: User['id']; + + @Index() + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]', + }) + public userHost: string | null; + //#endregion + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} + +export type IPoll = { + choices: string[]; + votes?: number[]; + multiple: boolean; + expiresAt: Date | null; +}; diff --git a/packages/backend/src/models/entities/promo-note.ts b/packages/backend/src/models/entities/promo-note.ts new file mode 100644 index 0000000000..d110b81e93 --- /dev/null +++ b/packages/backend/src/models/entities/promo-note.ts @@ -0,0 +1,28 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, OneToOne } from 'typeorm'; +import { Note } from './note.js'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +export class PromoNote { + @PrimaryColumn(id()) + public noteId: Note['id']; + + @OneToOne(type => Note, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note: Note | null; + + @Column('timestamp with time zone') + public expiresAt: Date; + + //#region Denormalized fields + @Index() + @Column({ + ...id(), + comment: '[Denormalized]', + }) + public userId: User['id']; + //#endregion +} diff --git a/packages/backend/src/models/entities/promo-read.ts b/packages/backend/src/models/entities/promo-read.ts new file mode 100644 index 0000000000..a63b79cd1e --- /dev/null +++ b/packages/backend/src/models/entities/promo-read.ts @@ -0,0 +1,35 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { Note } from './note.js'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +@Index(['userId', 'noteId'], { unique: true }) +export class PromoRead { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the PromoRead.', + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note: Note | null; +} diff --git a/packages/backend/src/models/entities/registration-tickets.ts b/packages/backend/src/models/entities/registration-tickets.ts new file mode 100644 index 0000000000..139e40f85e --- /dev/null +++ b/packages/backend/src/models/entities/registration-tickets.ts @@ -0,0 +1,17 @@ +import { PrimaryColumn, Entity, Index, Column } from 'typeorm'; +import { id } from '../id.js'; + +@Entity() +export class RegistrationTicket { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + @Index({ unique: true }) + @Column('varchar', { + length: 64, + }) + public code: string; +} diff --git a/packages/backend/src/models/entities/registry-item.ts b/packages/backend/src/models/entities/registry-item.ts new file mode 100644 index 0000000000..283796df91 --- /dev/null +++ b/packages/backend/src/models/entities/registry-item.ts @@ -0,0 +1,58 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +// TODO: 同じdomain、同じscope、同じkeyのレコードは二つ以上存在しないように制約付けたい +@Entity() +export class RegistryItem { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the RegistryItem.', + }) + public createdAt: Date; + + @Column('timestamp with time zone', { + comment: 'The updated date of the RegistryItem.', + }) + public updatedAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The owner ID.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 1024, + comment: 'The key of the RegistryItem.', + }) + public key: string; + + @Column('jsonb', { + default: {}, nullable: true, + comment: 'The value of the RegistryItem.', + }) + public value: any | null; + + @Index() + @Column('varchar', { + length: 1024, array: true, default: '{}', + }) + public scope: string[]; + + // サードパーティアプリに開放するときのためのカラム + @Index() + @Column('varchar', { + length: 512, nullable: true, + }) + public domain: string | null; +} diff --git a/packages/backend/src/models/entities/relay.ts b/packages/backend/src/models/entities/relay.ts new file mode 100644 index 0000000000..94d1929574 --- /dev/null +++ b/packages/backend/src/models/entities/relay.ts @@ -0,0 +1,19 @@ +import { PrimaryColumn, Entity, Index, Column } from 'typeorm'; +import { id } from '../id.js'; + +@Entity() +export class Relay { + @PrimaryColumn(id()) + public id: string; + + @Index({ unique: true }) + @Column('varchar', { + length: 512, nullable: false, + }) + public inbox: string; + + @Column('enum', { + enum: ['requesting', 'accepted', 'rejected'], + }) + public status: 'requesting' | 'accepted' | 'rejected'; +} diff --git a/packages/backend/src/models/entities/signin.ts b/packages/backend/src/models/entities/signin.ts new file mode 100644 index 0000000000..ba81f45e49 --- /dev/null +++ b/packages/backend/src/models/entities/signin.ts @@ -0,0 +1,35 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +export class Signin { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the Signin.', + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 128, + }) + public ip: string; + + @Column('jsonb') + public headers: Record; + + @Column('boolean') + public success: boolean; +} diff --git a/packages/backend/src/models/entities/sw-subscription.ts b/packages/backend/src/models/entities/sw-subscription.ts new file mode 100644 index 0000000000..59144d348b --- /dev/null +++ b/packages/backend/src/models/entities/sw-subscription.ts @@ -0,0 +1,37 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +export class SwSubscription { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 512, + }) + public endpoint: string; + + @Column('varchar', { + length: 256, + }) + public auth: string; + + @Column('varchar', { + length: 128, + }) + public publickey: string; +} diff --git a/packages/backend/src/models/entities/used-username.ts b/packages/backend/src/models/entities/used-username.ts new file mode 100644 index 0000000000..eb90bef6ca --- /dev/null +++ b/packages/backend/src/models/entities/used-username.ts @@ -0,0 +1,20 @@ +import { PrimaryColumn, Entity, Column } from 'typeorm'; + +@Entity() +export class UsedUsername { + @PrimaryColumn('varchar', { + length: 128, + }) + public username: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/user-group-invitation.ts b/packages/backend/src/models/entities/user-group-invitation.ts new file mode 100644 index 0000000000..10f357049f --- /dev/null +++ b/packages/backend/src/models/entities/user-group-invitation.ts @@ -0,0 +1,42 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { UserGroup } from './user-group.js'; +import { id } from '../id.js'; + +@Entity() +@Index(['userId', 'userGroupId'], { unique: true }) +export class UserGroupInvitation { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the UserGroupInvitation.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The user ID.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The group ID.', + }) + public userGroupId: UserGroup['id']; + + @ManyToOne(type => UserGroup, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public userGroup: UserGroup | null; +} diff --git a/packages/backend/src/models/entities/user-group-joining.ts b/packages/backend/src/models/entities/user-group-joining.ts new file mode 100644 index 0000000000..62a814218a --- /dev/null +++ b/packages/backend/src/models/entities/user-group-joining.ts @@ -0,0 +1,42 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { UserGroup } from './user-group.js'; +import { id } from '../id.js'; + +@Entity() +@Index(['userId', 'userGroupId'], { unique: true }) +export class UserGroupJoining { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the UserGroupJoining.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The user ID.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The group ID.', + }) + public userGroupId: UserGroup['id']; + + @ManyToOne(type => UserGroup, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public userGroup: UserGroup | null; +} diff --git a/packages/backend/src/models/entities/user-group.ts b/packages/backend/src/models/entities/user-group.ts new file mode 100644 index 0000000000..8d5de1d926 --- /dev/null +++ b/packages/backend/src/models/entities/user-group.ts @@ -0,0 +1,46 @@ +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +export class UserGroup { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the UserGroup.', + }) + public createdAt: Date; + + @Column('varchar', { + length: 256, + }) + public name: string; + + @Index() + @Column({ + ...id(), + comment: 'The ID of owner.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column('boolean', { + default: false, + }) + public isPrivate: boolean; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/user-ip.ts b/packages/backend/src/models/entities/user-ip.ts new file mode 100644 index 0000000000..543e9e7289 --- /dev/null +++ b/packages/backend/src/models/entities/user-ip.ts @@ -0,0 +1,24 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { id } from '../id.js'; +import { Note } from './note.js'; +import { User } from './user.js'; + +@Entity() +@Index(['userId', 'ip'], { unique: true }) +export class UserIp { + @PrimaryGeneratedColumn() + public id: string; + + @Column('timestamp with time zone', { + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @Column('varchar', { + length: 128, + }) + public ip: string; +} diff --git a/packages/backend/src/models/entities/user-keypair.ts b/packages/backend/src/models/entities/user-keypair.ts new file mode 100644 index 0000000000..85fa062977 --- /dev/null +++ b/packages/backend/src/models/entities/user-keypair.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, JoinColumn, Column, OneToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +export class UserKeypair { + @PrimaryColumn(id()) + public userId: User['id']; + + @OneToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 4096, + }) + public publicKey: string; + + @Column('varchar', { + length: 4096, + }) + public privateKey: string; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/user-list-joining.ts b/packages/backend/src/models/entities/user-list-joining.ts new file mode 100644 index 0000000000..12f28c4149 --- /dev/null +++ b/packages/backend/src/models/entities/user-list-joining.ts @@ -0,0 +1,42 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { UserList } from './user-list.js'; +import { id } from '../id.js'; + +@Entity() +@Index(['userId', 'userListId'], { unique: true }) +export class UserListJoining { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the UserListJoining.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The user ID.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The list ID.', + }) + public userListId: UserList['id']; + + @ManyToOne(type => UserList, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public userList: UserList | null; +} diff --git a/packages/backend/src/models/entities/user-list.ts b/packages/backend/src/models/entities/user-list.ts new file mode 100644 index 0000000000..ca69394e93 --- /dev/null +++ b/packages/backend/src/models/entities/user-list.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +export class UserList { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the UserList.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The owner ID.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 128, + comment: 'The name of the UserList.', + }) + public name: string; +} diff --git a/packages/backend/src/models/entities/user-note-pining.ts b/packages/backend/src/models/entities/user-note-pining.ts new file mode 100644 index 0000000000..c91ab7fdd8 --- /dev/null +++ b/packages/backend/src/models/entities/user-note-pining.ts @@ -0,0 +1,35 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { Note } from './note.js'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +@Index(['userId', 'noteId'], { unique: true }) +export class UserNotePining { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the UserNotePinings.', + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note: Note | null; +} diff --git a/packages/backend/src/models/entities/user-pending.ts b/packages/backend/src/models/entities/user-pending.ts new file mode 100644 index 0000000000..7637948841 --- /dev/null +++ b/packages/backend/src/models/entities/user-pending.ts @@ -0,0 +1,32 @@ +import { PrimaryColumn, Entity, Index, Column } from 'typeorm'; +import { id } from '../id.js'; + +@Entity() +export class UserPending { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + @Index({ unique: true }) + @Column('varchar', { + length: 128, + }) + public code: string; + + @Column('varchar', { + length: 128, + }) + public username: string; + + @Column('varchar', { + length: 128, + }) + public email: string; + + @Column('varchar', { + length: 128, + }) + public password: string; +} diff --git a/packages/backend/src/models/entities/user-profile.ts b/packages/backend/src/models/entities/user-profile.ts new file mode 100644 index 0000000000..3654b0a994 --- /dev/null +++ b/packages/backend/src/models/entities/user-profile.ts @@ -0,0 +1,232 @@ +import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; +import { ffVisibility, notificationTypes } from '@/types.js'; +import { id } from '../id.js'; +import { User } from './user.js'; +import { Page } from './page.js'; + +// TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも +// ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン +@Entity() +export class UserProfile { + @PrimaryColumn(id()) + public userId: User['id']; + + @OneToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 128, nullable: true, + comment: 'The location of the User.', + }) + public location: string | null; + + @Column('char', { + length: 10, nullable: true, + comment: 'The birthday (YYYY-MM-DD) of the User.', + }) + public birthday: string | null; + + @Column('varchar', { + length: 2048, nullable: true, + comment: 'The description (bio) of the User.', + }) + public description: string | null; + + @Column('jsonb', { + default: [], + }) + public fields: { + name: string; + value: string; + }[]; + + @Column('varchar', { + length: 32, nullable: true, + }) + public lang: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: 'Remote URL of the user.', + }) + public url: string | null; + + @Column('varchar', { + length: 128, nullable: true, + comment: 'The email address of the User.', + }) + public email: string | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public emailVerifyCode: string | null; + + @Column('boolean', { + default: false, + }) + public emailVerified: boolean; + + @Column('jsonb', { + default: ['follow', 'receiveFollowRequest', 'groupInvited'], + }) + public emailNotificationTypes: string[]; + + @Column('boolean', { + default: false, + }) + public publicReactions: boolean; + + @Column('enum', { + enum: ffVisibility, + default: 'public', + }) + public ffVisibility: typeof ffVisibility[number]; + + @Column('varchar', { + length: 128, nullable: true, + }) + public twoFactorTempSecret: string | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public twoFactorSecret: string | null; + + @Column('boolean', { + default: false, + }) + public twoFactorEnabled: boolean; + + @Column('boolean', { + default: false, + }) + public securityKeysAvailable: boolean; + + @Column('boolean', { + default: false, + }) + public usePasswordLessLogin: boolean; + + @Column('varchar', { + length: 128, nullable: true, + comment: 'The password hash of the User. It will be null if the origin of the user is local.', + }) + public password: string | null; + + @Column('varchar', { + length: 8192, default: '', + }) + public moderationNote: string | null; + + // TODO: そのうち消す + @Column('jsonb', { + default: {}, + comment: 'The client-specific data of the User.', + }) + public clientData: Record; + + // TODO: そのうち消す + @Column('jsonb', { + default: {}, + comment: 'The room data of the User.', + }) + public room: Record; + + @Column('boolean', { + default: false, + }) + public autoAcceptFollowed: boolean; + + @Column('boolean', { + default: false, + comment: 'Whether reject index by crawler.', + }) + public noCrawle: boolean; + + @Column('boolean', { + default: false, + }) + public alwaysMarkNsfw: boolean; + + @Column('boolean', { + default: false, + }) + public autoSensitive: boolean; + + @Column('boolean', { + default: false, + }) + public carefulBot: boolean; + + @Column('boolean', { + default: true, + }) + public injectFeaturedNote: boolean; + + @Column('boolean', { + default: true, + }) + public receiveAnnouncementEmail: boolean; + + @Column({ + ...id(), + nullable: true, + }) + public pinnedPageId: Page['id'] | null; + + @OneToOne(type => Page, { + onDelete: 'SET NULL', + }) + @JoinColumn() + public pinnedPage: Page | null; + + @Column('jsonb', { + default: {}, + }) + public integrations: Record; + + @Index() + @Column('boolean', { + default: false, select: false, + }) + public enableWordMute: boolean; + + @Column('jsonb', { + default: [], + }) + public mutedWords: string[][]; + + @Column('jsonb', { + default: [], + comment: 'List of instances muted by the user.', + }) + public mutedInstances: string[]; + + @Column('enum', { + enum: notificationTypes, + array: true, + default: [], + }) + public mutingNotificationTypes: typeof notificationTypes[number][]; + + //#region Denormalized fields + @Index() + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]', + }) + public userHost: string | null; + //#endregion + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/user-publickey.ts b/packages/backend/src/models/entities/user-publickey.ts new file mode 100644 index 0000000000..31ed60de82 --- /dev/null +++ b/packages/backend/src/models/entities/user-publickey.ts @@ -0,0 +1,34 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, OneToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +export class UserPublickey { + @PrimaryColumn(id()) + public userId: User['id']; + + @OneToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index({ unique: true }) + @Column('varchar', { + length: 256, + }) + public keyId: string; + + @Column('varchar', { + length: 4096, + }) + public keyPem: string; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/user-security-key.ts b/packages/backend/src/models/entities/user-security-key.ts new file mode 100644 index 0000000000..c4f2a852e2 --- /dev/null +++ b/packages/backend/src/models/entities/user-security-key.ts @@ -0,0 +1,48 @@ +import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +@Entity() +export class UserSecurityKey { + @PrimaryColumn('varchar', { + comment: 'Variable-length id given to navigator.credentials.get()', + }) + public id: string; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column('varchar', { + comment: + 'Variable-length public key used to verify attestations (hex-encoded).', + }) + public publicKey: string; + + @Column('timestamp with time zone', { + comment: + 'The date of the last time the UserSecurityKey was successfully validated.', + }) + public lastUsed: Date; + + @Column('varchar', { + comment: 'User-defined name for this key', + length: 30, + }) + public name: string; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/user.ts b/packages/backend/src/models/entities/user.ts new file mode 100644 index 0000000000..bc9446be41 --- /dev/null +++ b/packages/backend/src/models/entities/user.ts @@ -0,0 +1,248 @@ +import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; +import { id } from '../id.js'; +import { DriveFile } from './drive-file.js'; + +@Entity() +@Index(['usernameLower', 'host'], { unique: true }) +export class User { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the User.', + }) + public createdAt: Date; + + @Index() + @Column('timestamp with time zone', { + nullable: true, + comment: 'The updated date of the User.', + }) + public updatedAt: Date | null; + + @Column('timestamp with time zone', { + nullable: true, + }) + public lastFetchedAt: Date | null; + + @Index() + @Column('timestamp with time zone', { + nullable: true, + }) + public lastActiveDate: Date | null; + + @Column('boolean', { + default: false, + }) + public hideOnlineStatus: boolean; + + @Column('varchar', { + length: 128, + comment: 'The username of the User.', + }) + public username: string; + + @Index() + @Column('varchar', { + length: 128, select: false, + comment: 'The username (lowercased) of the User.', + }) + public usernameLower: string; + + @Column('varchar', { + length: 128, nullable: true, + comment: 'The name of the User.', + }) + public name: string | null; + + @Column('integer', { + default: 0, + comment: 'The count of followers.', + }) + public followersCount: number; + + @Column('integer', { + default: 0, + comment: 'The count of following.', + }) + public followingCount: number; + + @Column('integer', { + default: 0, + comment: 'The count of notes.', + }) + public notesCount: number; + + @Column({ + ...id(), + nullable: true, + comment: 'The ID of avatar DriveFile.', + }) + public avatarId: DriveFile['id'] | null; + + @OneToOne(type => DriveFile, { + onDelete: 'SET NULL', + }) + @JoinColumn() + public avatar: DriveFile | null; + + @Column({ + ...id(), + nullable: true, + comment: 'The ID of banner DriveFile.', + }) + public bannerId: DriveFile['id'] | null; + + @OneToOne(type => DriveFile, { + onDelete: 'SET NULL', + }) + @JoinColumn() + public banner: DriveFile | null; + + @Index() + @Column('varchar', { + length: 128, array: true, default: '{}', + }) + public tags: string[]; + + @Column('boolean', { + default: false, + comment: 'Whether the User is suspended.', + }) + public isSuspended: boolean; + + @Column('boolean', { + default: false, + comment: 'Whether the User is silenced.', + }) + public isSilenced: boolean; + + @Column('boolean', { + default: false, + comment: 'Whether the User is locked.', + }) + public isLocked: boolean; + + @Column('boolean', { + default: false, + comment: 'Whether the User is a bot.', + }) + public isBot: boolean; + + @Column('boolean', { + default: false, + comment: 'Whether the User is a cat.', + }) + public isCat: boolean; + + @Column('boolean', { + default: false, + comment: 'Whether the User is the admin.', + }) + public isAdmin: boolean; + + @Column('boolean', { + default: false, + comment: 'Whether the User is a moderator.', + }) + public isModerator: boolean; + + @Index() + @Column('boolean', { + default: true, + comment: 'Whether the User is explorable.', + }) + public isExplorable: boolean; + + // アカウントが削除されたかどうかのフラグだが、完全に削除される際は物理削除なので実質削除されるまでの「削除が進行しているかどうか」のフラグ + @Column('boolean', { + default: false, + comment: 'Whether the User is deleted.', + }) + public isDeleted: boolean; + + @Column('varchar', { + length: 128, array: true, default: '{}', + }) + public emojis: string[]; + + @Index() + @Column('varchar', { + length: 128, nullable: true, + comment: 'The host of the User. It will be null if the origin of the user is local.', + }) + public host: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: 'The inbox URL of the User. It will be null if the origin of the user is local.', + }) + public inbox: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: 'The sharedInbox URL of the User. It will be null if the origin of the user is local.', + }) + public sharedInbox: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: 'The featured URL of the User. It will be null if the origin of the user is local.', + }) + public featured: string | null; + + @Index() + @Column('varchar', { + length: 512, nullable: true, + comment: 'The URI of the User. It will be null if the origin of the user is local.', + }) + public uri: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: 'The URI of the user Follower Collection. It will be null if the origin of the user is local.', + }) + public followersUri: string | null; + + @Column('boolean', { + default: false, + comment: 'Whether to show users replying to other users in the timeline.', + }) + public showTimelineReplies: boolean; + + @Index({ unique: true }) + @Column('char', { + length: 16, nullable: true, unique: true, + comment: 'The native access token of the User. It will be null if the origin of the user is local.', + }) + public token: string | null; + + @Column('integer', { + nullable: true, + comment: 'Overrides user drive capacity limit', + }) + public driveCapacityOverrideMb: number | null; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} + +export interface ILocalUser extends User { + host: null; +} + +export interface IRemoteUser extends User { + host: string; +} + +export type CacheableLocalUser = ILocalUser; + +export type CacheableRemoteUser = IRemoteUser; + +export type CacheableUser = CacheableLocalUser | CacheableRemoteUser; diff --git a/packages/backend/src/models/entities/webhook.ts b/packages/backend/src/models/entities/webhook.ts new file mode 100644 index 0000000000..56b411f879 --- /dev/null +++ b/packages/backend/src/models/entities/webhook.ts @@ -0,0 +1,73 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +export const webhookEventTypes = ['mention', 'unfollow', 'follow', 'followed', 'note', 'reply', 'renote', 'reaction'] as const; + +@Entity() +export class Webhook { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the Antenna.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The owner ID.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 128, + comment: 'The name of the Antenna.', + }) + public name: string; + + @Index() + @Column('varchar', { + length: 128, array: true, default: '{}', + }) + public on: (typeof webhookEventTypes)[number][]; + + @Column('varchar', { + length: 1024, + }) + public url: string; + + @Column('varchar', { + length: 1024, + }) + public secret: string; + + @Index() + @Column('boolean', { + default: true, + }) + public active: boolean; + + /** + * 直近のリクエスト送信日時 + */ + @Column('timestamp with time zone', { + nullable: true, + }) + public latestSentAt: Date | null; + + /** + * 直近のリクエスト送信時のHTTPステータスコード + */ + @Column('integer', { + nullable: true, + }) + public latestStatus: number | null; +} diff --git a/packages/backend/src/models/id.ts b/packages/backend/src/models/id.ts new file mode 100644 index 0000000000..d614fc5048 --- /dev/null +++ b/packages/backend/src/models/id.ts @@ -0,0 +1,4 @@ +export const id = () => ({ + type: 'varchar' as const, + length: 32, +}); diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts new file mode 100644 index 0000000000..3f73269318 --- /dev/null +++ b/packages/backend/src/models/index.ts @@ -0,0 +1,133 @@ +import { } from 'typeorm'; +import { db } from '@/db/postgre.js'; + +import { Announcement } from './entities/announcement.js'; +import { AnnouncementRead } from './entities/announcement-read.js'; +import { Instance } from './entities/instance.js'; +import { Poll } from './entities/poll.js'; +import { PollVote } from './entities/poll-vote.js'; +import { Meta } from './entities/meta.js'; +import { SwSubscription } from './entities/sw-subscription.js'; +import { NoteWatching } from './entities/note-watching.js'; +import { NoteThreadMuting } from './entities/note-thread-muting.js'; +import { NoteUnread } from './entities/note-unread.js'; +import { RegistrationTicket } from './entities/registration-tickets.js'; +import { UserRepository } from './repositories/user.js'; +import { NoteRepository } from './repositories/note.js'; +import { DriveFileRepository } from './repositories/drive-file.js'; +import { DriveFolderRepository } from './repositories/drive-folder.js'; +import { AccessToken } from './entities/access-token.js'; +import { UserNotePining } from './entities/user-note-pining.js'; +import { SigninRepository } from './repositories/signin.js'; +import { MessagingMessageRepository } from './repositories/messaging-message.js'; +import { UserListRepository } from './repositories/user-list.js'; +import { UserListJoining } from './entities/user-list-joining.js'; +import { UserGroupRepository } from './repositories/user-group.js'; +import { UserGroupJoining } from './entities/user-group-joining.js'; +import { UserGroupInvitationRepository } from './repositories/user-group-invitation.js'; +import { FollowRequestRepository } from './repositories/follow-request.js'; +import { MutingRepository } from './repositories/muting.js'; +import { BlockingRepository } from './repositories/blocking.js'; +import { NoteReactionRepository } from './repositories/note-reaction.js'; +import { NotificationRepository } from './repositories/notification.js'; +import { NoteFavoriteRepository } from './repositories/note-favorite.js'; +import { UserPublickey } from './entities/user-publickey.js'; +import { UserKeypair } from './entities/user-keypair.js'; +import { AppRepository } from './repositories/app.js'; +import { FollowingRepository } from './repositories/following.js'; +import { AbuseUserReportRepository } from './repositories/abuse-user-report.js'; +import { AuthSessionRepository } from './repositories/auth-session.js'; +import { UserProfile } from './entities/user-profile.js'; +import { AttestationChallenge } from './entities/attestation-challenge.js'; +import { UserSecurityKey } from './entities/user-security-key.js'; +import { HashtagRepository } from './repositories/hashtag.js'; +import { PageRepository } from './repositories/page.js'; +import { PageLikeRepository } from './repositories/page-like.js'; +import { GalleryPostRepository } from './repositories/gallery-post.js'; +import { GalleryLikeRepository } from './repositories/gallery-like.js'; +import { ModerationLogRepository } from './repositories/moderation-logs.js'; +import { UsedUsername } from './entities/used-username.js'; +import { ClipRepository } from './repositories/clip.js'; +import { ClipNote } from './entities/clip-note.js'; +import { AntennaRepository } from './repositories/antenna.js'; +import { AntennaNote } from './entities/antenna-note.js'; +import { PromoNote } from './entities/promo-note.js'; +import { PromoRead } from './entities/promo-read.js'; +import { EmojiRepository } from './repositories/emoji.js'; +import { RelayRepository } from './repositories/relay.js'; +import { ChannelRepository } from './repositories/channel.js'; +import { MutedNote } from './entities/muted-note.js'; +import { ChannelFollowing } from './entities/channel-following.js'; +import { ChannelNotePining } from './entities/channel-note-pining.js'; +import { RegistryItem } from './entities/registry-item.js'; +import { Ad } from './entities/ad.js'; +import { PasswordResetRequest } from './entities/password-reset-request.js'; +import { UserPending } from './entities/user-pending.js'; +import { InstanceRepository } from './repositories/instance.js'; +import { Webhook } from './entities/webhook.js'; +import { UserIp } from './entities/user-ip.js'; + +export const Announcements = db.getRepository(Announcement); +export const AnnouncementReads = db.getRepository(AnnouncementRead); +export const Apps = (AppRepository); +export const Notes = (NoteRepository); +export const NoteFavorites = (NoteFavoriteRepository); +export const NoteWatchings = db.getRepository(NoteWatching); +export const NoteThreadMutings = db.getRepository(NoteThreadMuting); +export const NoteReactions = (NoteReactionRepository); +export const NoteUnreads = db.getRepository(NoteUnread); +export const Polls = db.getRepository(Poll); +export const PollVotes = db.getRepository(PollVote); +export const Users = (UserRepository); +export const UserProfiles = db.getRepository(UserProfile); +export const UserKeypairs = db.getRepository(UserKeypair); +export const UserPendings = db.getRepository(UserPending); +export const AttestationChallenges = db.getRepository(AttestationChallenge); +export const UserSecurityKeys = db.getRepository(UserSecurityKey); +export const UserPublickeys = db.getRepository(UserPublickey); +export const UserLists = (UserListRepository); +export const UserListJoinings = db.getRepository(UserListJoining); +export const UserGroups = (UserGroupRepository); +export const UserGroupJoinings = db.getRepository(UserGroupJoining); +export const UserGroupInvitations = (UserGroupInvitationRepository); +export const UserNotePinings = db.getRepository(UserNotePining); +export const UserIps = db.getRepository(UserIp); +export const UsedUsernames = db.getRepository(UsedUsername); +export const Followings = (FollowingRepository); +export const FollowRequests = (FollowRequestRepository); +export const Instances = (InstanceRepository); +export const Emojis = (EmojiRepository); +export const DriveFiles = (DriveFileRepository); +export const DriveFolders = (DriveFolderRepository); +export const Notifications = (NotificationRepository); +export const Metas = db.getRepository(Meta); +export const Mutings = (MutingRepository); +export const Blockings = (BlockingRepository); +export const SwSubscriptions = db.getRepository(SwSubscription); +export const Hashtags = (HashtagRepository); +export const AbuseUserReports = (AbuseUserReportRepository); +export const RegistrationTickets = db.getRepository(RegistrationTicket); +export const AuthSessions = (AuthSessionRepository); +export const AccessTokens = db.getRepository(AccessToken); +export const Signins = (SigninRepository); +export const MessagingMessages = (MessagingMessageRepository); +export const Pages = (PageRepository); +export const PageLikes = (PageLikeRepository); +export const GalleryPosts = (GalleryPostRepository); +export const GalleryLikes = (GalleryLikeRepository); +export const ModerationLogs = (ModerationLogRepository); +export const Clips = (ClipRepository); +export const ClipNotes = db.getRepository(ClipNote); +export const Antennas = (AntennaRepository); +export const AntennaNotes = db.getRepository(AntennaNote); +export const PromoNotes = db.getRepository(PromoNote); +export const PromoReads = db.getRepository(PromoRead); +export const Relays = (RelayRepository); +export const MutedNotes = db.getRepository(MutedNote); +export const Channels = (ChannelRepository); +export const ChannelFollowings = db.getRepository(ChannelFollowing); +export const ChannelNotePinings = db.getRepository(ChannelNotePining); +export const RegistryItems = db.getRepository(RegistryItem); +export const Webhooks = db.getRepository(Webhook); +export const Ads = db.getRepository(Ad); +export const PasswordResetRequests = db.getRepository(PasswordResetRequest); diff --git a/packages/backend/src/models/json-schema/ad.ts b/packages/backend/src/models/json-schema/ad.ts deleted file mode 100644 index 649ffcd4dc..0000000000 --- a/packages/backend/src/models/json-schema/ad.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedAdSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, - nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - expiresAt: { - type: 'string', - optional: false, - nullable: false, - format: 'date-time', - }, - startsAt: { - type: 'string', - optional: false, - nullable: false, - format: 'date-time', - }, - place: { - type: 'string', - optional: false, - nullable: false, - }, - priority: { - type: 'string', - optional: false, - nullable: false, - }, - ratio: { - type: 'number', - optional: false, - nullable: false, - }, - url: { - type: 'string', - optional: false, - nullable: false, - }, - imageUrl: { - type: 'string', - optional: false, - nullable: false, - }, - memo: { - type: 'string', - optional: false, - nullable: false, - }, - dayOfWeek: { - type: 'integer', - optional: false, - nullable: false, - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/announcement.ts b/packages/backend/src/models/json-schema/announcement.ts deleted file mode 100644 index 78a98872b2..0000000000 --- a/packages/backend/src/models/json-schema/announcement.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedAnnouncementSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - updatedAt: { - type: 'string', - optional: false, nullable: true, - format: 'date-time', - }, - text: { - type: 'string', - optional: false, nullable: false, - }, - title: { - type: 'string', - optional: false, nullable: false, - }, - imageUrl: { - type: 'string', - optional: false, nullable: true, - }, - icon: { - type: 'string', - optional: false, nullable: false, - }, - display: { - type: 'string', - optional: false, nullable: false, - }, - needConfirmationToRead: { - type: 'boolean', - optional: false, nullable: false, - }, - silence: { - type: 'boolean', - optional: false, nullable: false, - }, - forYou: { - type: 'boolean', - optional: false, nullable: false, - }, - isRead: { - type: 'boolean', - optional: true, nullable: false, - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/antenna.ts b/packages/backend/src/models/json-schema/antenna.ts deleted file mode 100644 index 4a9f0ed355..0000000000 --- a/packages/backend/src/models/json-schema/antenna.ts +++ /dev/null @@ -1,98 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedAntennaSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - keywords: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - }, - }, - }, - excludeKeywords: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - }, - }, - }, - src: { - type: 'string', - optional: false, nullable: false, - enum: ['home', 'all', 'users', 'list', 'users_blacklist'], - }, - userListId: { - type: 'string', - optional: false, nullable: true, - format: 'id', - }, - users: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - }, - }, - caseSensitive: { - type: 'boolean', - optional: false, nullable: false, - default: false, - }, - localOnly: { - type: 'boolean', - optional: false, nullable: false, - default: false, - }, - notify: { - type: 'boolean', - optional: false, nullable: false, - }, - withReplies: { - type: 'boolean', - optional: false, nullable: false, - default: false, - }, - withFile: { - type: 'boolean', - optional: false, nullable: false, - }, - isActive: { - type: 'boolean', - optional: false, nullable: false, - }, - hasUnreadNote: { - type: 'boolean', - optional: false, nullable: false, - default: false, - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/app.ts b/packages/backend/src/models/json-schema/app.ts deleted file mode 100644 index 9e0916299c..0000000000 --- a/packages/backend/src/models/json-schema/app.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedAppSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - callbackUrl: { - type: 'string', - optional: false, nullable: true, - }, - permission: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - }, - }, - secret: { - type: 'string', - optional: true, nullable: false, - }, - isAuthorized: { - type: 'boolean', - optional: true, nullable: false, - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/blocking.ts b/packages/backend/src/models/json-schema/blocking.ts deleted file mode 100644 index 0b58f1f8d7..0000000000 --- a/packages/backend/src/models/json-schema/blocking.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedBlockingSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - blockeeId: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - blockee: { - type: 'object', - optional: false, nullable: false, - ref: 'UserDetailed', - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/channel.ts b/packages/backend/src/models/json-schema/channel.ts deleted file mode 100644 index 5b0fa0f15d..0000000000 --- a/packages/backend/src/models/json-schema/channel.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedChannelSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - lastNotedAt: { - type: 'string', - nullable: true, optional: false, - format: 'date-time', - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - description: { - type: 'string', - optional: false, nullable: true, - }, - userId: { - type: 'string', - nullable: true, optional: false, - format: 'id', - }, - bannerUrl: { - type: 'string', - format: 'url', - nullable: true, optional: false, - }, - pinnedNoteIds: { - type: 'array', - nullable: false, optional: false, - items: { - type: 'string', - format: 'id', - }, - }, - color: { - type: 'string', - optional: false, nullable: false, - }, - isArchived: { - type: 'boolean', - optional: false, nullable: false, - }, - usersCount: { - type: 'number', - nullable: false, optional: false, - }, - notesCount: { - type: 'number', - nullable: false, optional: false, - }, - isSensitive: { - type: 'boolean', - optional: false, nullable: false, - }, - allowRenoteToExternal: { - type: 'boolean', - optional: false, nullable: false, - }, - isFollowing: { - type: 'boolean', - optional: true, nullable: false, - }, - isFavorited: { - type: 'boolean', - optional: true, nullable: false, - }, - pinnedNotes: { - type: 'array', - optional: true, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'Note', - }, - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/clip.ts b/packages/backend/src/models/json-schema/clip.ts deleted file mode 100644 index 1ab96c2b3b..0000000000 --- a/packages/backend/src/models/json-schema/clip.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedClipSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - lastClippedAt: { - type: 'string', - optional: false, nullable: true, - format: 'date-time', - }, - userId: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - user: { - type: 'object', - ref: 'UserLite', - optional: false, nullable: false, - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - description: { - type: 'string', - optional: false, nullable: true, - }, - isPublic: { - type: 'boolean', - optional: false, nullable: false, - }, - favoritedCount: { - type: 'number', - optional: false, nullable: false, - }, - isFavorited: { - type: 'boolean', - optional: true, nullable: false, - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/drive-file.ts b/packages/backend/src/models/json-schema/drive-file.ts deleted file mode 100644 index 79f242a711..0000000000 --- a/packages/backend/src/models/json-schema/drive-file.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedDriveFileSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - name: { - type: 'string', - optional: false, nullable: false, - example: 'lenna.jpg', - }, - type: { - type: 'string', - optional: false, nullable: false, - example: 'image/jpeg', - }, - md5: { - type: 'string', - optional: false, nullable: false, - format: 'md5', - example: '15eca7fba0480996e2245f5185bf39f2', - }, - size: { - type: 'number', - optional: false, nullable: false, - example: 51469, - }, - isSensitive: { - type: 'boolean', - optional: false, nullable: false, - }, - blurhash: { - type: 'string', - optional: false, nullable: true, - }, - properties: { - type: 'object', - optional: false, nullable: false, - properties: { - width: { - type: 'number', - optional: true, nullable: false, - example: 1280, - }, - height: { - type: 'number', - optional: true, nullable: false, - example: 720, - }, - orientation: { - type: 'number', - optional: true, nullable: false, - example: 8, - }, - avgColor: { - type: 'string', - optional: true, nullable: false, - example: 'rgb(40,65,87)', - }, - }, - }, - url: { - type: 'string', - optional: false, nullable: false, - format: 'url', - }, - thumbnailUrl: { - type: 'string', - optional: false, nullable: true, - format: 'url', - }, - comment: { - type: 'string', - optional: false, nullable: true, - }, - folderId: { - type: 'string', - optional: false, nullable: true, - format: 'id', - example: 'xxxxxxxxxx', - }, - folder: { - type: 'object', - optional: true, nullable: true, - ref: 'DriveFolder', - }, - userId: { - type: 'string', - optional: false, nullable: true, - format: 'id', - example: 'xxxxxxxxxx', - }, - user: { - type: 'object', - optional: true, nullable: true, - ref: 'UserLite', - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/drive-folder.ts b/packages/backend/src/models/json-schema/drive-folder.ts deleted file mode 100644 index aaad301303..0000000000 --- a/packages/backend/src/models/json-schema/drive-folder.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedDriveFolderSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - parentId: { - type: 'string', - optional: false, nullable: true, - format: 'id', - example: 'xxxxxxxxxx', - }, - foldersCount: { - type: 'number', - optional: true, nullable: false, - }, - filesCount: { - type: 'number', - optional: true, nullable: false, - }, - parent: { - type: 'object', - optional: true, nullable: true, - ref: 'DriveFolder', - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/emoji.ts b/packages/backend/src/models/json-schema/emoji.ts deleted file mode 100644 index 99a58f8773..0000000000 --- a/packages/backend/src/models/json-schema/emoji.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedEmojiSimpleSchema = { - type: 'object', - properties: { - aliases: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - category: { - type: 'string', - optional: false, nullable: true, - }, - url: { - type: 'string', - optional: false, nullable: false, - }, - isSensitive: { - type: 'boolean', - optional: true, nullable: false, - }, - roleIdsThatCanBeUsedThisEmojiAsReaction: { - type: 'array', - optional: true, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - }, - }, -} as const; - -export const packedEmojiDetailedSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - aliases: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - category: { - type: 'string', - optional: false, nullable: true, - }, - host: { - type: 'string', - optional: false, nullable: true, - description: 'The local host is represented with `null`.', - }, - url: { - type: 'string', - optional: false, nullable: false, - }, - license: { - type: 'string', - optional: false, nullable: true, - }, - isSensitive: { - type: 'boolean', - optional: false, nullable: false, - }, - localOnly: { - type: 'boolean', - optional: false, nullable: false, - }, - roleIdsThatCanBeUsedThisEmojiAsReaction: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/flash.ts b/packages/backend/src/models/json-schema/flash.ts deleted file mode 100644 index f08fa7a279..0000000000 --- a/packages/backend/src/models/json-schema/flash.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedFlashSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - updatedAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - userId: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - user: { - type: 'object', - ref: 'UserLite', - optional: false, nullable: false, - }, - title: { - type: 'string', - optional: false, nullable: false, - }, - summary: { - type: 'string', - optional: false, nullable: false, - }, - script: { - type: 'string', - optional: false, nullable: false, - }, - likedCount: { - type: 'number', - optional: false, nullable: true, - }, - isLiked: { - type: 'boolean', - optional: true, nullable: false, - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/following.ts b/packages/backend/src/models/json-schema/following.ts deleted file mode 100644 index e92cff20a1..0000000000 --- a/packages/backend/src/models/json-schema/following.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedFollowingSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - followeeId: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - followerId: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - followee: { - type: 'object', - optional: true, nullable: false, - ref: 'UserDetailed', - }, - follower: { - type: 'object', - optional: true, nullable: false, - ref: 'UserDetailed', - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/gallery-post.ts b/packages/backend/src/models/json-schema/gallery-post.ts deleted file mode 100644 index df7038950c..0000000000 --- a/packages/backend/src/models/json-schema/gallery-post.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedGalleryPostSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - updatedAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - userId: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - user: { - type: 'object', - ref: 'UserLite', - optional: false, nullable: false, - }, - title: { - type: 'string', - optional: false, nullable: false, - }, - description: { - type: 'string', - optional: false, nullable: true, - }, - fileIds: { - type: 'array', - optional: true, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - }, - files: { - type: 'array', - optional: true, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'DriveFile', - }, - }, - tags: { - type: 'array', - optional: true, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - }, - }, - isSensitive: { - type: 'boolean', - optional: false, nullable: false, - }, - likedCount: { - type: 'number', - optional: false, nullable: false, - }, - isLiked: { - type: 'boolean', - optional: true, nullable: false, - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/hashtag.ts b/packages/backend/src/models/json-schema/hashtag.ts deleted file mode 100644 index a48e972a5d..0000000000 --- a/packages/backend/src/models/json-schema/hashtag.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedHashtagSchema = { - type: 'object', - properties: { - tag: { - type: 'string', - optional: false, nullable: false, - example: 'misskey', - }, - mentionedUsersCount: { - type: 'number', - optional: false, nullable: false, - }, - mentionedLocalUsersCount: { - type: 'number', - optional: false, nullable: false, - }, - mentionedRemoteUsersCount: { - type: 'number', - optional: false, nullable: false, - }, - attachedUsersCount: { - type: 'number', - optional: false, nullable: false, - }, - attachedLocalUsersCount: { - type: 'number', - optional: false, nullable: false, - }, - attachedRemoteUsersCount: { - type: 'number', - optional: false, nullable: false, - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/invite-code.ts b/packages/backend/src/models/json-schema/invite-code.ts deleted file mode 100644 index cd8bf98d90..0000000000 --- a/packages/backend/src/models/json-schema/invite-code.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedInviteCodeSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - code: { - type: 'string', - optional: false, nullable: false, - example: 'GR6S02ERUA5VR', - }, - expiresAt: { - type: 'string', - optional: false, nullable: true, - format: 'date-time', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - createdBy: { - type: 'object', - optional: false, nullable: true, - ref: 'UserLite', - }, - usedBy: { - type: 'object', - optional: false, nullable: true, - ref: 'UserLite', - }, - usedAt: { - type: 'string', - optional: false, nullable: true, - format: 'date-time', - }, - used: { - type: 'boolean', - optional: false, nullable: false, - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/muting.ts b/packages/backend/src/models/json-schema/muting.ts deleted file mode 100644 index dde9dc0288..0000000000 --- a/packages/backend/src/models/json-schema/muting.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedMutingSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - expiresAt: { - type: 'string', - optional: false, nullable: true, - format: 'date-time', - }, - muteeId: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - mutee: { - type: 'object', - optional: false, nullable: false, - ref: 'UserDetailed', - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/note-edit.ts b/packages/backend/src/models/json-schema/note-edit.ts deleted file mode 100644 index a58e2aa1de..0000000000 --- a/packages/backend/src/models/json-schema/note-edit.ts +++ /dev/null @@ -1,54 +0,0 @@ -export const packedNoteEdit = { - type: "object", - properties: { - id: { - type: "string", - optional: false, - nullable: false, - format: "id", - example: "xxxxxxxxxx", - }, - updatedAt: { - type: "string", - optional: false, - nullable: false, - format: "date-time", - }, - note: { - type: "object", - optional: false, - nullable: false, - ref: "Note", - }, - noteId: { - type: "string", - optional: false, - nullable: false, - format: "id", - }, - oldText: { - type: "string", - optional: true, - nullable: true, - }, - newText: { - type: "string", - optional: true, - nullable: true, - }, - cw: { - type: "string", - optional: true, - nullable: true, - }, - fileIds: { - type: "array", - optional: true, - nullable: true, - items: { - type: "string", - format: "id", - }, - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/note-favorite.ts b/packages/backend/src/models/json-schema/note-favorite.ts deleted file mode 100644 index 3f0007d917..0000000000 --- a/packages/backend/src/models/json-schema/note-favorite.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedNoteFavoriteSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - note: { - type: 'object', - optional: false, nullable: false, - ref: 'Note', - }, - noteId: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/note-reaction.ts b/packages/backend/src/models/json-schema/note-reaction.ts deleted file mode 100644 index e3335f426e..0000000000 --- a/packages/backend/src/models/json-schema/note-reaction.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedNoteReactionSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - user: { - type: 'object', - optional: false, nullable: false, - ref: 'UserLite', - }, - type: { - type: 'string', - optional: false, nullable: false, - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts deleted file mode 100644 index aa749943f0..0000000000 --- a/packages/backend/src/models/json-schema/note.ts +++ /dev/null @@ -1,199 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedNoteSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - deletedAt: { - type: 'string', - optional: true, nullable: true, - format: 'date-time', - }, - text: { - type: 'string', - optional: false, nullable: true, - }, - cw: { - type: 'string', - optional: true, nullable: true, - }, - userId: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - user: { - type: 'object', - ref: 'UserLite', - optional: false, nullable: false, - }, - replyId: { - type: 'string', - optional: true, nullable: true, - format: 'id', - example: 'xxxxxxxxxx', - }, - renoteId: { - type: 'string', - optional: true, nullable: true, - format: 'id', - example: 'xxxxxxxxxx', - }, - reply: { - type: 'object', - optional: true, nullable: true, - ref: 'Note', - }, - renote: { - type: 'object', - optional: true, nullable: true, - ref: 'Note', - }, - isHidden: { - type: 'boolean', - optional: true, nullable: false, - }, - visibility: { - type: 'string', - optional: false, nullable: false, - }, - mentions: { - type: 'array', - optional: true, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - }, - visibleUserIds: { - type: 'array', - optional: true, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - }, - fileIds: { - type: 'array', - optional: true, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - }, - files: { - type: 'array', - optional: true, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'DriveFile', - }, - }, - tags: { - type: 'array', - optional: true, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - }, - }, - poll: { - type: 'object', - optional: true, nullable: true, - }, - channelId: { - type: 'string', - optional: true, nullable: true, - format: 'id', - example: 'xxxxxxxxxx', - }, - channel: { - type: 'object', - optional: true, nullable: true, - properties: { - id: { - type: 'string', - optional: false, nullable: false, - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - color: { - type: 'string', - optional: false, nullable: false, - }, - isSensitive: { - type: 'boolean', - optional: false, nullable: false, - }, - allowRenoteToExternal: { - type: 'boolean', - optional: false, nullable: false, - }, - }, - }, - localOnly: { - type: 'boolean', - optional: true, nullable: false, - }, - reactionAcceptance: { - type: 'string', - optional: false, nullable: true, - }, - reactions: { - type: 'object', - optional: false, nullable: false, - }, - renoteCount: { - type: 'number', - optional: false, nullable: false, - }, - repliesCount: { - type: 'number', - optional: false, nullable: false, - }, - uri: { - type: 'string', - optional: true, nullable: false, - }, - url: { - type: 'string', - optional: true, nullable: false, - }, - reactionAndUserPairCache: { - type: 'array', - optional: true, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - }, - }, - clippedCount: { - type: 'number', - optional: true, nullable: false, - }, - - myReaction: { - type: 'object', - optional: true, nullable: true, - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts deleted file mode 100644 index c6d6e84317..0000000000 --- a/packages/backend/src/models/json-schema/notification.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { notificationTypes } from '@/types.js'; - -export const packedNotificationSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - type: { - type: 'string', - optional: false, nullable: false, - enum: [...notificationTypes, 'reaction:grouped', 'renote:grouped'], - }, - user: { - type: 'object', - ref: 'UserLite', - optional: true, nullable: true, - }, - userId: { - type: 'string', - optional: true, nullable: true, - format: 'id', - }, - note: { - type: 'object', - ref: 'Note', - optional: true, nullable: true, - }, - reaction: { - type: 'string', - optional: true, nullable: true, - }, - achievement: { - type: 'string', - optional: true, nullable: false, - }, - body: { - type: 'string', - optional: true, nullable: true, - }, - header: { - type: 'string', - optional: true, nullable: true, - }, - icon: { - type: 'string', - optional: true, nullable: true, - }, - reactions: { - type: 'array', - optional: true, nullable: true, - items: { - type: 'object', - properties: { - user: { - type: 'object', - ref: 'UserLite', - optional: false, nullable: false, - }, - reaction: { - type: 'string', - optional: false, nullable: false, - }, - }, - required: ['user', 'reaction'], - }, - }, - users: { - type: 'array', - optional: true, nullable: true, - items: { - type: 'object', - ref: 'UserLite', - optional: false, nullable: false, - }, - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/page.ts b/packages/backend/src/models/json-schema/page.ts deleted file mode 100644 index 9baacd6884..0000000000 --- a/packages/backend/src/models/json-schema/page.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedPageSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - updatedAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - userId: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - user: { - type: 'object', - ref: 'UserLite', - optional: false, nullable: false, - }, - content: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - }, - }, - variables: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - }, - }, - title: { - type: 'string', - optional: false, nullable: false, - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - summary: { - type: 'string', - optional: false, nullable: true, - }, - hideTitleWhenPinned: { - type: 'boolean', - optional: false, nullable: false, - }, - alignCenter: { - type: 'boolean', - optional: false, nullable: false, - }, - font: { - type: 'string', - optional: false, nullable: false, - }, - script: { - type: 'string', - optional: false, nullable: false, - }, - eyeCatchingImageId: { - type: 'string', - optional: false, nullable: true, - }, - eyeCatchingImage: { - type: 'object', - optional: false, nullable: true, - ref: 'DriveFile', - }, - attachedFiles: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'DriveFile', - }, - }, - likedCount: { - type: 'number', - optional: false, nullable: false, - }, - isLiked: { - type: 'boolean', - optional: true, nullable: false, - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/renote-muting.ts b/packages/backend/src/models/json-schema/renote-muting.ts deleted file mode 100644 index feed1ceb09..0000000000 --- a/packages/backend/src/models/json-schema/renote-muting.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedRenoteMutingSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - muteeId: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - mutee: { - type: 'object', - optional: false, nullable: false, - ref: 'UserDetailed', - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts deleted file mode 100644 index b0c6804bb8..0000000000 --- a/packages/backend/src/models/json-schema/role.ts +++ /dev/null @@ -1,158 +0,0 @@ -const rolePolicyValue = { - type: 'object', - properties: { - value: { - oneOf: [ - { - type: 'integer', - optional: false, nullable: false, - }, - { - type: 'boolean', - optional: false, nullable: false, - }, - ], - }, - priority: { - type: 'integer', - optional: false, nullable: false, - }, - useDefault: { - type: 'boolean', - optional: false, nullable: false, - }, - }, -} as const; - -export const packedRoleLiteSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - name: { - type: 'string', - optional: false, nullable: false, - example: 'New Role', - }, - color: { - type: 'string', - optional: false, nullable: true, - example: '#000000', - }, - iconUrl: { - type: 'string', - optional: false, nullable: true, - }, - description: { - type: 'string', - optional: false, nullable: false, - }, - isModerator: { - type: 'boolean', - optional: false, nullable: false, - example: false, - }, - isAdministrator: { - type: 'boolean', - optional: false, nullable: false, - example: false, - }, - displayOrder: { - type: 'integer', - optional: false, nullable: false, - example: 0, - }, - }, -} as const; - -export const packedRoleSchema = { - type: 'object', - allOf: [ - { - type: 'object', - ref: 'RoleLite', - }, - { - type: 'object', - properties: { - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - updatedAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - target: { - type: 'string', - optional: false, nullable: false, - enum: ['manual', 'conditional'], - }, - condFormula: { - type: 'object', - optional: false, nullable: false, - }, - isPublic: { - type: 'boolean', - optional: false, nullable: false, - example: false, - }, - isExplorable: { - type: 'boolean', - optional: false, nullable: false, - example: false, - }, - asBadge: { - type: 'boolean', - optional: false, nullable: false, - example: false, - }, - canEditMembersByModerator: { - type: 'boolean', - optional: false, nullable: false, - example: false, - }, - policies: { - type: 'object', - optional: false, nullable: false, - properties: { - pinLimit: rolePolicyValue, - canInvite: rolePolicyValue, - clipLimit: rolePolicyValue, - canHideAds: rolePolicyValue, - inviteLimit: rolePolicyValue, - antennaLimit: rolePolicyValue, - gtlAvailable: rolePolicyValue, - ltlAvailable: rolePolicyValue, - webhookLimit: rolePolicyValue, - canPublicNote: rolePolicyValue, - userListLimit: rolePolicyValue, - wordMuteLimit: rolePolicyValue, - alwaysMarkNsfw: rolePolicyValue, - canSearchNotes: rolePolicyValue, - driveCapacityMb: rolePolicyValue, - rateLimitFactor: rolePolicyValue, - inviteLimitCycle: rolePolicyValue, - noteEachClipsLimit: rolePolicyValue, - inviteExpirationTime: rolePolicyValue, - canManageCustomEmojis: rolePolicyValue, - userEachUserListsLimit: rolePolicyValue, - canManageAvatarDecorations: rolePolicyValue, - canUseTranslator: rolePolicyValue, - avatarDecorationLimit: rolePolicyValue, - }, - }, - usersCount: { - type: 'integer', - optional: false, nullable: false, - }, - }, - }, - ], -} as const; diff --git a/packages/backend/src/models/json-schema/signin.ts b/packages/backend/src/models/json-schema/signin.ts deleted file mode 100644 index d27d2490c5..0000000000 --- a/packages/backend/src/models/json-schema/signin.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const packedSigninSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - ip: { - type: 'string', - optional: false, nullable: false, - }, - headers: { - type: 'object', - optional: false, nullable: false, - }, - success: { - type: 'boolean', - optional: false, nullable: false, - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/user-list.ts b/packages/backend/src/models/json-schema/user-list.ts deleted file mode 100644 index e257d9984c..0000000000 --- a/packages/backend/src/models/json-schema/user-list.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedUserListSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - userIds: { - type: 'array', - nullable: false, optional: true, - items: { - type: 'string', - nullable: false, optional: false, - format: 'id', - }, - }, - isPublic: { - type: 'boolean', - nullable: false, - optional: false, - }, - }, -} as const; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts deleted file mode 100644 index af67e62afa..0000000000 --- a/packages/backend/src/models/json-schema/user.ts +++ /dev/null @@ -1,832 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -const notificationRecieveConfig = { - type: 'object', - nullable: false, optional: true, - properties: { - type: { - type: 'string', - nullable: false, optional: false, - enum: ['all', 'following', 'follower', 'mutualFollow', 'list', 'never'], - }, - }, -} as const; - -export const packedUserLiteSchema = { - type: 'object', - properties: { - id: { - type: 'string', - nullable: false, optional: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - name: { - type: 'string', - nullable: true, optional: false, - example: '藍', - }, - username: { - type: 'string', - nullable: false, optional: false, - example: 'ai', - }, - host: { - type: 'string', - nullable: true, optional: false, - example: 'misskey.example.com', - description: 'The local host is represented with `null`.', - }, - avatarUrl: { - type: 'string', - format: 'url', - nullable: true, optional: false, - }, - avatarBlurhash: { - type: 'string', - nullable: true, optional: false, - }, - avatarDecorations: { - type: 'array', - nullable: false, optional: false, - items: { - type: 'object', - nullable: false, optional: false, - properties: { - id: { - type: 'string', - nullable: false, optional: false, - format: 'id', - }, - angle: { - type: 'number', - nullable: false, optional: true, - }, - flipH: { - type: 'boolean', - nullable: false, optional: true, - }, - url: { - type: 'string', - format: 'url', - nullable: false, optional: false, - }, - offsetX: { - type: 'number', - nullable: false, optional: true, - }, - offsetY: { - type: 'number', - nullable: false, optional: true, - }, - }, - }, - }, - isAdmin: { - type: 'boolean', - nullable: false, optional: true, - default: false, - }, - isModerator: { - type: 'boolean', - nullable: false, optional: true, - default: false, - }, - isSilenced: { - type: 'boolean', - nullable: false, optional: false, - }, - noindex: { - type: 'boolean', - nullable: false, optional: false, - }, - isBot: { - type: 'boolean', - nullable: false, optional: true, - }, - isCat: { - type: 'boolean', - nullable: false, optional: true, - }, - speakAsCat: { - type: 'boolean', - nullable: false, optional: true, - }, - instance: { - type: 'object', - nullable: false, optional: true, - properties: { - name: { - type: 'string', - nullable: true, optional: false, - }, - softwareName: { - type: 'string', - nullable: true, optional: false, - }, - softwareVersion: { - type: 'string', - nullable: true, optional: false, - }, - iconUrl: { - type: 'string', - nullable: true, optional: false, - }, - faviconUrl: { - type: 'string', - nullable: true, optional: false, - }, - themeColor: { - type: 'string', - nullable: true, optional: false, - }, - }, - }, - emojis: { - type: 'object', - nullable: false, optional: false, - }, - onlineStatus: { - type: 'string', - nullable: false, optional: false, - enum: ['unknown', 'online', 'active', 'offline'], - }, - badgeRoles: { - type: 'array', - nullable: false, optional: true, - items: { - type: 'object', - nullable: false, optional: false, - properties: { - name: { - type: 'string', - nullable: false, optional: false, - }, - iconUrl: { - type: 'string', - nullable: true, optional: false, - }, - displayOrder: { - type: 'number', - nullable: false, optional: false, - }, - }, - }, - }, - }, -} as const; - -export const packedUserDetailedNotMeOnlySchema = { - type: 'object', - properties: { - url: { - type: 'string', - format: 'url', - nullable: true, optional: false, - }, - uri: { - type: 'string', - format: 'uri', - nullable: true, optional: false, - }, - movedTo: { - type: 'string', - format: 'uri', - nullable: true, optional: false, - }, - alsoKnownAs: { - type: 'array', - nullable: true, optional: false, - items: { - type: 'string', - format: 'id', - nullable: false, optional: false, - }, - }, - createdAt: { - type: 'string', - nullable: false, optional: false, - format: 'date-time', - }, - updatedAt: { - type: 'string', - nullable: true, optional: false, - format: 'date-time', - }, - lastFetchedAt: { - type: 'string', - nullable: true, optional: false, - format: 'date-time', - }, - bannerUrl: { - type: 'string', - format: 'url', - nullable: true, optional: false, - }, - bannerBlurhash: { - type: 'string', - nullable: true, optional: false, - }, - backgroundUrl: { - type: 'string', - format: 'url', - nullable: true, optional: false, - }, - backgroundBlurhash: { - type: 'string', - nullable: true, optional: false, - }, - isLocked: { - type: 'boolean', - nullable: false, optional: false, - }, - isSuspended: { - type: 'boolean', - nullable: false, optional: false, - example: false, - }, - description: { - type: 'string', - nullable: true, optional: false, - example: 'Hi masters, I am Ai!', - }, - location: { - type: 'string', - nullable: true, optional: false, - }, - birthday: { - type: 'string', - nullable: true, optional: false, - example: '2018-03-12', - }, - ListenBrainz: { - type: 'string', - nullable: true, - optional: false, - example: 'Steve', - }, - lang: { - type: 'string', - nullable: true, optional: false, - example: 'ja-JP', - }, - fields: { - type: 'array', - nullable: false, optional: false, - maxItems: 16, - items: { - type: 'object', - nullable: false, optional: false, - properties: { - name: { - type: 'string', - nullable: false, optional: false, - }, - value: { - type: 'string', - nullable: false, optional: false, - }, - }, - }, - }, - verifiedLinks: { - type: 'array', - nullable: false, optional: false, - items: { - type: 'string', - nullable: false, optional: false, - format: 'url', - }, - }, - followersCount: { - type: 'number', - nullable: false, optional: false, - }, - followingCount: { - type: 'number', - nullable: false, optional: false, - }, - notesCount: { - type: 'number', - nullable: false, optional: false, - }, - pinnedNoteIds: { - type: 'array', - nullable: false, optional: false, - items: { - type: 'string', - nullable: false, optional: false, - format: 'id', - }, - }, - pinnedNotes: { - type: 'array', - nullable: false, optional: false, - items: { - type: 'object', - nullable: false, optional: false, - ref: 'Note', - }, - }, - pinnedPageId: { - type: 'string', - nullable: true, optional: false, - }, - pinnedPage: { - type: 'object', - nullable: true, optional: false, - ref: 'Page', - }, - publicReactions: { - type: 'boolean', - nullable: false, optional: false, - }, - followingVisibility: { - type: 'string', - nullable: false, optional: false, - enum: ['public', 'followers', 'private'], - }, - followersVisibility: { - type: 'string', - nullable: false, optional: false, - enum: ['public', 'followers', 'private'], - }, - twoFactorEnabled: { - type: 'boolean', - nullable: false, optional: false, - default: false, - }, - usePasswordLessLogin: { - type: 'boolean', - nullable: false, optional: false, - default: false, - }, - securityKeys: { - type: 'boolean', - nullable: false, optional: false, - default: false, - }, - roles: { - type: 'array', - nullable: false, optional: false, - items: { - type: 'object', - nullable: false, optional: false, - ref: 'RoleLite', - }, - }, - memo: { - type: 'string', - nullable: true, optional: false, - }, - moderationNote: { - type: 'string', - nullable: false, optional: true, - }, - //#region relations - isFollowing: { - type: 'boolean', - nullable: false, optional: true, - }, - isFollowed: { - type: 'boolean', - nullable: false, optional: true, - }, - hasPendingFollowRequestFromYou: { - type: 'boolean', - nullable: false, optional: true, - }, - hasPendingFollowRequestToYou: { - type: 'boolean', - nullable: false, optional: true, - }, - isBlocking: { - type: 'boolean', - nullable: false, optional: true, - }, - isBlocked: { - type: 'boolean', - nullable: false, optional: true, - }, - isMuted: { - type: 'boolean', - nullable: false, optional: true, - }, - isRenoteMuted: { - type: 'boolean', - nullable: false, optional: true, - }, - notify: { - type: 'string', - nullable: false, optional: true, - enum: ['normal', 'none'], - }, - withReplies: { - type: 'boolean', - nullable: false, optional: true, - }, - //#endregion - }, -} as const; - -export const packedMeDetailedOnlySchema = { - type: 'object', - properties: { - avatarId: { - type: 'string', - nullable: true, optional: false, - format: 'id', - }, - bannerId: { - type: 'string', - nullable: true, optional: false, - format: 'id', - }, - backgroundId: { - type: 'string', - nullable: true, optional: false, - format: 'id', - }, - isModerator: { - type: 'boolean', - nullable: true, optional: false, - }, - isAdmin: { - type: 'boolean', - nullable: true, optional: false, - }, - injectFeaturedNote: { - type: 'boolean', - nullable: false, optional: false, - }, - receiveAnnouncementEmail: { - type: 'boolean', - nullable: false, optional: false, - }, - alwaysMarkNsfw: { - type: 'boolean', - nullable: false, optional: false, - }, - autoSensitive: { - type: 'boolean', - nullable: false, optional: false, - }, - carefulBot: { - type: 'boolean', - nullable: false, optional: false, - }, - autoAcceptFollowed: { - type: 'boolean', - nullable: false, optional: false, - }, - noCrawle: { - type: 'boolean', - nullable: false, optional: false, - }, - preventAiLearning: { - type: 'boolean', - nullable: false, optional: false, - }, - isExplorable: { - type: 'boolean', - nullable: false, optional: false, - }, - isDeleted: { - type: 'boolean', - nullable: false, optional: false, - }, - twoFactorBackupCodesStock: { - type: 'string', - enum: ['full', 'partial', 'none'], - nullable: false, optional: false, - }, - hideOnlineStatus: { - type: 'boolean', - nullable: false, optional: false, - }, - hasUnreadSpecifiedNotes: { - type: 'boolean', - nullable: false, optional: false, - }, - hasUnreadMentions: { - type: 'boolean', - nullable: false, optional: false, - }, - hasUnreadAnnouncement: { - type: 'boolean', - nullable: false, optional: false, - }, - unreadAnnouncements: { - type: 'array', - nullable: false, optional: false, - items: { - type: 'object', - nullable: false, optional: false, - ref: 'Announcement', - }, - }, - hasUnreadAntenna: { - type: 'boolean', - nullable: false, optional: false, - }, - hasUnreadChannel: { - type: 'boolean', - nullable: false, optional: false, - }, - hasUnreadNotification: { - type: 'boolean', - nullable: false, optional: false, - }, - hasPendingReceivedFollowRequest: { - type: 'boolean', - nullable: false, optional: false, - }, - unreadNotificationsCount: { - type: 'number', - nullable: false, optional: false, - }, - mutedWords: { - type: 'array', - nullable: false, optional: false, - items: { - type: 'array', - nullable: false, optional: false, - items: { - type: 'string', - nullable: false, optional: false, - }, - }, - }, - hardMutedWords: { - type: 'array', - nullable: false, optional: false, - items: { - type: 'array', - nullable: false, optional: false, - items: { - type: 'string', - nullable: false, optional: false, - }, - }, - }, - mutedInstances: { - type: 'array', - nullable: true, optional: false, - items: { - type: 'string', - nullable: false, optional: false, - }, - }, - notificationRecieveConfig: { - type: 'object', - nullable: false, optional: false, - properties: { - app: notificationRecieveConfig, - quote: notificationRecieveConfig, - reply: notificationRecieveConfig, - follow: notificationRecieveConfig, - renote: notificationRecieveConfig, - mention: notificationRecieveConfig, - reaction: notificationRecieveConfig, - pollEnded: notificationRecieveConfig, - receiveFollowRequest: notificationRecieveConfig, - }, - }, - emailNotificationTypes: { - type: 'array', - nullable: false, optional: false, - items: { - type: 'string', - nullable: false, optional: false, - }, - }, - achievements: { - type: 'array', - nullable: false, optional: false, - items: { - type: 'object', - nullable: false, optional: false, - properties: { - name: { - type: 'string', - nullable: false, optional: false, - }, - unlockedAt: { - type: 'number', - nullable: false, optional: false, - }, - }, - }, - }, - loggedInDays: { - type: 'number', - nullable: false, optional: false, - }, - policies: { - type: 'object', - nullable: false, optional: false, - properties: { - gtlAvailable: { - type: 'boolean', - nullable: false, optional: false, - }, - ltlAvailable: { - type: 'boolean', - nullable: false, optional: false, - }, - canPublicNote: { - type: 'boolean', - nullable: false, optional: false, - }, - canInvite: { - type: 'boolean', - nullable: false, optional: false, - }, - inviteLimit: { - type: 'number', - nullable: false, optional: false, - }, - inviteLimitCycle: { - type: 'number', - nullable: false, optional: false, - }, - inviteExpirationTime: { - type: 'number', - nullable: false, optional: false, - }, - canManageCustomEmojis: { - type: 'boolean', - nullable: false, optional: false, - }, - canManageAvatarDecorations: { - type: 'boolean', - nullable: false, optional: false, - }, - canSearchNotes: { - type: 'boolean', - nullable: false, optional: false, - }, - canUseTranslator: { - type: 'boolean', - nullable: false, optional: false, - }, - canHideAds: { - type: 'boolean', - nullable: false, optional: false, - }, - driveCapacityMb: { - type: 'number', - nullable: false, optional: false, - }, - alwaysMarkNsfw: { - type: 'boolean', - nullable: false, optional: false, - }, - pinLimit: { - type: 'number', - nullable: false, optional: false, - }, - antennaLimit: { - type: 'number', - nullable: false, optional: false, - }, - wordMuteLimit: { - type: 'number', - nullable: false, optional: false, - }, - webhookLimit: { - type: 'number', - nullable: false, optional: false, - }, - clipLimit: { - type: 'number', - nullable: false, optional: false, - }, - noteEachClipsLimit: { - type: 'number', - nullable: false, optional: false, - }, - userListLimit: { - type: 'number', - nullable: false, optional: false, - }, - userEachUserListsLimit: { - type: 'number', - nullable: false, optional: false, - }, - rateLimitFactor: { - type: 'number', - nullable: false, optional: false, - }, - avatarDecorationLimit: { - type: 'number', - nullable: false, optional: false, - }, - }, - }, - //#region secrets - email: { - type: 'string', - nullable: true, optional: true, - }, - emailVerified: { - type: 'boolean', - nullable: true, optional: true, - }, - securityKeysList: { - type: 'array', - nullable: false, optional: true, - items: { - type: 'object', - nullable: false, optional: false, - properties: { - id: { - type: 'string', - nullable: false, optional: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - name: { - type: 'string', - nullable: false, optional: false, - }, - lastUsed: { - type: 'string', - nullable: false, optional: false, - format: 'date-time', - }, - }, - }, - }, - //#endregion - }, -} as const; - -export const packedUserDetailedNotMeSchema = { - type: 'object', - allOf: [ - { - type: 'object', - ref: 'UserLite', - }, - { - type: 'object', - ref: 'UserDetailedNotMeOnly', - }, - ], -} as const; - -export const packedMeDetailedSchema = { - type: 'object', - allOf: [ - { - type: 'object', - ref: 'UserLite', - }, - { - type: 'object', - ref: 'UserDetailedNotMeOnly', - }, - { - type: 'object', - ref: 'MeDetailedOnly', - }, - ], -} as const; - -export const packedUserDetailedSchema = { - oneOf: [ - { - type: 'object', - ref: 'UserDetailedNotMe', - }, - { - type: 'object', - ref: 'MeDetailed', - }, - ], -} as const; - -export const packedUserSchema = { - oneOf: [ - { - type: 'object', - ref: 'UserLite', - }, - { - type: 'object', - ref: 'UserDetailed', - }, - { - type: 'object', - ref: 'UserDetailedNotMe', - }, - { - type: 'object', - ref: 'MeDetailed', - }, - ], -} as const; diff --git a/packages/backend/src/models/repositories/abuse-user-report.ts b/packages/backend/src/models/repositories/abuse-user-report.ts new file mode 100644 index 0000000000..36d7ab90c5 --- /dev/null +++ b/packages/backend/src/models/repositories/abuse-user-report.ts @@ -0,0 +1,38 @@ +import { db } from '@/db/postgre.js'; +import { Users } from '../index.js'; +import { AbuseUserReport } from '@/models/entities/abuse-user-report.js'; +import { awaitAll } from '@/prelude/await-all.js'; + +export const AbuseUserReportRepository = db.getRepository(AbuseUserReport).extend({ + async pack( + src: AbuseUserReport['id'] | AbuseUserReport, + ) { + const report = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: report.id, + createdAt: report.createdAt.toISOString(), + comment: report.comment, + resolved: report.resolved, + reporterId: report.reporterId, + targetUserId: report.targetUserId, + assigneeId: report.assigneeId, + reporter: Users.pack(report.reporter || report.reporterId, null, { + detail: true, + }), + targetUser: Users.pack(report.targetUser || report.targetUserId, null, { + detail: true, + }), + assignee: report.assigneeId ? Users.pack(report.assignee || report.assigneeId, null, { + detail: true, + }) : null, + forwarded: report.forwarded, + }); + }, + + packMany( + reports: any[], + ) { + return Promise.all(reports.map(x => this.pack(x))); + }, +}); diff --git a/packages/backend/src/models/repositories/antenna.ts b/packages/backend/src/models/repositories/antenna.ts new file mode 100644 index 0000000000..70180e2dec --- /dev/null +++ b/packages/backend/src/models/repositories/antenna.ts @@ -0,0 +1,32 @@ +import { db } from '@/db/postgre.js'; +import { Antenna } from '@/models/entities/antenna.js'; +import { Packed } from '@/misc/schema.js'; +import { AntennaNotes, UserGroupJoinings } from '../index.js'; + +export const AntennaRepository = db.getRepository(Antenna).extend({ + async pack( + src: Antenna['id'] | Antenna, + ): Promise> { + const antenna = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + const hasUnreadNote = (await AntennaNotes.findOneBy({ antennaId: antenna.id, read: false })) != null; + const userGroupJoining = antenna.userGroupJoiningId ? await UserGroupJoinings.findOneBy({ id: antenna.userGroupJoiningId }) : null; + + return { + id: antenna.id, + createdAt: antenna.createdAt.toISOString(), + name: antenna.name, + keywords: antenna.keywords, + excludeKeywords: antenna.excludeKeywords, + src: antenna.src, + userListId: antenna.userListId, + userGroupId: userGroupJoining ? userGroupJoining.userGroupId : null, + users: antenna.users, + caseSensitive: antenna.caseSensitive, + notify: antenna.notify, + withReplies: antenna.withReplies, + withFile: antenna.withFile, + hasUnreadNote, + }; + }, +}); diff --git a/packages/backend/src/models/repositories/app.ts b/packages/backend/src/models/repositories/app.ts new file mode 100644 index 0000000000..e08dd6f0e3 --- /dev/null +++ b/packages/backend/src/models/repositories/app.ts @@ -0,0 +1,39 @@ +import { db } from '@/db/postgre.js'; +import { App } from '@/models/entities/app.js'; +import { AccessTokens } from '../index.js'; +import { Packed } from '@/misc/schema.js'; +import { User } from '../entities/user.js'; + +export const AppRepository = db.getRepository(App).extend({ + async pack( + src: App['id'] | App, + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: boolean, + includeSecret?: boolean, + includeProfileImageIds?: boolean + } + ): Promise> { + const opts = Object.assign({ + detail: false, + includeSecret: false, + includeProfileImageIds: false, + }, options); + + const app = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return { + id: app.id, + name: app.name, + callbackUrl: app.callbackUrl, + permission: app.permission, + ...(opts.includeSecret ? { secret: app.secret } : {}), + ...(me ? { + isAuthorized: await AccessTokens.countBy({ + appId: app.id, + userId: me.id, + }).then(count => count > 0), + } : {}), + }; + }, +}); diff --git a/packages/backend/src/models/repositories/auth-session.ts b/packages/backend/src/models/repositories/auth-session.ts new file mode 100644 index 0000000000..3f1f6f4897 --- /dev/null +++ b/packages/backend/src/models/repositories/auth-session.ts @@ -0,0 +1,20 @@ +import { db } from '@/db/postgre.js'; +import { Apps } from '../index.js'; +import { AuthSession } from '@/models/entities/auth-session.js'; +import { awaitAll } from '@/prelude/await-all.js'; +import { User } from '@/models/entities/user.js'; + +export const AuthSessionRepository = db.getRepository(AuthSession).extend({ + async pack( + src: AuthSession['id'] | AuthSession, + me?: { id: User['id'] } | null | undefined + ) { + const session = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: session.id, + app: Apps.pack(session.appId, me), + token: session.token, + }); + }, +}); diff --git a/packages/backend/src/models/repositories/blocking.ts b/packages/backend/src/models/repositories/blocking.ts new file mode 100644 index 0000000000..1d569fb875 --- /dev/null +++ b/packages/backend/src/models/repositories/blocking.ts @@ -0,0 +1,31 @@ +import { db } from '@/db/postgre.js'; +import { Users } from '../index.js'; +import { Blocking } from '@/models/entities/blocking.js'; +import { awaitAll } from '@/prelude/await-all.js'; +import { Packed } from '@/misc/schema.js'; +import { User } from '@/models/entities/user.js'; + +export const BlockingRepository = db.getRepository(Blocking).extend({ + async pack( + src: Blocking['id'] | Blocking, + me?: { id: User['id'] } | null | undefined + ): Promise> { + const blocking = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: blocking.id, + createdAt: blocking.createdAt.toISOString(), + blockeeId: blocking.blockeeId, + blockee: Users.pack(blocking.blockeeId, me, { + detail: true, + }), + }); + }, + + packMany( + blockings: any[], + me: { id: User['id'] } + ) { + return Promise.all(blockings.map(x => this.pack(x, me))); + }, +}); diff --git a/packages/backend/src/models/repositories/channel.ts b/packages/backend/src/models/repositories/channel.ts new file mode 100644 index 0000000000..213ac3671a --- /dev/null +++ b/packages/backend/src/models/repositories/channel.ts @@ -0,0 +1,41 @@ +import { db } from '@/db/postgre.js'; +import { Channel } from '@/models/entities/channel.js'; +import { Packed } from '@/misc/schema.js'; +import { DriveFiles, ChannelFollowings, NoteUnreads } from '../index.js'; +import { User } from '@/models/entities/user.js'; + +export const ChannelRepository = db.getRepository(Channel).extend({ + async pack( + src: Channel['id'] | Channel, + me?: { id: User['id'] } | null | undefined, + ): Promise> { + const channel = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + const meId = me ? me.id : null; + + const banner = channel.bannerId ? await DriveFiles.findOneBy({ id: channel.bannerId }) : null; + + const hasUnreadNote = meId ? (await NoteUnreads.findOneBy({ noteChannelId: channel.id, userId: meId })) != null : undefined; + + const following = meId ? await ChannelFollowings.findOneBy({ + followerId: meId, + followeeId: channel.id, + }) : null; + + return { + id: channel.id, + createdAt: channel.createdAt.toISOString(), + lastNotedAt: channel.lastNotedAt ? channel.lastNotedAt.toISOString() : null, + name: channel.name, + description: channel.description, + userId: channel.userId, + bannerUrl: banner ? DriveFiles.getPublicUrl(banner, false) : null, + usersCount: channel.usersCount, + notesCount: channel.notesCount, + + ...(me ? { + isFollowing: following != null, + hasUnreadNote, + } : {}), + }; + }, +}); diff --git a/packages/backend/src/models/repositories/clip.ts b/packages/backend/src/models/repositories/clip.ts new file mode 100644 index 0000000000..b4a342905e --- /dev/null +++ b/packages/backend/src/models/repositories/clip.ts @@ -0,0 +1,30 @@ +import { db } from '@/db/postgre.js'; +import { Clip } from '@/models/entities/clip.js'; +import { Packed } from '@/misc/schema.js'; +import { Users } from '../index.js'; +import { awaitAll } from '@/prelude/await-all.js'; + +export const ClipRepository = db.getRepository(Clip).extend({ + async pack( + src: Clip['id'] | Clip, + ): Promise> { + const clip = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: clip.id, + createdAt: clip.createdAt.toISOString(), + userId: clip.userId, + user: Users.pack(clip.user || clip.userId), + name: clip.name, + description: clip.description, + isPublic: clip.isPublic, + }); + }, + + packMany( + clips: Clip[], + ) { + return Promise.all(clips.map(x => this.pack(x))); + }, +}); + diff --git a/packages/backend/src/models/repositories/drive-file.ts b/packages/backend/src/models/repositories/drive-file.ts new file mode 100644 index 0000000000..0d589d4f11 --- /dev/null +++ b/packages/backend/src/models/repositories/drive-file.ts @@ -0,0 +1,188 @@ +import { db } from '@/db/postgre.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { User } from '@/models/entities/user.js'; +import { toPuny } from '@/misc/convert-host.js'; +import { awaitAll, Promiseable } from '@/prelude/await-all.js'; +import { Packed } from '@/misc/schema.js'; +import config from '@/config/index.js'; +import { query, appendQuery } from '@/prelude/url.js'; +import { Meta } from '@/models/entities/meta.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Users, DriveFolders } from '../index.js'; + +type PackOptions = { + detail?: boolean, + self?: boolean, + withUser?: boolean, +}; + +export const DriveFileRepository = db.getRepository(DriveFile).extend({ + validateFileName(name: string): boolean { + return ( + (name.trim().length > 0) && + (name.length <= 200) && + (name.indexOf('\\') === -1) && + (name.indexOf('/') === -1) && + (name.indexOf('..') === -1) + ); + }, + + getPublicProperties(file: DriveFile): DriveFile['properties'] { + if (file.properties.orientation != null) { + // TODO + //const properties = structuredClone(file.properties); + const properties = JSON.parse(JSON.stringify(file.properties)); + if (file.properties.orientation >= 5) { + [properties.width, properties.height] = [properties.height, properties.width]; + } + properties.orientation = undefined; + return properties; + } + + return file.properties; + }, + + getPublicUrl(file: DriveFile, thumbnail = false): string | null { + // リモートかつメディアプロキシ + if (file.uri != null && file.userHost != null && config.mediaProxy != null) { + return appendQuery(config.mediaProxy, query({ + url: file.uri, + thumbnail: thumbnail ? '1' : undefined, + })); + } + + // リモートかつ期限切れはローカルプロキシを試みる + if (file.uri != null && file.isLink && config.proxyRemoteFiles) { + const key = thumbnail ? file.thumbnailAccessKey : file.webpublicAccessKey; + + if (key && !key.match('/')) { // 古いものはここにオブジェクトストレージキーが入ってるので除外 + return `${config.url}/files/${key}`; + } + } + + const isImage = file.type && ['image/png', 'image/apng', 'image/gif', 'image/jpeg', 'image/webp', 'image/svg+xml'].includes(file.type); + + return thumbnail ? (file.thumbnailUrl || (isImage ? (file.webpublicUrl || file.url) : null)) : (file.webpublicUrl || file.url); + }, + + async calcDriveUsageOf(user: User['id'] | { id: User['id'] }): Promise { + const id = typeof user === 'object' ? user.id : user; + + const { sum } = await this + .createQueryBuilder('file') + .where('file.userId = :id', { id: id }) + .andWhere('file.isLink = FALSE') + .select('SUM(file.size)', 'sum') + .getRawOne(); + + return parseInt(sum, 10) || 0; + }, + + async calcDriveUsageOfHost(host: string): Promise { + const { sum } = await this + .createQueryBuilder('file') + .where('file.userHost = :host', { host: toPuny(host) }) + .andWhere('file.isLink = FALSE') + .select('SUM(file.size)', 'sum') + .getRawOne(); + + return parseInt(sum, 10) || 0; + }, + + async calcDriveUsageOfLocal(): Promise { + const { sum } = await this + .createQueryBuilder('file') + .where('file.userHost IS NULL') + .andWhere('file.isLink = FALSE') + .select('SUM(file.size)', 'sum') + .getRawOne(); + + return parseInt(sum, 10) || 0; + }, + + async calcDriveUsageOfRemote(): Promise { + const { sum } = await this + .createQueryBuilder('file') + .where('file.userHost IS NOT NULL') + .andWhere('file.isLink = FALSE') + .select('SUM(file.size)', 'sum') + .getRawOne(); + + return parseInt(sum, 10) || 0; + }, + + async pack( + src: DriveFile['id'] | DriveFile, + options?: PackOptions, + ): Promise> { + const opts = Object.assign({ + detail: false, + self: false, + }, options); + + const file = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll>({ + id: file.id, + createdAt: file.createdAt.toISOString(), + name: file.name, + type: file.type, + md5: file.md5, + size: file.size, + isSensitive: file.isSensitive, + blurhash: file.blurhash, + properties: opts.self ? file.properties : this.getPublicProperties(file), + url: opts.self ? file.url : this.getPublicUrl(file, false), + thumbnailUrl: this.getPublicUrl(file, true), + comment: file.comment, + folderId: file.folderId, + folder: opts.detail && file.folderId ? DriveFolders.pack(file.folderId, { + detail: true, + }) : null, + userId: opts.withUser ? file.userId : null, + user: (opts.withUser && file.userId) ? Users.pack(file.userId) : null, + }); + }, + + async packNullable( + src: DriveFile['id'] | DriveFile, + options?: PackOptions, + ): Promise | null> { + const opts = Object.assign({ + detail: false, + self: false, + }, options); + + const file = typeof src === 'object' ? src : await this.findOneBy({ id: src }); + if (file == null) return null; + + return await awaitAll>({ + id: file.id, + createdAt: file.createdAt.toISOString(), + name: file.name, + type: file.type, + md5: file.md5, + size: file.size, + isSensitive: file.isSensitive, + blurhash: file.blurhash, + properties: opts.self ? file.properties : this.getPublicProperties(file), + url: opts.self ? file.url : this.getPublicUrl(file, false), + thumbnailUrl: this.getPublicUrl(file, true), + comment: file.comment, + folderId: file.folderId, + folder: opts.detail && file.folderId ? DriveFolders.pack(file.folderId, { + detail: true, + }) : null, + userId: opts.withUser ? file.userId : null, + user: (opts.withUser && file.userId) ? Users.pack(file.userId) : null, + }); + }, + + async packMany( + files: (DriveFile['id'] | DriveFile)[], + options?: PackOptions, + ): Promise[]> { + const items = await Promise.all(files.map(f => this.packNullable(f, options))); + return items.filter((x): x is Packed<'DriveFile'> => x != null); + }, +}); diff --git a/packages/backend/src/models/repositories/drive-folder.ts b/packages/backend/src/models/repositories/drive-folder.ts new file mode 100644 index 0000000000..ab5f3dab63 --- /dev/null +++ b/packages/backend/src/models/repositories/drive-folder.ts @@ -0,0 +1,42 @@ +import { db } from '@/db/postgre.js'; +import { DriveFolders, DriveFiles } from '../index.js'; +import { DriveFolder } from '@/models/entities/drive-folder.js'; +import { awaitAll } from '@/prelude/await-all.js'; +import { Packed } from '@/misc/schema.js'; + +export const DriveFolderRepository = db.getRepository(DriveFolder).extend({ + async pack( + src: DriveFolder['id'] | DriveFolder, + options?: { + detail: boolean + } + ): Promise> { + const opts = Object.assign({ + detail: false, + }, options); + + const folder = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: folder.id, + createdAt: folder.createdAt.toISOString(), + name: folder.name, + parentId: folder.parentId, + + ...(opts.detail ? { + foldersCount: DriveFolders.countBy({ + parentId: folder.id, + }), + filesCount: DriveFiles.countBy({ + folderId: folder.id, + }), + + ...(folder.parentId ? { + parent: this.pack(folder.parentId, { + detail: true, + }), + } : {}), + } : {}), + }); + }, +}); diff --git a/packages/backend/src/models/repositories/emoji.ts b/packages/backend/src/models/repositories/emoji.ts new file mode 100644 index 0000000000..a0d390d793 --- /dev/null +++ b/packages/backend/src/models/repositories/emoji.ts @@ -0,0 +1,27 @@ +import { db } from '@/db/postgre.js'; +import { Emoji } from '@/models/entities/emoji.js'; +import { Packed } from '@/misc/schema.js'; + +export const EmojiRepository = db.getRepository(Emoji).extend({ + async pack( + src: Emoji['id'] | Emoji, + ): Promise> { + const emoji = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return { + id: emoji.id, + aliases: emoji.aliases, + name: emoji.name, + category: emoji.category, + host: emoji.host, + // || emoji.originalUrl してるのは後方互換性のため + url: emoji.publicUrl || emoji.originalUrl, + }; + }, + + packMany( + emojis: any[], + ) { + return Promise.all(emojis.map(x => this.pack(x))); + }, +}); diff --git a/packages/backend/src/models/repositories/follow-request.ts b/packages/backend/src/models/repositories/follow-request.ts new file mode 100644 index 0000000000..c4a7203aa1 --- /dev/null +++ b/packages/backend/src/models/repositories/follow-request.ts @@ -0,0 +1,19 @@ +import { db } from '@/db/postgre.js'; +import { FollowRequest } from '@/models/entities/follow-request.js'; +import { Users } from '../index.js'; +import { User } from '@/models/entities/user.js'; + +export const FollowRequestRepository = db.getRepository(FollowRequest).extend({ + async pack( + src: FollowRequest['id'] | FollowRequest, + me?: { id: User['id'] } | null | undefined + ) { + const request = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return { + id: request.id, + follower: await Users.pack(request.followerId, me), + followee: await Users.pack(request.followeeId, me), + }; + }, +}); diff --git a/packages/backend/src/models/repositories/following.ts b/packages/backend/src/models/repositories/following.ts new file mode 100644 index 0000000000..46109244fa --- /dev/null +++ b/packages/backend/src/models/repositories/following.ts @@ -0,0 +1,85 @@ +import { db } from '@/db/postgre.js'; +import { Users } from '../index.js'; +import { Following } from '@/models/entities/following.js'; +import { awaitAll } from '@/prelude/await-all.js'; +import { Packed } from '@/misc/schema.js'; +import { User } from '@/models/entities/user.js'; + +type LocalFollowerFollowing = Following & { + followerHost: null; + followerInbox: null; + followerSharedInbox: null; +}; + +type RemoteFollowerFollowing = Following & { + followerHost: string; + followerInbox: string; + followerSharedInbox: string; +}; + +type LocalFolloweeFollowing = Following & { + followeeHost: null; + followeeInbox: null; + followeeSharedInbox: null; +}; + +type RemoteFolloweeFollowing = Following & { + followeeHost: string; + followeeInbox: string; + followeeSharedInbox: string; +}; + +export const FollowingRepository = db.getRepository(Following).extend({ + isLocalFollower(following: Following): following is LocalFollowerFollowing { + return following.followerHost == null; + }, + + isRemoteFollower(following: Following): following is RemoteFollowerFollowing { + return following.followerHost != null; + }, + + isLocalFollowee(following: Following): following is LocalFolloweeFollowing { + return following.followeeHost == null; + }, + + isRemoteFollowee(following: Following): following is RemoteFolloweeFollowing { + return following.followeeHost != null; + }, + + async pack( + src: Following['id'] | Following, + me?: { id: User['id'] } | null | undefined, + opts?: { + populateFollowee?: boolean; + populateFollower?: boolean; + } + ): Promise> { + const following = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + if (opts == null) opts = {}; + + return await awaitAll({ + id: following.id, + createdAt: following.createdAt.toISOString(), + followeeId: following.followeeId, + followerId: following.followerId, + followee: opts.populateFollowee ? Users.pack(following.followee || following.followeeId, me, { + detail: true, + }) : undefined, + follower: opts.populateFollower ? Users.pack(following.follower || following.followerId, me, { + detail: true, + }) : undefined, + }); + }, + + packMany( + followings: any[], + me?: { id: User['id'] } | null | undefined, + opts?: { + populateFollowee?: boolean; + populateFollower?: boolean; + } + ) { + return Promise.all(followings.map(x => this.pack(x, me, opts))); + }, +}); diff --git a/packages/backend/src/models/repositories/gallery-like.ts b/packages/backend/src/models/repositories/gallery-like.ts new file mode 100644 index 0000000000..08ca4962b8 --- /dev/null +++ b/packages/backend/src/models/repositories/gallery-like.ts @@ -0,0 +1,24 @@ +import { db } from '@/db/postgre.js'; +import { GalleryLike } from '@/models/entities/gallery-like.js'; +import { GalleryPosts } from '../index.js'; + +export const GalleryLikeRepository = db.getRepository(GalleryLike).extend({ + async pack( + src: GalleryLike['id'] | GalleryLike, + me?: any + ) { + const like = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return { + id: like.id, + post: await GalleryPosts.pack(like.post || like.postId, me), + }; + }, + + packMany( + likes: any[], + me: any + ) { + return Promise.all(likes.map(x => this.pack(x, me))); + }, +}); diff --git a/packages/backend/src/models/repositories/gallery-post.ts b/packages/backend/src/models/repositories/gallery-post.ts new file mode 100644 index 0000000000..bb8d40b75e --- /dev/null +++ b/packages/backend/src/models/repositories/gallery-post.ts @@ -0,0 +1,39 @@ +import { db } from '@/db/postgre.js'; +import { GalleryPost } from '@/models/entities/gallery-post.js'; +import { Packed } from '@/misc/schema.js'; +import { Users, DriveFiles, GalleryLikes } from '../index.js'; +import { awaitAll } from '@/prelude/await-all.js'; +import { User } from '@/models/entities/user.js'; + +export const GalleryPostRepository = db.getRepository(GalleryPost).extend({ + async pack( + src: GalleryPost['id'] | GalleryPost, + me?: { id: User['id'] } | null | undefined, + ): Promise> { + const meId = me ? me.id : null; + const post = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: post.id, + createdAt: post.createdAt.toISOString(), + updatedAt: post.updatedAt.toISOString(), + userId: post.userId, + user: Users.pack(post.user || post.userId, me), + title: post.title, + description: post.description, + fileIds: post.fileIds, + files: DriveFiles.packMany(post.fileIds), + tags: post.tags.length > 0 ? post.tags : undefined, + isSensitive: post.isSensitive, + likedCount: post.likedCount, + isLiked: meId ? await GalleryLikes.findOneBy({ postId: post.id, userId: meId }).then(x => x != null) : undefined, + }); + }, + + packMany( + posts: GalleryPost[], + me?: { id: User['id'] } | null | undefined, + ) { + return Promise.all(posts.map(x => this.pack(x, me))); + }, +}); diff --git a/packages/backend/src/models/repositories/hashtag.ts b/packages/backend/src/models/repositories/hashtag.ts new file mode 100644 index 0000000000..e6c0e36f00 --- /dev/null +++ b/packages/backend/src/models/repositories/hashtag.ts @@ -0,0 +1,25 @@ +import { db } from '@/db/postgre.js'; +import { Hashtag } from '@/models/entities/hashtag.js'; +import { Packed } from '@/misc/schema.js'; + +export const HashtagRepository = db.getRepository(Hashtag).extend({ + async pack( + src: Hashtag, + ): Promise> { + return { + tag: src.name, + mentionedUsersCount: src.mentionedUsersCount, + mentionedLocalUsersCount: src.mentionedLocalUsersCount, + mentionedRemoteUsersCount: src.mentionedRemoteUsersCount, + attachedUsersCount: src.attachedUsersCount, + attachedLocalUsersCount: src.attachedLocalUsersCount, + attachedRemoteUsersCount: src.attachedRemoteUsersCount, + }; + }, + + packMany( + hashtags: Hashtag[], + ) { + return Promise.all(hashtags.map(x => this.pack(x))); + }, +}); diff --git a/packages/backend/src/models/repositories/instance.ts b/packages/backend/src/models/repositories/instance.ts new file mode 100644 index 0000000000..5f0fd8d582 --- /dev/null +++ b/packages/backend/src/models/repositories/instance.ts @@ -0,0 +1,43 @@ +import { db } from '@/db/postgre.js'; +import { Instance } from '@/models/entities/instance.js'; +import { Packed } from '@/misc/schema.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; + +export const InstanceRepository = db.getRepository(Instance).extend({ + async pack( + instance: Instance, + ): Promise> { + const meta = await fetchMeta(); + return { + id: instance.id, + caughtAt: instance.caughtAt.toISOString(), + host: instance.host, + usersCount: instance.usersCount, + notesCount: instance.notesCount, + followingCount: instance.followingCount, + followersCount: instance.followersCount, + latestRequestSentAt: instance.latestRequestSentAt ? instance.latestRequestSentAt.toISOString() : null, + lastCommunicatedAt: instance.lastCommunicatedAt.toISOString(), + isNotResponding: instance.isNotResponding, + isSuspended: instance.isSuspended, + isBlocked: meta.blockedHosts.includes(instance.host), + softwareName: instance.softwareName, + softwareVersion: instance.softwareVersion, + openRegistrations: instance.openRegistrations, + name: instance.name, + description: instance.description, + maintainerName: instance.maintainerName, + maintainerEmail: instance.maintainerEmail, + iconUrl: instance.iconUrl, + faviconUrl: instance.faviconUrl, + themeColor: instance.themeColor, + infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null, + }; + }, + + packMany( + instances: Instance[], + ) { + return Promise.all(instances.map(x => this.pack(x))); + }, +}); diff --git a/packages/backend/src/models/repositories/messaging-message.ts b/packages/backend/src/models/repositories/messaging-message.ts new file mode 100644 index 0000000000..6c51c93ff7 --- /dev/null +++ b/packages/backend/src/models/repositories/messaging-message.ts @@ -0,0 +1,39 @@ +import { db } from '@/db/postgre.js'; +import { MessagingMessage } from '@/models/entities/messaging-message.js'; +import { Users, DriveFiles, UserGroups } from '../index.js'; +import { Packed } from '@/misc/schema.js'; +import { User } from '@/models/entities/user.js'; + +export const MessagingMessageRepository = db.getRepository(MessagingMessage).extend({ + async pack( + src: MessagingMessage['id'] | MessagingMessage, + me?: { id: User['id'] } | null | undefined, + options?: { + populateRecipient?: boolean, + populateGroup?: boolean, + } + ): Promise> { + const opts = options || { + populateRecipient: true, + populateGroup: true, + }; + + const message = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return { + id: message.id, + createdAt: message.createdAt.toISOString(), + text: message.text, + userId: message.userId, + user: await Users.pack(message.user || message.userId, me), + recipientId: message.recipientId, + recipient: message.recipientId && opts.populateRecipient ? await Users.pack(message.recipient || message.recipientId, me) : undefined, + groupId: message.groupId, + group: message.groupId && opts.populateGroup ? await UserGroups.pack(message.group || message.groupId) : undefined, + fileId: message.fileId, + file: message.fileId ? await DriveFiles.pack(message.fileId) : null, + isRead: message.isRead, + reads: message.reads, + }; + }, +}); diff --git a/packages/backend/src/models/repositories/moderation-logs.ts b/packages/backend/src/models/repositories/moderation-logs.ts new file mode 100644 index 0000000000..1488b1eabe --- /dev/null +++ b/packages/backend/src/models/repositories/moderation-logs.ts @@ -0,0 +1,29 @@ +import { db } from '@/db/postgre.js'; +import { Users } from '../index.js'; +import { ModerationLog } from '@/models/entities/moderation-log.js'; +import { awaitAll } from '@/prelude/await-all.js'; + +export const ModerationLogRepository = db.getRepository(ModerationLog).extend({ + async pack( + src: ModerationLog['id'] | ModerationLog, + ) { + const log = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: log.id, + createdAt: log.createdAt.toISOString(), + type: log.type, + info: log.info, + userId: log.userId, + user: Users.pack(log.user || log.userId, null, { + detail: true, + }), + }); + }, + + packMany( + reports: any[], + ) { + return Promise.all(reports.map(x => this.pack(x))); + }, +}); diff --git a/packages/backend/src/models/repositories/muting.ts b/packages/backend/src/models/repositories/muting.ts new file mode 100644 index 0000000000..7891b10fb0 --- /dev/null +++ b/packages/backend/src/models/repositories/muting.ts @@ -0,0 +1,32 @@ +import { db } from '@/db/postgre.js'; +import { Users } from '../index.js'; +import { Muting } from '@/models/entities/muting.js'; +import { awaitAll } from '@/prelude/await-all.js'; +import { Packed } from '@/misc/schema.js'; +import { User } from '@/models/entities/user.js'; + +export const MutingRepository = db.getRepository(Muting).extend({ + async pack( + src: Muting['id'] | Muting, + me?: { id: User['id'] } | null | undefined + ): Promise> { + const muting = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: muting.id, + createdAt: muting.createdAt.toISOString(), + expiresAt: muting.expiresAt ? muting.expiresAt.toISOString() : null, + muteeId: muting.muteeId, + mutee: Users.pack(muting.muteeId, me, { + detail: true, + }), + }); + }, + + packMany( + mutings: any[], + me: { id: User['id'] } + ) { + return Promise.all(mutings.map(x => this.pack(x, me))); + }, +}); diff --git a/packages/backend/src/models/repositories/note-favorite.ts b/packages/backend/src/models/repositories/note-favorite.ts new file mode 100644 index 0000000000..9bd97f9880 --- /dev/null +++ b/packages/backend/src/models/repositories/note-favorite.ts @@ -0,0 +1,27 @@ +import { db } from '@/db/postgre.js'; +import { NoteFavorite } from '@/models/entities/note-favorite.js'; +import { Notes } from '../index.js'; +import { User } from '@/models/entities/user.js'; + +export const NoteFavoriteRepository = db.getRepository(NoteFavorite).extend({ + async pack( + src: NoteFavorite['id'] | NoteFavorite, + me?: { id: User['id'] } | null | undefined + ) { + const favorite = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return { + id: favorite.id, + createdAt: favorite.createdAt.toISOString(), + noteId: favorite.noteId, + note: await Notes.pack(favorite.note || favorite.noteId, me), + }; + }, + + packMany( + favorites: any[], + me: { id: User['id'] } + ) { + return Promise.all(favorites.map(x => this.pack(x, me))); + }, +}); diff --git a/packages/backend/src/models/repositories/note-reaction.ts b/packages/backend/src/models/repositories/note-reaction.ts new file mode 100644 index 0000000000..4deae51c93 --- /dev/null +++ b/packages/backend/src/models/repositories/note-reaction.ts @@ -0,0 +1,32 @@ +import { db } from '@/db/postgre.js'; +import { NoteReaction } from '@/models/entities/note-reaction.js'; +import { Notes, Users } from '../index.js'; +import { Packed } from '@/misc/schema.js'; +import { convertLegacyReaction } from '@/misc/reaction-lib.js'; +import { User } from '@/models/entities/user.js'; + +export const NoteReactionRepository = db.getRepository(NoteReaction).extend({ + async pack( + src: NoteReaction['id'] | NoteReaction, + me?: { id: User['id'] } | null | undefined, + options?: { + withNote: boolean; + }, + ): Promise> { + const opts = Object.assign({ + withNote: false, + }, options); + + const reaction = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return { + id: reaction.id, + createdAt: reaction.createdAt.toISOString(), + user: await Users.pack(reaction.user ?? reaction.userId, me), + type: convertLegacyReaction(reaction.reaction), + ...(opts.withNote ? { + note: await Notes.pack(reaction.note ?? reaction.noteId, me), + } : {}), + }; + }, +}); diff --git a/packages/backend/src/models/repositories/note.ts b/packages/backend/src/models/repositories/note.ts new file mode 100644 index 0000000000..3fefab0319 --- /dev/null +++ b/packages/backend/src/models/repositories/note.ts @@ -0,0 +1,326 @@ +import { In } from 'typeorm'; +import * as mfm from 'mfm-js'; +import { Note } from '@/models/entities/note.js'; +import { User } from '@/models/entities/user.js'; +import { Users, PollVotes, DriveFiles, NoteReactions, Followings, Polls, Channels } from '../index.js'; +import { Packed } from '@/misc/schema.js'; +import { nyaize } from '@/misc/nyaize.js'; +import { awaitAll } from '@/prelude/await-all.js'; +import { convertLegacyReaction, convertLegacyReactions, decodeReaction } from '@/misc/reaction-lib.js'; +import { NoteReaction } from '@/models/entities/note-reaction.js'; +import { aggregateNoteEmojis, populateEmojis, prefetchEmojis } from '@/misc/populate-emojis.js'; +import { db } from '@/db/postgre.js'; + +async function hideNote(packedNote: Packed<'Note'>, meId: User['id'] | null) { + // TODO: isVisibleForMe を使うようにしても良さそう(型違うけど) + let hide = false; + + // visibility が specified かつ自分が指定されていなかったら非表示 + if (packedNote.visibility === 'specified') { + if (meId == null) { + hide = true; + } else if (meId === packedNote.userId) { + hide = false; + } else { + // 指定されているかどうか + const specified = packedNote.visibleUserIds!.some((id: any) => meId === id); + + if (specified) { + hide = false; + } else { + hide = true; + } + } + } + + // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 + if (packedNote.visibility === 'followers') { + if (meId == null) { + hide = true; + } else if (meId === packedNote.userId) { + hide = false; + } else if (packedNote.reply && (meId === packedNote.reply.userId)) { + // 自分の投稿に対するリプライ + hide = false; + } else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) { + // 自分へのメンション + hide = false; + } else { + // フォロワーかどうか + const following = await Followings.findOneBy({ + followeeId: packedNote.userId, + followerId: meId, + }); + + if (following == null) { + hide = true; + } else { + hide = false; + } + } + } + + if (hide) { + packedNote.visibleUserIds = undefined; + packedNote.fileIds = []; + packedNote.files = []; + packedNote.text = null; + packedNote.poll = undefined; + packedNote.cw = null; + packedNote.isHidden = true; + } +} + +async function populatePoll(note: Note, meId: User['id'] | null) { + const poll = await Polls.findOneByOrFail({ noteId: note.id }); + const choices = poll.choices.map(c => ({ + text: c, + votes: poll.votes[poll.choices.indexOf(c)], + isVoted: false, + })); + + if (meId) { + if (poll.multiple) { + const votes = await PollVotes.findBy({ + userId: meId, + noteId: note.id, + }); + + const myChoices = votes.map(v => v.choice); + for (const myChoice of myChoices) { + choices[myChoice].isVoted = true; + } + } else { + const vote = await PollVotes.findOneBy({ + userId: meId, + noteId: note.id, + }); + + if (vote) { + choices[vote.choice].isVoted = true; + } + } + } + + return { + multiple: poll.multiple, + expiresAt: poll.expiresAt, + choices, + }; +} + +async function populateMyReaction(note: Note, meId: User['id'], _hint_?: { + myReactions: Map; +}) { + if (_hint_?.myReactions) { + const reaction = _hint_.myReactions.get(note.id); + if (reaction) { + return convertLegacyReaction(reaction.reaction); + } else if (reaction === null) { + return undefined; + } + // 実装上抜けがあるだけかもしれないので、「ヒントに含まれてなかったら(=undefinedなら)return」のようにはしない + } + + const reaction = await NoteReactions.findOneBy({ + userId: meId, + noteId: note.id, + }); + + if (reaction) { + return convertLegacyReaction(reaction.reaction); + } + + return undefined; +} + +export const NoteRepository = db.getRepository(Note).extend({ + async isVisibleForMe(note: Note, meId: User['id'] | null): Promise { + // This code must always be synchronized with the checks in generateVisibilityQuery. + // visibility が specified かつ自分が指定されていなかったら非表示 + if (note.visibility === 'specified') { + if (meId == null) { + return false; + } else if (meId === note.userId) { + return true; + } else { + // 指定されているかどうか + return note.visibleUserIds.some((id: any) => meId === id); + } + } + + // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 + if (note.visibility === 'followers') { + if (meId == null) { + return false; + } else if (meId === note.userId) { + return true; + } else if (note.reply && (meId === note.reply.userId)) { + // 自分の投稿に対するリプライ + return true; + } else if (note.mentions && note.mentions.some(id => meId === id)) { + // 自分へのメンション + return true; + } else { + // フォロワーかどうか + const [following, user] = await Promise.all([ + Followings.count({ + where: { + followeeId: note.userId, + followerId: meId, + }, + take: 1, + }), + Users.findOneByOrFail({ id: meId }), + ]); + + /* If we know the following, everyhting is fine. + + But if we do not know the following, it might be that both the + author of the note and the author of the like are remote users, + in which case we can never know the following. Instead we have + to assume that the users are following each other. + */ + return following > 0 || (note.userHost != null && user.host != null); + } + } + + return true; + }, + + async pack( + src: Note['id'] | Note, + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: boolean; + skipHide?: boolean; + _hint_?: { + myReactions: Map; + }; + } + ): Promise> { + const opts = Object.assign({ + detail: true, + skipHide: false, + }, options); + + const meId = me ? me.id : null; + const note = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + const host = note.userHost; + + let text = note.text; + + if (note.name && (note.url ?? note.uri)) { + text = `【${note.name}】\n${(note.text || '').trim()}\n\n${note.url ?? note.uri}`; + } + + const channel = note.channelId + ? note.channel + ? note.channel + : await Channels.findOneBy({ id: note.channelId }) + : null; + + const reactionEmojiNames = Object.keys(note.reactions).filter(x => x?.startsWith(':')).map(x => decodeReaction(x).reaction).map(x => x.replace(/:/g, '')); + + const packed: Packed<'Note'> = await awaitAll({ + id: note.id, + createdAt: note.createdAt.toISOString(), + userId: note.userId, + user: Users.pack(note.user ?? note.userId, me, { + detail: false, + }), + text: text, + cw: note.cw, + visibility: note.visibility, + localOnly: note.localOnly || undefined, + visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, + renoteCount: note.renoteCount, + repliesCount: note.repliesCount, + reactions: convertLegacyReactions(note.reactions), + tags: note.tags.length > 0 ? note.tags : undefined, + emojis: populateEmojis(note.emojis.concat(reactionEmojiNames), host), + fileIds: note.fileIds, + files: DriveFiles.packMany(note.fileIds), + replyId: note.replyId, + renoteId: note.renoteId, + channelId: note.channelId || undefined, + channel: channel ? { + id: channel.id, + name: channel.name, + } : undefined, + mentions: note.mentions.length > 0 ? note.mentions : undefined, + uri: note.uri || undefined, + url: note.url || undefined, + + ...(opts.detail ? { + reply: note.replyId ? this.pack(note.reply || note.replyId, me, { + detail: false, + _hint_: options?._hint_, + }) : undefined, + + renote: note.renoteId ? this.pack(note.renote || note.renoteId, me, { + detail: true, + _hint_: options?._hint_, + }) : undefined, + + poll: note.hasPoll ? populatePoll(note, meId) : undefined, + + ...(meId ? { + myReaction: populateMyReaction(note, meId, options?._hint_), + } : {}), + } : {}), + }); + + if (packed.user.isCat && packed.text) { + const tokens = packed.text ? mfm.parse(packed.text) : []; + mfm.inspect(tokens, node => { + if (node.type === 'text') { + // TODO: quoteなtextはskip + node.props.text = nyaize(node.props.text); + } + }); + packed.text = mfm.toString(tokens); + } + + if (!opts.skipHide) { + await hideNote(packed, meId); + } + + return packed; + }, + + async packMany( + notes: Note[], + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: boolean; + skipHide?: boolean; + } + ) { + if (notes.length === 0) return []; + + const meId = me ? me.id : null; + const myReactionsMap = new Map(); + if (meId) { + const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); + const targets = [...notes.map(n => n.id), ...renoteIds]; + const myReactions = await NoteReactions.findBy({ + userId: meId, + noteId: In(targets), + }); + + for (const target of targets) { + myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) || null); + } + } + + await prefetchEmojis(aggregateNoteEmojis(notes)); + + return await Promise.all(notes.map(n => this.pack(n, me, { + ...options, + _hint_: { + myReactions: myReactionsMap, + }, + }))); + }, +}); diff --git a/packages/backend/src/models/repositories/notification.ts b/packages/backend/src/models/repositories/notification.ts new file mode 100644 index 0000000000..42b47ab150 --- /dev/null +++ b/packages/backend/src/models/repositories/notification.ts @@ -0,0 +1,115 @@ +import { In, Repository } from 'typeorm'; +import { Users, Notes, UserGroupInvitations, AccessTokens, NoteReactions } from '../index.js'; +import { Notification } from '@/models/entities/notification.js'; +import { awaitAll } from '@/prelude/await-all.js'; +import { Packed } from '@/misc/schema.js'; +import { Note } from '@/models/entities/note.js'; +import { NoteReaction } from '@/models/entities/note-reaction.js'; +import { User } from '@/models/entities/user.js'; +import { aggregateNoteEmojis, prefetchEmojis } from '@/misc/populate-emojis.js'; +import { notificationTypes } from '@/types.js'; +import { db } from '@/db/postgre.js'; + +export const NotificationRepository = db.getRepository(Notification).extend({ + async pack( + src: Notification['id'] | Notification, + options: { + _hintForEachNotes_?: { + myReactions: Map; + }; + } + ): Promise> { + const notification = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + const token = notification.appAccessTokenId ? await AccessTokens.findOneByOrFail({ id: notification.appAccessTokenId }) : null; + + return await awaitAll({ + id: notification.id, + createdAt: notification.createdAt.toISOString(), + type: notification.type, + isRead: notification.isRead, + userId: notification.notifierId, + user: notification.notifierId ? Users.pack(notification.notifier || notification.notifierId) : null, + ...(notification.type === 'mention' ? { + note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + } : {}), + ...(notification.type === 'reply' ? { + note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + } : {}), + ...(notification.type === 'renote' ? { + note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + } : {}), + ...(notification.type === 'quote' ? { + note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + } : {}), + ...(notification.type === 'reaction' ? { + note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + reaction: notification.reaction, + } : {}), + ...(notification.type === 'pollVote' ? { + note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + choice: notification.choice, + } : {}), + ...(notification.type === 'pollEnded' ? { + note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + } : {}), + ...(notification.type === 'groupInvited' ? { + invitation: UserGroupInvitations.pack(notification.userGroupInvitationId!), + } : {}), + ...(notification.type === 'app' ? { + body: notification.customBody, + header: notification.customHeader || token?.name, + icon: notification.customIcon || token?.iconUrl, + } : {}), + }); + }, + + async packMany( + notifications: Notification[], + meId: User['id'] + ) { + if (notifications.length === 0) return []; + + const notes = notifications.filter(x => x.note != null).map(x => x.note!); + const noteIds = notes.map(n => n.id); + const myReactionsMap = new Map(); + const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); + const targets = [...noteIds, ...renoteIds]; + const myReactions = await NoteReactions.findBy({ + userId: meId, + noteId: In(targets), + }); + + for (const target of targets) { + myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) || null); + } + + await prefetchEmojis(aggregateNoteEmojis(notes)); + + return await Promise.all(notifications.map(x => this.pack(x, { + _hintForEachNotes_: { + myReactions: myReactionsMap, + }, + }))); + }, +}); diff --git a/packages/backend/src/models/repositories/page-like.ts b/packages/backend/src/models/repositories/page-like.ts new file mode 100644 index 0000000000..87d6accc34 --- /dev/null +++ b/packages/backend/src/models/repositories/page-like.ts @@ -0,0 +1,25 @@ +import { db } from '@/db/postgre.js'; +import { PageLike } from '@/models/entities/page-like.js'; +import { Pages } from '../index.js'; +import { User } from '@/models/entities/user.js'; + +export const PageLikeRepository = db.getRepository(PageLike).extend({ + async pack( + src: PageLike['id'] | PageLike, + me?: { id: User['id'] } | null | undefined + ) { + const like = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return { + id: like.id, + page: await Pages.pack(like.page || like.pageId, me), + }; + }, + + packMany( + likes: any[], + me: { id: User['id'] } + ) { + return Promise.all(likes.map(x => this.pack(x, me))); + }, +}); diff --git a/packages/backend/src/models/repositories/page.ts b/packages/backend/src/models/repositories/page.ts new file mode 100644 index 0000000000..092b26b396 --- /dev/null +++ b/packages/backend/src/models/repositories/page.ts @@ -0,0 +1,88 @@ +import { db } from '@/db/postgre.js'; +import { Page } from '@/models/entities/page.js'; +import { Packed } from '@/misc/schema.js'; +import { awaitAll } from '@/prelude/await-all.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { User } from '@/models/entities/user.js'; +import { Users, DriveFiles, PageLikes } from '../index.js'; + +export const PageRepository = db.getRepository(Page).extend({ + async pack( + src: Page['id'] | Page, + me?: { id: User['id'] } | null | undefined, + ): Promise> { + const meId = me ? me.id : null; + const page = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + const attachedFiles: Promise[] = []; + const collectFile = (xs: any[]) => { + for (const x of xs) { + if (x.type === 'image') { + attachedFiles.push(DriveFiles.findOneBy({ + id: x.fileId, + userId: page.userId, + })); + } + if (x.children) { + collectFile(x.children); + } + } + }; + collectFile(page.content); + + // 後方互換性のため + let migrated = false; + const migrate = (xs: any[]) => { + for (const x of xs) { + if (x.type === 'input') { + if (x.inputType === 'text') { + x.type = 'textInput'; + } + if (x.inputType === 'number') { + x.type = 'numberInput'; + if (x.default) x.default = parseInt(x.default, 10); + } + migrated = true; + } + if (x.children) { + migrate(x.children); + } + } + }; + migrate(page.content); + if (migrated) { + this.update(page.id, { + content: page.content, + }); + } + + return await awaitAll({ + id: page.id, + createdAt: page.createdAt.toISOString(), + updatedAt: page.updatedAt.toISOString(), + userId: page.userId, + user: Users.pack(page.user || page.userId, me), // { detail: true } すると無限ループするので注意 + content: page.content, + variables: page.variables, + title: page.title, + name: page.name, + summary: page.summary, + hideTitleWhenPinned: page.hideTitleWhenPinned, + alignCenter: page.alignCenter, + font: page.font, + script: page.script, + eyeCatchingImageId: page.eyeCatchingImageId, + eyeCatchingImage: page.eyeCatchingImageId ? await DriveFiles.pack(page.eyeCatchingImageId) : null, + attachedFiles: DriveFiles.packMany((await Promise.all(attachedFiles)).filter((x): x is DriveFile => x != null)), + likedCount: page.likedCount, + isLiked: meId ? await PageLikes.findOneBy({ pageId: page.id, userId: meId }).then(x => x != null) : undefined, + }); + }, + + packMany( + pages: Page[], + me?: { id: User['id'] } | null | undefined, + ) { + return Promise.all(pages.map(x => this.pack(x, me))); + }, +}); diff --git a/packages/backend/src/models/repositories/relay.ts b/packages/backend/src/models/repositories/relay.ts new file mode 100644 index 0000000000..fa1c8f4d8d --- /dev/null +++ b/packages/backend/src/models/repositories/relay.ts @@ -0,0 +1,5 @@ +import { db } from '@/db/postgre.js'; +import { Relay } from '@/models/entities/relay.js'; + +export const RelayRepository = db.getRepository(Relay).extend({ +}); diff --git a/packages/backend/src/models/repositories/signin.ts b/packages/backend/src/models/repositories/signin.ts new file mode 100644 index 0000000000..94410ec58a --- /dev/null +++ b/packages/backend/src/models/repositories/signin.ts @@ -0,0 +1,10 @@ +import { db } from '@/db/postgre.js'; +import { Signin } from '@/models/entities/signin.js'; + +export const SigninRepository = db.getRepository(Signin).extend({ + async pack( + src: Signin, + ) { + return src; + }, +}); diff --git a/packages/backend/src/models/repositories/user-group-invitation.ts b/packages/backend/src/models/repositories/user-group-invitation.ts new file mode 100644 index 0000000000..79ad019c99 --- /dev/null +++ b/packages/backend/src/models/repositories/user-group-invitation.ts @@ -0,0 +1,22 @@ +import { db } from '@/db/postgre.js'; +import { UserGroupInvitation } from '@/models/entities/user-group-invitation.js'; +import { UserGroups } from '../index.js'; + +export const UserGroupInvitationRepository = db.getRepository(UserGroupInvitation).extend({ + async pack( + src: UserGroupInvitation['id'] | UserGroupInvitation, + ) { + const invitation = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + return { + id: invitation.id, + group: await UserGroups.pack(invitation.userGroup || invitation.userGroupId), + }; + }, + + packMany( + invitations: any[], + ) { + return Promise.all(invitations.map(x => this.pack(x))); + }, +}); diff --git a/packages/backend/src/models/repositories/user-group.ts b/packages/backend/src/models/repositories/user-group.ts new file mode 100644 index 0000000000..6eb9234244 --- /dev/null +++ b/packages/backend/src/models/repositories/user-group.ts @@ -0,0 +1,24 @@ +import { db } from '@/db/postgre.js'; +import { UserGroup } from '@/models/entities/user-group.js'; +import { UserGroupJoinings } from '../index.js'; +import { Packed } from '@/misc/schema.js'; + +export const UserGroupRepository = db.getRepository(UserGroup).extend({ + async pack( + src: UserGroup['id'] | UserGroup, + ): Promise> { + const userGroup = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + const users = await UserGroupJoinings.findBy({ + userGroupId: userGroup.id, + }); + + return { + id: userGroup.id, + createdAt: userGroup.createdAt.toISOString(), + name: userGroup.name, + ownerId: userGroup.userId, + userIds: users.map(x => x.userId), + }; + }, +}); diff --git a/packages/backend/src/models/repositories/user-list.ts b/packages/backend/src/models/repositories/user-list.ts new file mode 100644 index 0000000000..2b6f411ef6 --- /dev/null +++ b/packages/backend/src/models/repositories/user-list.ts @@ -0,0 +1,23 @@ +import { db } from '@/db/postgre.js'; +import { UserList } from '@/models/entities/user-list.js'; +import { UserListJoinings } from '../index.js'; +import { Packed } from '@/misc/schema.js'; + +export const UserListRepository = db.getRepository(UserList).extend({ + async pack( + src: UserList['id'] | UserList, + ): Promise> { + const userList = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src }); + + const users = await UserListJoinings.findBy({ + userListId: userList.id, + }); + + return { + id: userList.id, + createdAt: userList.createdAt.toISOString(), + name: userList.name, + userIds: users.map(x => x.userId), + }; + }, +}); diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts new file mode 100644 index 0000000000..5c46ae27a3 --- /dev/null +++ b/packages/backend/src/models/repositories/user.ts @@ -0,0 +1,436 @@ +import { EntityRepository, Repository, In, Not } from 'typeorm'; +import Ajv from 'ajv'; +import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js'; +import config from '@/config/index.js'; +import { Packed } from '@/misc/schema.js'; +import { awaitAll, Promiseable } from '@/prelude/await-all.js'; +import { populateEmojis } from '@/misc/populate-emojis.js'; +import { getAntennas } from '@/misc/antenna-cache.js'; +import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; +import { Cache } from '@/misc/cache.js'; +import { db } from '@/db/postgre.js'; +import { Instance } from '../entities/instance.js'; +import { Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, Antennas, AntennaNotes, ChannelFollowings, Instances, DriveFiles } from '../index.js'; + +const userInstanceCache = new Cache(1000 * 60 * 60 * 3); + +type IsUserDetailed = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>; +type IsMeAndIsUserDetailed = + Detailed extends true ? + ExpectsMe extends true ? Packed<'MeDetailed'> : + ExpectsMe extends false ? Packed<'UserDetailedNotMe'> : + Packed<'UserDetailed'> : + Packed<'UserLite'>; + +const ajv = new Ajv(); + +const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const; +const passwordSchema = { type: 'string', minLength: 1 } as const; +const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; +const descriptionSchema = { type: 'string', minLength: 1, maxLength: 500 } as const; +const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; +const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1) } as const; + +function isLocalUser(user: User): user is ILocalUser; +function isLocalUser(user: T): user is T & { host: null; }; +function isLocalUser(user: User | { host: User['host'] }): boolean { + return user.host == null; +} + +function isRemoteUser(user: User): user is IRemoteUser; +function isRemoteUser(user: T): user is T & { host: string; }; +function isRemoteUser(user: User | { host: User['host'] }): boolean { + return !isLocalUser(user); +} + +export const UserRepository = db.getRepository(User).extend({ + localUsernameSchema, + passwordSchema, + nameSchema, + descriptionSchema, + locationSchema, + birthdaySchema, + + //#region Validators + validateLocalUsername: ajv.compile(localUsernameSchema), + validatePassword: ajv.compile(passwordSchema), + validateName: ajv.compile(nameSchema), + validateDescription: ajv.compile(descriptionSchema), + validateLocation: ajv.compile(locationSchema), + validateBirthday: ajv.compile(birthdaySchema), + //#endregion + + async getRelation(me: User['id'], target: User['id']) { + return awaitAll({ + id: target, + isFollowing: Followings.count({ + where: { + followerId: me, + followeeId: target, + }, + take: 1, + }).then(n => n > 0), + isFollowed: Followings.count({ + where: { + followerId: target, + followeeId: me, + }, + take: 1, + }).then(n => n > 0), + hasPendingFollowRequestFromYou: FollowRequests.count({ + where: { + followerId: me, + followeeId: target, + }, + take: 1, + }).then(n => n > 0), + hasPendingFollowRequestToYou: FollowRequests.count({ + where: { + followerId: target, + followeeId: me, + }, + take: 1, + }).then(n => n > 0), + isBlocking: Blockings.count({ + where: { + blockerId: me, + blockeeId: target, + }, + take: 1, + }).then(n => n > 0), + isBlocked: Blockings.count({ + where: { + blockerId: target, + blockeeId: me, + }, + take: 1, + }).then(n => n > 0), + isMuted: Mutings.count({ + where: { + muterId: me, + muteeId: target, + }, + take: 1, + }).then(n => n > 0), + }); + }, + + async getHasUnreadMessagingMessage(userId: User['id']): Promise { + const mute = await Mutings.findBy({ + muterId: userId, + }); + + const joinings = await UserGroupJoinings.findBy({ userId: userId }); + + const groupQs = Promise.all(joinings.map(j => MessagingMessages.createQueryBuilder('message') + .where('message.groupId = :groupId', { groupId: j.userGroupId }) + .andWhere('message.userId != :userId', { userId: userId }) + .andWhere('NOT (:userId = ANY(message.reads))', { userId: userId }) + .andWhere('message.createdAt > :joinedAt', { joinedAt: j.createdAt }) // 自分が加入する前の会話については、未読扱いしない + .getOne().then(x => x != null))); + + const [withUser, withGroups] = await Promise.all([ + MessagingMessages.count({ + where: { + recipientId: userId, + isRead: false, + ...(mute.length > 0 ? { userId: Not(In(mute.map(x => x.muteeId))) } : {}), + }, + take: 1, + }).then(count => count > 0), + groupQs, + ]); + + return withUser || withGroups.some(x => x); + }, + + async getHasUnreadAnnouncement(userId: User['id']): Promise { + const reads = await AnnouncementReads.findBy({ + userId: userId, + }); + + const count = await Announcements.countBy(reads.length > 0 ? { + id: Not(In(reads.map(read => read.announcementId))), + } : {}); + + return count > 0; + }, + + async getHasUnreadAntenna(userId: User['id']): Promise { + const myAntennas = (await getAntennas()).filter(a => a.userId === userId); + + const unread = myAntennas.length > 0 ? await AntennaNotes.findOneBy({ + antennaId: In(myAntennas.map(x => x.id)), + read: false, + }) : null; + + return unread != null; + }, + + async getHasUnreadChannel(userId: User['id']): Promise { + const channels = await ChannelFollowings.findBy({ followerId: userId }); + + const unread = channels.length > 0 ? await NoteUnreads.findOneBy({ + userId: userId, + noteChannelId: In(channels.map(x => x.followeeId)), + }) : null; + + return unread != null; + }, + + async getHasUnreadNotification(userId: User['id']): Promise { + const mute = await Mutings.findBy({ + muterId: userId, + }); + const mutedUserIds = mute.map(m => m.muteeId); + + const count = await Notifications.count({ + where: { + notifieeId: userId, + ...(mutedUserIds.length > 0 ? { notifierId: Not(In(mutedUserIds)) } : {}), + isRead: false, + }, + take: 1, + }); + + return count > 0; + }, + + async getHasPendingReceivedFollowRequest(userId: User['id']): Promise { + const count = await FollowRequests.countBy({ + followeeId: userId, + }); + + return count > 0; + }, + + getOnlineStatus(user: User): 'unknown' | 'online' | 'active' | 'offline' { + if (user.hideOnlineStatus) return 'unknown'; + if (user.lastActiveDate == null) return 'unknown'; + const elapsed = Date.now() - user.lastActiveDate.getTime(); + return ( + elapsed < USER_ONLINE_THRESHOLD ? 'online' : + elapsed < USER_ACTIVE_THRESHOLD ? 'active' : + 'offline' + ); + }, + + async getAvatarUrl(user: User): Promise { + if (user.avatar) { + return DriveFiles.getPublicUrl(user.avatar, true) || this.getIdenticonUrl(user.id); + } else if (user.avatarId) { + const avatar = await DriveFiles.findOneByOrFail({ id: user.avatarId }); + return DriveFiles.getPublicUrl(avatar, true) || this.getIdenticonUrl(user.id); + } else { + return this.getIdenticonUrl(user.id); + } + }, + + getAvatarUrlSync(user: User): string { + if (user.avatar) { + return DriveFiles.getPublicUrl(user.avatar, true) || this.getIdenticonUrl(user.id); + } else { + return this.getIdenticonUrl(user.id); + } + }, + + getIdenticonUrl(userId: User['id']): string { + return `${config.url}/identicon/${userId}`; + }, + + async pack( + src: User['id'] | User, + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: D, + includeSecrets?: boolean, + }, + ): Promise> { + const opts = Object.assign({ + detail: false, + includeSecrets: false, + }, options); + + let user: User; + + if (typeof src === 'object') { + user = src; + if (src.avatar === undefined && src.avatarId) src.avatar = await DriveFiles.findOneBy({ id: src.avatarId }) ?? null; + if (src.banner === undefined && src.bannerId) src.banner = await DriveFiles.findOneBy({ id: src.bannerId }) ?? null; + } else { + user = await this.findOneOrFail({ + where: { id: src }, + relations: { + avatar: true, + banner: true, + }, + }); + } + + const meId = me ? me.id : null; + const isMe = meId === user.id; + + const relation = meId && !isMe && opts.detail ? await this.getRelation(meId, user.id) : null; + const pins = opts.detail ? await UserNotePinings.createQueryBuilder('pin') + .where('pin.userId = :userId', { userId: user.id }) + .innerJoinAndSelect('pin.note', 'note') + .orderBy('pin.id', 'DESC') + .getMany() : []; + const profile = opts.detail ? await UserProfiles.findOneByOrFail({ userId: user.id }) : null; + + const followingCount = profile == null ? null : + (profile.ffVisibility === 'public') || isMe ? user.followingCount : + (profile.ffVisibility === 'followers') && (relation && relation.isFollowing) ? user.followingCount : + null; + + const followersCount = profile == null ? null : + (profile.ffVisibility === 'public') || isMe ? user.followersCount : + (profile.ffVisibility === 'followers') && (relation && relation.isFollowing) ? user.followersCount : + null; + + const falsy = opts.detail ? false : undefined; + + const packed = { + id: user.id, + name: user.name, + username: user.username, + host: user.host, + avatarUrl: this.getAvatarUrlSync(user), + avatarBlurhash: user.avatar?.blurhash || null, + avatarColor: null, // 後方互換性のため + isAdmin: user.isAdmin || falsy, + isModerator: user.isModerator || falsy, + isBot: user.isBot || falsy, + isCat: user.isCat || falsy, + instance: user.host ? userInstanceCache.fetch(user.host, + () => Instances.findOneBy({ host: user.host! }), + v => v != null, + ).then(instance => instance ? { + name: instance.name, + softwareName: instance.softwareName, + softwareVersion: instance.softwareVersion, + iconUrl: instance.iconUrl, + faviconUrl: instance.faviconUrl, + themeColor: instance.themeColor, + } : undefined) : undefined, + emojis: populateEmojis(user.emojis, user.host), + onlineStatus: this.getOnlineStatus(user), + driveCapacityOverrideMb: user.driveCapacityOverrideMb, + + ...(opts.detail ? { + url: profile!.url, + uri: user.uri, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, + lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, + bannerUrl: user.banner ? DriveFiles.getPublicUrl(user.banner, false) : null, + bannerBlurhash: user.banner?.blurhash || null, + bannerColor: null, // 後方互換性のため + isLocked: user.isLocked, + isSilenced: user.isSilenced || falsy, + isSuspended: user.isSuspended || falsy, + description: profile!.description, + location: profile!.location, + birthday: profile!.birthday, + lang: profile!.lang, + fields: profile!.fields, + followersCount: followersCount || 0, + followingCount: followingCount || 0, + notesCount: user.notesCount, + pinnedNoteIds: pins.map(pin => pin.noteId), + pinnedNotes: Notes.packMany(pins.map(pin => pin.note!), me, { + detail: true, + }), + pinnedPageId: profile!.pinnedPageId, + pinnedPage: profile!.pinnedPageId ? Pages.pack(profile!.pinnedPageId, me) : null, + publicReactions: profile!.publicReactions, + ffVisibility: profile!.ffVisibility, + twoFactorEnabled: profile!.twoFactorEnabled, + usePasswordLessLogin: profile!.usePasswordLessLogin, + securityKeys: profile!.twoFactorEnabled + ? UserSecurityKeys.countBy({ + userId: user.id, + }).then(result => result >= 1) + : false, + } : {}), + + ...(opts.detail && isMe ? { + avatarId: user.avatarId, + bannerId: user.bannerId, + injectFeaturedNote: profile!.injectFeaturedNote, + receiveAnnouncementEmail: profile!.receiveAnnouncementEmail, + alwaysMarkNsfw: profile!.alwaysMarkNsfw, + autoSensitive: profile!.autoSensitive, + carefulBot: profile!.carefulBot, + autoAcceptFollowed: profile!.autoAcceptFollowed, + noCrawle: profile!.noCrawle, + isExplorable: user.isExplorable, + isDeleted: user.isDeleted, + hideOnlineStatus: user.hideOnlineStatus, + hasUnreadSpecifiedNotes: NoteUnreads.count({ + where: { userId: user.id, isSpecified: true }, + take: 1, + }).then(count => count > 0), + hasUnreadMentions: NoteUnreads.count({ + where: { userId: user.id, isMentioned: true }, + take: 1, + }).then(count => count > 0), + hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id), + hasUnreadAntenna: this.getHasUnreadAntenna(user.id), + hasUnreadChannel: this.getHasUnreadChannel(user.id), + hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id), + hasUnreadNotification: this.getHasUnreadNotification(user.id), + hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), + integrations: profile!.integrations, + mutedWords: profile!.mutedWords, + mutedInstances: profile!.mutedInstances, + mutingNotificationTypes: profile!.mutingNotificationTypes, + emailNotificationTypes: profile!.emailNotificationTypes, + showTimelineReplies: user.showTimelineReplies || falsy, + } : {}), + + ...(opts.includeSecrets ? { + email: profile!.email, + emailVerified: profile!.emailVerified, + securityKeysList: profile!.twoFactorEnabled + ? UserSecurityKeys.find({ + where: { + userId: user.id, + }, + select: { + id: true, + name: true, + lastUsed: true, + }, + }) + : [], + } : {}), + + ...(relation ? { + isFollowing: relation.isFollowing, + isFollowed: relation.isFollowed, + hasPendingFollowRequestFromYou: relation.hasPendingFollowRequestFromYou, + hasPendingFollowRequestToYou: relation.hasPendingFollowRequestToYou, + isBlocking: relation.isBlocking, + isBlocked: relation.isBlocked, + isMuted: relation.isMuted, + } : {}), + } as Promiseable> as Promiseable>; + + return await awaitAll(packed); + }, + + packMany( + users: (User['id'] | User)[], + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: D, + includeSecrets?: boolean, + }, + ): Promise[]> { + return Promise.all(users.map(u => this.pack(u, me, options))); + }, + + isLocalUser, + isRemoteUser, +}); diff --git a/packages/backend/src/models/schema/antenna.ts b/packages/backend/src/models/schema/antenna.ts new file mode 100644 index 0000000000..9cf522802c --- /dev/null +++ b/packages/backend/src/models/schema/antenna.ts @@ -0,0 +1,89 @@ +export const packedAntennaSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + keywords: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, + excludeKeywords: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, + src: { + type: 'string', + optional: false, nullable: false, + enum: ['home', 'all', 'users', 'list', 'group'], + }, + userListId: { + type: 'string', + optional: false, nullable: true, + format: 'id', + }, + userGroupId: { + type: 'string', + optional: false, nullable: true, + format: 'id', + }, + users: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, + caseSensitive: { + type: 'boolean', + optional: false, nullable: false, + default: false, + }, + notify: { + type: 'boolean', + optional: false, nullable: false, + }, + withReplies: { + type: 'boolean', + optional: false, nullable: false, + default: false, + }, + withFile: { + type: 'boolean', + optional: false, nullable: false, + }, + hasUnreadNote: { + type: 'boolean', + optional: false, nullable: false, + default: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/app.ts b/packages/backend/src/models/schema/app.ts new file mode 100644 index 0000000000..c80dc81c33 --- /dev/null +++ b/packages/backend/src/models/schema/app.ts @@ -0,0 +1,33 @@ +export const packedAppSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + callbackUrl: { + type: 'string', + optional: false, nullable: true, + }, + permission: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, + secret: { + type: 'string', + optional: true, nullable: false, + }, + isAuthorized: { + type: 'boolean', + optional: true, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/blocking.ts b/packages/backend/src/models/schema/blocking.ts new file mode 100644 index 0000000000..5532322420 --- /dev/null +++ b/packages/backend/src/models/schema/blocking.ts @@ -0,0 +1,26 @@ +export const packedBlockingSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + blockeeId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + blockee: { + type: 'object', + optional: false, nullable: false, + ref: 'UserDetailed', + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/channel.ts b/packages/backend/src/models/schema/channel.ts new file mode 100644 index 0000000000..7f4f2a48b8 --- /dev/null +++ b/packages/backend/src/models/schema/channel.ts @@ -0,0 +1,51 @@ +export const packedChannelSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + lastNotedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + description: { + type: 'string', + nullable: true, optional: false, + }, + bannerUrl: { + type: 'string', + format: 'url', + nullable: true, optional: false, + }, + notesCount: { + type: 'number', + nullable: false, optional: false, + }, + usersCount: { + type: 'number', + nullable: false, optional: false, + }, + isFollowing: { + type: 'boolean', + optional: true, nullable: false, + }, + userId: { + type: 'string', + nullable: true, optional: false, + format: 'id', + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/clip.ts b/packages/backend/src/models/schema/clip.ts new file mode 100644 index 0000000000..f0ee2ce0c4 --- /dev/null +++ b/packages/backend/src/models/schema/clip.ts @@ -0,0 +1,38 @@ +export const packedClipSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + description: { + type: 'string', + optional: false, nullable: true, + }, + isPublic: { + type: 'boolean', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/drive-file.ts b/packages/backend/src/models/schema/drive-file.ts new file mode 100644 index 0000000000..4359076612 --- /dev/null +++ b/packages/backend/src/models/schema/drive-file.ts @@ -0,0 +1,107 @@ +export const packedDriveFileSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + name: { + type: 'string', + optional: false, nullable: false, + example: 'lenna.jpg', + }, + type: { + type: 'string', + optional: false, nullable: false, + example: 'image/jpeg', + }, + md5: { + type: 'string', + optional: false, nullable: false, + format: 'md5', + example: '15eca7fba0480996e2245f5185bf39f2', + }, + size: { + type: 'number', + optional: false, nullable: false, + example: 51469, + }, + isSensitive: { + type: 'boolean', + optional: false, nullable: false, + }, + blurhash: { + type: 'string', + optional: false, nullable: true, + }, + properties: { + type: 'object', + optional: false, nullable: false, + properties: { + width: { + type: 'number', + optional: true, nullable: false, + example: 1280, + }, + height: { + type: 'number', + optional: true, nullable: false, + example: 720, + }, + orientation: { + type: 'number', + optional: true, nullable: false, + example: 8, + }, + avgColor: { + type: 'string', + optional: true, nullable: false, + example: 'rgb(40,65,87)', + }, + }, + }, + url: { + type: 'string', + optional: false, nullable: true, + format: 'url', + }, + thumbnailUrl: { + type: 'string', + optional: false, nullable: true, + format: 'url', + }, + comment: { + type: 'string', + optional: false, nullable: true, + }, + folderId: { + type: 'string', + optional: false, nullable: true, + format: 'id', + example: 'xxxxxxxxxx', + }, + folder: { + type: 'object', + optional: true, nullable: true, + ref: 'DriveFolder', + }, + userId: { + type: 'string', + optional: false, nullable: true, + format: 'id', + example: 'xxxxxxxxxx', + }, + user: { + type: 'object', + optional: true, nullable: true, + ref: 'UserLite', + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/drive-folder.ts b/packages/backend/src/models/schema/drive-folder.ts new file mode 100644 index 0000000000..88cb8ab4a2 --- /dev/null +++ b/packages/backend/src/models/schema/drive-folder.ts @@ -0,0 +1,39 @@ +export const packedDriveFolderSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + foldersCount: { + type: 'number', + optional: true, nullable: false, + }, + filesCount: { + type: 'number', + optional: true, nullable: false, + }, + parentId: { + type: 'string', + optional: false, nullable: true, + format: 'id', + example: 'xxxxxxxxxx', + }, + parent: { + type: 'object', + optional: true, nullable: true, + ref: 'DriveFolder', + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/emoji.ts b/packages/backend/src/models/schema/emoji.ts new file mode 100644 index 0000000000..e97fdd5ef6 --- /dev/null +++ b/packages/backend/src/models/schema/emoji.ts @@ -0,0 +1,37 @@ +export const packedEmojiSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + aliases: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + category: { + type: 'string', + optional: false, nullable: true, + }, + host: { + type: 'string', + optional: false, nullable: true, + description: 'The local host is represented with `null`.', + }, + url: { + type: 'string', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/federation-instance.ts b/packages/backend/src/models/schema/federation-instance.ts similarity index 85% rename from packages/backend/src/models/json-schema/federation-instance.ts rename to packages/backend/src/models/schema/federation-instance.ts index 8042d0fe5b..93327304f3 100644 --- a/packages/backend/src/models/json-schema/federation-instance.ts +++ b/packages/backend/src/models/schema/federation-instance.ts @@ -1,7 +1,4 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ +import config from '@/config/index.js'; export const packedFederationInstanceSchema = { type: 'object', @@ -11,7 +8,7 @@ export const packedFederationInstanceSchema = { optional: false, nullable: false, format: 'id', }, - firstRetrievedAt: { + caughtAt: { type: 'string', optional: false, nullable: false, format: 'date-time', @@ -37,6 +34,16 @@ export const packedFederationInstanceSchema = { type: 'number', optional: false, nullable: false, }, + latestRequestSentAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, + lastCommunicatedAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, isNotResponding: { type: 'boolean', optional: false, nullable: false, @@ -49,10 +56,6 @@ export const packedFederationInstanceSchema = { type: 'boolean', optional: false, nullable: false, }, - isAllowed: { - type: 'boolean', - optional: false, nullable: true, - }, softwareName: { type: 'string', optional: false, nullable: true, @@ -61,6 +64,7 @@ export const packedFederationInstanceSchema = { softwareVersion: { type: 'string', optional: false, nullable: true, + example: config.version, }, openRegistrations: { type: 'boolean', @@ -83,10 +87,6 @@ export const packedFederationInstanceSchema = { type: 'string', optional: false, nullable: true, }, - isSilenced: { - type: 'boolean', - optional: false, nullable: false, - }, iconUrl: { type: 'string', optional: false, nullable: true, @@ -106,15 +106,5 @@ export const packedFederationInstanceSchema = { optional: false, nullable: true, format: 'date-time', }, - latestRequestReceivedAt: { - type: 'string', - optional: false, nullable: true, - format: 'date-time', - }, - isNSFW: { - type: 'boolean', - optional: false, - nullable: false, - }, }, } as const; diff --git a/packages/backend/src/models/schema/following.ts b/packages/backend/src/models/schema/following.ts new file mode 100644 index 0000000000..2bcffbfc4d --- /dev/null +++ b/packages/backend/src/models/schema/following.ts @@ -0,0 +1,36 @@ +export const packedFollowingSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + followeeId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + followee: { + type: 'object', + optional: true, nullable: false, + ref: 'UserDetailed', + }, + followerId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + follower: { + type: 'object', + optional: true, nullable: false, + ref: 'UserDetailed', + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/gallery-post.ts b/packages/backend/src/models/schema/gallery-post.ts new file mode 100644 index 0000000000..fc503d4a64 --- /dev/null +++ b/packages/backend/src/models/schema/gallery-post.ts @@ -0,0 +1,69 @@ +export const packedGalleryPostSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + updatedAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + title: { + type: 'string', + optional: false, nullable: false, + }, + description: { + type: 'string', + optional: false, nullable: true, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + fileIds: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + files: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'DriveFile', + }, + }, + tags: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, + isSensitive: { + type: 'boolean', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/hashtag.ts b/packages/backend/src/models/schema/hashtag.ts new file mode 100644 index 0000000000..98f8827640 --- /dev/null +++ b/packages/backend/src/models/schema/hashtag.ts @@ -0,0 +1,34 @@ +export const packedHashtagSchema = { + type: 'object', + properties: { + tag: { + type: 'string', + optional: false, nullable: false, + example: 'misskey', + }, + mentionedUsersCount: { + type: 'number', + optional: false, nullable: false, + }, + mentionedLocalUsersCount: { + type: 'number', + optional: false, nullable: false, + }, + mentionedRemoteUsersCount: { + type: 'number', + optional: false, nullable: false, + }, + attachedUsersCount: { + type: 'number', + optional: false, nullable: false, + }, + attachedLocalUsersCount: { + type: 'number', + optional: false, nullable: false, + }, + attachedRemoteUsersCount: { + type: 'number', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/messaging-message.ts b/packages/backend/src/models/schema/messaging-message.ts new file mode 100644 index 0000000000..b1ffa45955 --- /dev/null +++ b/packages/backend/src/models/schema/messaging-message.ts @@ -0,0 +1,73 @@ +export const packedMessagingMessageSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + user: { + type: 'object', + ref: 'UserLite', + optional: true, nullable: false, + }, + text: { + type: 'string', + optional: false, nullable: true, + }, + fileId: { + type: 'string', + optional: true, nullable: true, + format: 'id', + }, + file: { + type: 'object', + optional: true, nullable: true, + ref: 'DriveFile', + }, + recipientId: { + type: 'string', + optional: false, nullable: true, + format: 'id', + }, + recipient: { + type: 'object', + optional: true, nullable: true, + ref: 'UserLite', + }, + groupId: { + type: 'string', + optional: false, nullable: true, + format: 'id', + }, + group: { + type: 'object', + optional: true, nullable: true, + ref: 'UserGroup', + }, + isRead: { + type: 'boolean', + optional: true, nullable: false, + }, + reads: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/muting.ts b/packages/backend/src/models/schema/muting.ts new file mode 100644 index 0000000000..3ab99e17e7 --- /dev/null +++ b/packages/backend/src/models/schema/muting.ts @@ -0,0 +1,31 @@ +export const packedMutingSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + expiresAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, + muteeId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + mutee: { + type: 'object', + optional: false, nullable: false, + ref: 'UserDetailed', + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/note-favorite.ts b/packages/backend/src/models/schema/note-favorite.ts new file mode 100644 index 0000000000..d133f7367d --- /dev/null +++ b/packages/backend/src/models/schema/note-favorite.ts @@ -0,0 +1,26 @@ +export const packedNoteFavoriteSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + note: { + type: 'object', + optional: false, nullable: false, + ref: 'Note', + }, + noteId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/note-reaction.ts b/packages/backend/src/models/schema/note-reaction.ts new file mode 100644 index 0000000000..0d8fc5449b --- /dev/null +++ b/packages/backend/src/models/schema/note-reaction.ts @@ -0,0 +1,25 @@ +export const packedNoteReactionSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + user: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + type: { + type: 'string', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/note.ts b/packages/backend/src/models/schema/note.ts new file mode 100644 index 0000000000..cdf4b9a544 --- /dev/null +++ b/packages/backend/src/models/schema/note.ts @@ -0,0 +1,183 @@ +export const packedNoteSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + text: { + type: 'string', + optional: false, nullable: true, + }, + cw: { + type: 'string', + optional: true, nullable: true, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + replyId: { + type: 'string', + optional: true, nullable: true, + format: 'id', + example: 'xxxxxxxxxx', + }, + renoteId: { + type: 'string', + optional: true, nullable: true, + format: 'id', + example: 'xxxxxxxxxx', + }, + reply: { + type: 'object', + optional: true, nullable: true, + ref: 'Note', + }, + renote: { + type: 'object', + optional: true, nullable: true, + ref: 'Note', + }, + isHidden: { + type: 'boolean', + optional: true, nullable: false, + }, + visibility: { + type: 'string', + optional: false, nullable: false, + }, + mentions: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + visibleUserIds: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + fileIds: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + files: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'DriveFile', + }, + }, + tags: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, + poll: { + type: 'object', + optional: true, nullable: true, + }, + channelId: { + type: 'string', + optional: true, nullable: true, + format: 'id', + example: 'xxxxxxxxxx', + }, + channel: { + type: 'object', + optional: true, nullable: true, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + name: { + type: 'string', + optional: false, nullable: true, + }, + }, + }, + }, + localOnly: { + type: 'boolean', + optional: true, nullable: false, + }, + emojis: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + name: { + type: 'string', + optional: false, nullable: false, + }, + url: { + type: 'string', + optional: false, nullable: true, + }, + }, + }, + }, + reactions: { + type: 'object', + optional: false, nullable: false, + }, + renoteCount: { + type: 'number', + optional: false, nullable: false, + }, + repliesCount: { + type: 'number', + optional: false, nullable: false, + }, + uri: { + type: 'string', + optional: true, nullable: false, + }, + url: { + type: 'string', + optional: true, nullable: false, + }, + + myReaction: { + type: 'object', + optional: true, nullable: true, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/notification.ts b/packages/backend/src/models/schema/notification.ts new file mode 100644 index 0000000000..d3f2405cdd --- /dev/null +++ b/packages/backend/src/models/schema/notification.ts @@ -0,0 +1,66 @@ +import { notificationTypes } from '@/types.js'; + +export const packedNotificationSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + isRead: { + type: 'boolean', + optional: false, nullable: false, + }, + type: { + type: 'string', + optional: false, nullable: false, + enum: [...notificationTypes], + }, + user: { + type: 'object', + ref: 'UserLite', + optional: true, nullable: true, + }, + userId: { + type: 'string', + optional: true, nullable: true, + format: 'id', + }, + note: { + type: 'object', + ref: 'Note', + optional: true, nullable: true, + }, + reaction: { + type: 'string', + optional: true, nullable: true, + }, + choice: { + type: 'number', + optional: true, nullable: true, + }, + invitation: { + type: 'object', + optional: true, nullable: true, + }, + body: { + type: 'string', + optional: true, nullable: true, + }, + header: { + type: 'string', + optional: true, nullable: true, + }, + icon: { + type: 'string', + optional: true, nullable: true, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/page.ts b/packages/backend/src/models/schema/page.ts new file mode 100644 index 0000000000..55ba3ce7f7 --- /dev/null +++ b/packages/backend/src/models/schema/page.ts @@ -0,0 +1,51 @@ +export const packedPageSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + updatedAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + title: { + type: 'string', + optional: false, nullable: false, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + summary: { + type: 'string', + optional: false, nullable: true, + }, + content: { + type: 'array', + optional: false, nullable: false, + }, + variables: { + type: 'array', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/queue.ts b/packages/backend/src/models/schema/queue.ts similarity index 79% rename from packages/backend/src/models/json-schema/queue.ts rename to packages/backend/src/models/schema/queue.ts index 43da6e605d..7ceeda26af 100644 --- a/packages/backend/src/models/json-schema/queue.ts +++ b/packages/backend/src/models/schema/queue.ts @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - export const packedQueueCountSchema = { type: 'object', properties: { diff --git a/packages/backend/src/models/schema/user-group.ts b/packages/backend/src/models/schema/user-group.ts new file mode 100644 index 0000000000..a73bf82bb8 --- /dev/null +++ b/packages/backend/src/models/schema/user-group.ts @@ -0,0 +1,34 @@ +export const packedUserGroupSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + ownerId: { + type: 'string', + nullable: false, optional: false, + format: 'id', + }, + userIds: { + type: 'array', + nullable: false, optional: true, + items: { + type: 'string', + nullable: false, optional: false, + format: 'id', + }, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/user-list.ts b/packages/backend/src/models/schema/user-list.ts new file mode 100644 index 0000000000..3ba5dc4a8a --- /dev/null +++ b/packages/backend/src/models/schema/user-list.ts @@ -0,0 +1,29 @@ +export const packedUserListSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + userIds: { + type: 'array', + nullable: false, optional: true, + items: { + type: 'string', + nullable: false, optional: false, + format: 'id', + }, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/user.ts b/packages/backend/src/models/schema/user.ts new file mode 100644 index 0000000000..1c8fe97858 --- /dev/null +++ b/packages/backend/src/models/schema/user.ts @@ -0,0 +1,472 @@ +export const packedUserLiteSchema = { + type: 'object', + properties: { + id: { + type: 'string', + nullable: false, optional: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + name: { + type: 'string', + nullable: true, optional: false, + example: '藍', + }, + username: { + type: 'string', + nullable: false, optional: false, + example: 'ai', + }, + host: { + type: 'string', + nullable: true, optional: false, + example: 'misskey.example.com', + description: 'The local host is represented with `null`.', + }, + avatarUrl: { + type: 'string', + format: 'url', + nullable: true, optional: false, + }, + avatarBlurhash: { + type: 'any', + nullable: true, optional: false, + }, + avatarColor: { + type: 'any', + nullable: true, optional: false, + default: null, + }, + isAdmin: { + type: 'boolean', + nullable: false, optional: true, + default: false, + }, + isModerator: { + type: 'boolean', + nullable: false, optional: true, + default: false, + }, + isBot: { + type: 'boolean', + nullable: false, optional: true, + }, + isCat: { + type: 'boolean', + nullable: false, optional: true, + }, + emojis: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'object', + nullable: false, optional: false, + properties: { + name: { + type: 'string', + nullable: false, optional: false, + }, + url: { + type: 'string', + nullable: false, optional: false, + format: 'url', + }, + }, + }, + }, + onlineStatus: { + type: 'string', + format: 'url', + nullable: true, optional: false, + enum: ['unknown', 'online', 'active', 'offline'], + }, + }, +} as const; + +export const packedUserDetailedNotMeOnlySchema = { + type: 'object', + properties: { + url: { + type: 'string', + format: 'url', + nullable: true, optional: false, + }, + uri: { + type: 'string', + format: 'uri', + nullable: true, optional: false, + }, + createdAt: { + type: 'string', + nullable: false, optional: false, + format: 'date-time', + }, + updatedAt: { + type: 'string', + nullable: true, optional: false, + format: 'date-time', + }, + lastFetchedAt: { + type: 'string', + nullable: true, optional: false, + format: 'date-time', + }, + bannerUrl: { + type: 'string', + format: 'url', + nullable: true, optional: false, + }, + bannerBlurhash: { + type: 'any', + nullable: true, optional: false, + }, + bannerColor: { + type: 'any', + nullable: true, optional: false, + default: null, + }, + isLocked: { + type: 'boolean', + nullable: false, optional: false, + }, + isSilenced: { + type: 'boolean', + nullable: false, optional: false, + }, + isSuspended: { + type: 'boolean', + nullable: false, optional: false, + example: false, + }, + description: { + type: 'string', + nullable: true, optional: false, + example: 'Hi masters, I am Ai!', + }, + location: { + type: 'string', + nullable: true, optional: false, + }, + birthday: { + type: 'string', + nullable: true, optional: false, + example: '2018-03-12', + }, + lang: { + type: 'string', + nullable: true, optional: false, + example: 'ja-JP', + }, + fields: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'object', + nullable: false, optional: false, + properties: { + name: { + type: 'string', + nullable: false, optional: false, + }, + value: { + type: 'string', + nullable: false, optional: false, + }, + }, + maxLength: 4, + }, + }, + followersCount: { + type: 'number', + nullable: false, optional: false, + }, + followingCount: { + type: 'number', + nullable: false, optional: false, + }, + notesCount: { + type: 'number', + nullable: false, optional: false, + }, + pinnedNoteIds: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'string', + nullable: false, optional: false, + format: 'id', + }, + }, + pinnedNotes: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'object', + nullable: false, optional: false, + ref: 'Note', + }, + }, + pinnedPageId: { + type: 'string', + nullable: true, optional: false, + }, + pinnedPage: { + type: 'object', + nullable: true, optional: false, + ref: 'Page', + }, + publicReactions: { + type: 'boolean', + nullable: false, optional: false, + }, + twoFactorEnabled: { + type: 'boolean', + nullable: false, optional: false, + default: false, + }, + usePasswordLessLogin: { + type: 'boolean', + nullable: false, optional: false, + default: false, + }, + securityKeys: { + type: 'boolean', + nullable: false, optional: false, + default: false, + }, + //#region relations + isFollowing: { + type: 'boolean', + nullable: false, optional: true, + }, + isFollowed: { + type: 'boolean', + nullable: false, optional: true, + }, + hasPendingFollowRequestFromYou: { + type: 'boolean', + nullable: false, optional: true, + }, + hasPendingFollowRequestToYou: { + type: 'boolean', + nullable: false, optional: true, + }, + isBlocking: { + type: 'boolean', + nullable: false, optional: true, + }, + isBlocked: { + type: 'boolean', + nullable: false, optional: true, + }, + isMuted: { + type: 'boolean', + nullable: false, optional: true, + }, + //#endregion + }, +} as const; + +export const packedMeDetailedOnlySchema = { + type: 'object', + properties: { + avatarId: { + type: 'string', + nullable: true, optional: false, + format: 'id', + }, + bannerId: { + type: 'string', + nullable: true, optional: false, + format: 'id', + }, + injectFeaturedNote: { + type: 'boolean', + nullable: true, optional: false, + }, + receiveAnnouncementEmail: { + type: 'boolean', + nullable: true, optional: false, + }, + alwaysMarkNsfw: { + type: 'boolean', + nullable: true, optional: false, + }, + autoSensitive: { + type: 'boolean', + nullable: true, optional: false, + }, + carefulBot: { + type: 'boolean', + nullable: true, optional: false, + }, + autoAcceptFollowed: { + type: 'boolean', + nullable: true, optional: false, + }, + noCrawle: { + type: 'boolean', + nullable: true, optional: false, + }, + isExplorable: { + type: 'boolean', + nullable: false, optional: false, + }, + isDeleted: { + type: 'boolean', + nullable: false, optional: false, + }, + hideOnlineStatus: { + type: 'boolean', + nullable: false, optional: false, + }, + hasUnreadSpecifiedNotes: { + type: 'boolean', + nullable: false, optional: false, + }, + hasUnreadMentions: { + type: 'boolean', + nullable: false, optional: false, + }, + hasUnreadAnnouncement: { + type: 'boolean', + nullable: false, optional: false, + }, + hasUnreadAntenna: { + type: 'boolean', + nullable: false, optional: false, + }, + hasUnreadChannel: { + type: 'boolean', + nullable: false, optional: false, + }, + hasUnreadMessagingMessage: { + type: 'boolean', + nullable: false, optional: false, + }, + hasUnreadNotification: { + type: 'boolean', + nullable: false, optional: false, + }, + hasPendingReceivedFollowRequest: { + type: 'boolean', + nullable: false, optional: false, + }, + integrations: { + type: 'object', + nullable: true, optional: false, + }, + mutedWords: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'string', + nullable: false, optional: false, + }, + }, + }, + mutedInstances: { + type: 'array', + nullable: true, optional: false, + items: { + type: 'string', + nullable: false, optional: false, + }, + }, + mutingNotificationTypes: { + type: 'array', + nullable: true, optional: false, + items: { + type: 'string', + nullable: false, optional: false, + }, + }, + emailNotificationTypes: { + type: 'array', + nullable: true, optional: false, + items: { + type: 'string', + nullable: false, optional: false, + }, + }, + //#region secrets + email: { + type: 'string', + nullable: true, optional: true, + }, + emailVerified: { + type: 'boolean', + nullable: true, optional: true, + }, + securityKeysList: { + type: 'array', + nullable: false, optional: true, + items: { + type: 'object', + nullable: false, optional: false, + }, + }, + //#endregion + }, +} as const; + +export const packedUserDetailedNotMeSchema = { + type: 'object', + allOf: [ + { + type: 'object', + ref: 'UserLite', + }, + { + type: 'object', + ref: 'UserDetailedNotMeOnly', + }, + ], +} as const; + +export const packedMeDetailedSchema = { + type: 'object', + allOf: [ + { + type: 'object', + ref: 'UserLite', + }, + { + type: 'object', + ref: 'UserDetailedNotMeOnly', + }, + { + type: 'object', + ref: 'MeDetailedOnly', + }, + ], +} as const; + +export const packedUserDetailedSchema = { + oneOf: [ + { + type: 'object', + ref: 'UserDetailedNotMe', + }, + { + type: 'object', + ref: 'MeDetailed', + }, + ], +} as const; + +export const packedUserSchema = { + oneOf: [ + { + type: 'object', + ref: 'UserLite', + }, + { + type: 'object', + ref: 'UserDetailed', + }, + ], +} as const; diff --git a/packages/backend/src/models/util/id.ts b/packages/backend/src/models/util/id.ts deleted file mode 100644 index 81e83b8db9..0000000000 --- a/packages/backend/src/models/util/id.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const id = () => ({ - type: 'varchar' as const, - length: 32, -}); diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts deleted file mode 100644 index 18773a1b66..0000000000 --- a/packages/backend/src/postgres.ts +++ /dev/null @@ -1,247 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// https://github.com/typeorm/typeorm/issues/2400 -import pg from 'pg'; -pg.types.setTypeParser(20, Number); - -import { DataSource, Logger } from 'typeorm'; -import * as highlight from 'cli-highlight'; -import { entities as charts } from '@/core/chart/entities.js'; - -import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; -import { MiAccessToken } from '@/models/AccessToken.js'; -import { MiAd } from '@/models/Ad.js'; -import { MiAnnouncement } from '@/models/Announcement.js'; -import { MiAnnouncementRead } from '@/models/AnnouncementRead.js'; -import { MiAntenna } from '@/models/Antenna.js'; -import { MiApp } from '@/models/App.js'; -import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; -import { MiAuthSession } from '@/models/AuthSession.js'; -import { MiBlocking } from '@/models/Blocking.js'; -import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; -import { MiChannelFavorite } from '@/models/ChannelFavorite.js'; -import { MiClip } from '@/models/Clip.js'; -import { MiClipNote } from '@/models/ClipNote.js'; -import { MiClipFavorite } from '@/models/ClipFavorite.js'; -import { MiDriveFile } from '@/models/DriveFile.js'; -import { MiDriveFolder } from '@/models/DriveFolder.js'; -import { MiEmoji } from '@/models/Emoji.js'; -import { MiFollowing } from '@/models/Following.js'; -import { MiFollowRequest } from '@/models/FollowRequest.js'; -import { MiGalleryLike } from '@/models/GalleryLike.js'; -import { MiGalleryPost } from '@/models/GalleryPost.js'; -import { MiHashtag } from '@/models/Hashtag.js'; -import { MiInstance } from '@/models/Instance.js'; -import { MiMeta } from '@/models/Meta.js'; -import { MiModerationLog } from '@/models/ModerationLog.js'; -import { MiMuting } from '@/models/Muting.js'; -import { MiRenoteMuting } from '@/models/RenoteMuting.js'; -import { MiNote } from '@/models/Note.js'; -import { MiNoteFavorite } from '@/models/NoteFavorite.js'; -import { MiNoteReaction } from '@/models/NoteReaction.js'; -import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js'; -import { MiNoteUnread } from '@/models/NoteUnread.js'; -import { MiPage } from '@/models/Page.js'; -import { MiPageLike } from '@/models/PageLike.js'; -import { MiPasswordResetRequest } from '@/models/PasswordResetRequest.js'; -import { MiPoll } from '@/models/Poll.js'; -import { MiPollVote } from '@/models/PollVote.js'; -import { MiPromoNote } from '@/models/PromoNote.js'; -import { MiPromoRead } from '@/models/PromoRead.js'; -import { MiRegistrationTicket } from '@/models/RegistrationTicket.js'; -import { MiRegistryItem } from '@/models/RegistryItem.js'; -import { MiRelay } from '@/models/Relay.js'; -import { MiSignin } from '@/models/Signin.js'; -import { MiSwSubscription } from '@/models/SwSubscription.js'; -import { MiUsedUsername } from '@/models/UsedUsername.js'; -import { MiUser } from '@/models/User.js'; -import { MiUserIp } from '@/models/UserIp.js'; -import { MiUserKeypair } from '@/models/UserKeypair.js'; -import { MiUserList } from '@/models/UserList.js'; -import { MiUserListFavorite } from '@/models/UserListFavorite.js'; -import { MiUserListMembership } from '@/models/UserListMembership.js'; -import { MiUserNotePining } from '@/models/UserNotePining.js'; -import { MiUserPending } from '@/models/UserPending.js'; -import { MiUserProfile } from '@/models/UserProfile.js'; -import { MiUserPublickey } from '@/models/UserPublickey.js'; -import { MiUserSecurityKey } from '@/models/UserSecurityKey.js'; -import { MiWebhook } from '@/models/Webhook.js'; -import { MiChannel } from '@/models/Channel.js'; -import { MiRetentionAggregation } from '@/models/RetentionAggregation.js'; -import { MiRole } from '@/models/Role.js'; -import { MiRoleAssignment } from '@/models/RoleAssignment.js'; -import { MiFlash } from '@/models/Flash.js'; -import { MiFlashLike } from '@/models/FlashLike.js'; -import { MiUserMemo } from '@/models/UserMemo.js'; -import { NoteEdit } from '@/models/NoteEdit.js'; - -import { Config } from '@/config.js'; -import MisskeyLogger from '@/logger.js'; -import { bindThis } from '@/decorators.js'; - -export const dbLogger = new MisskeyLogger('db'); - -const sqlLogger = dbLogger.createSubLogger('sql', 'gray', false); - -class MyCustomLogger implements Logger { - @bindThis - private highlight(sql: string) { - return highlight.highlight(sql, { - language: 'sql', ignoreIllegals: true, - }); - } - - @bindThis - public logQuery(query: string, parameters?: any[]) { - sqlLogger.info(this.highlight(query).substring(0, 100)); - } - - @bindThis - public logQueryError(error: string, query: string, parameters?: any[]) { - sqlLogger.error(this.highlight(query)); - } - - @bindThis - public logQuerySlow(time: number, query: string, parameters?: any[]) { - sqlLogger.warn(this.highlight(query)); - } - - @bindThis - public logSchemaBuild(message: string) { - sqlLogger.info(message); - } - - @bindThis - public log(message: string) { - sqlLogger.info(message); - } - - @bindThis - public logMigration(message: string) { - sqlLogger.info(message); - } -} - -export const entities = [ - MiAnnouncement, - MiAnnouncementRead, - MiMeta, - MiInstance, - MiApp, - MiAvatarDecoration, - MiAuthSession, - MiAccessToken, - MiUser, - MiUserProfile, - MiUserKeypair, - MiUserPublickey, - MiUserList, - MiUserListFavorite, - MiUserListMembership, - MiUserNotePining, - MiUserSecurityKey, - MiUsedUsername, - MiFollowing, - MiFollowRequest, - MiMuting, - MiRenoteMuting, - MiBlocking, - MiNote, - MiNoteFavorite, - MiNoteReaction, - MiNoteThreadMuting, - MiNoteUnread, - MiPage, - MiPageLike, - MiGalleryPost, - MiGalleryLike, - MiDriveFile, - MiDriveFolder, - MiPoll, - MiPollVote, - MiEmoji, - MiHashtag, - MiSwSubscription, - MiAbuseUserReport, - MiRegistrationTicket, - MiSignin, - MiModerationLog, - MiClip, - MiClipNote, - MiClipFavorite, - MiAntenna, - MiPromoNote, - MiPromoRead, - MiRelay, - MiChannel, - MiChannelFollowing, - MiChannelFavorite, - MiRegistryItem, - MiAd, - MiPasswordResetRequest, - MiUserPending, - MiWebhook, - MiUserIp, - MiRetentionAggregation, - MiRole, - MiRoleAssignment, - MiFlash, - MiFlashLike, - MiUserMemo, - NoteEdit, - ...charts, -]; - -const log = process.env.NODE_ENV !== 'production'; - -export function createPostgresDataSource(config: Config) { - return new DataSource({ - type: 'postgres', - host: config.db.host, - port: config.db.port, - username: config.db.user, - password: config.db.pass, - database: config.db.db, - extra: { - statement_timeout: 1000 * 10, - ...config.db.extra, - }, - replication: config.dbReplications ? { - master: { - host: config.db.host, - port: config.db.port, - username: config.db.user, - password: config.db.pass, - database: config.db.db, - }, - slaves: config.dbSlaves!.map(rep => ({ - host: rep.host, - port: rep.port, - username: rep.user, - password: rep.pass, - database: rep.db, - })), - } : undefined, - synchronize: process.env.NODE_ENV === 'test', - dropSchema: process.env.NODE_ENV === 'test', - cache: !config.db.disableCache && process.env.NODE_ENV !== 'test' ? { // dbをcloseしても何故かredisのコネクションが内部的に残り続けるようで、テストの際に支障が出るため無効にする(キャッシュも含めてテストしたいため本当は有効にしたいが...) - type: 'ioredis', - options: { - host: config.redis.host, - port: config.redis.port, - family: config.redis.family ?? 0, - password: config.redis.pass, - keyPrefix: `${config.redis.prefix}:query:`, - db: config.redis.db ?? 0, - }, - } : false, - logging: log, - logger: log ? new MyCustomLogger() : undefined, - maxQueryExecutionTime: 300, - entities: entities, - migrations: ['../../migration/*.js'], - }); -} diff --git a/packages/backend/src/misc/prelude/README.md b/packages/backend/src/prelude/README.md similarity index 100% rename from packages/backend/src/misc/prelude/README.md rename to packages/backend/src/prelude/README.md diff --git a/packages/backend/src/prelude/array.ts b/packages/backend/src/prelude/array.ts new file mode 100644 index 0000000000..0b2830cb7b --- /dev/null +++ b/packages/backend/src/prelude/array.ts @@ -0,0 +1,138 @@ +import { EndoRelation, Predicate } from './relation.js'; + +/** + * Count the number of elements that satisfy the predicate + */ + +export function countIf(f: Predicate, xs: T[]): number { + return xs.filter(f).length; +} + +/** + * Count the number of elements that is equal to the element + */ +export function count(a: T, xs: T[]): number { + return countIf(x => x === a, xs); +} + +/** + * Concatenate an array of arrays + */ +export function concat(xss: T[][]): T[] { + return ([] as T[]).concat(...xss); +} + +/** + * Intersperse the element between the elements of the array + * @param sep The element to be interspersed + */ +export function intersperse(sep: T, xs: T[]): T[] { + return concat(xs.map(x => [sep, x])).slice(1); +} + +/** + * Returns the array of elements that is not equal to the element + */ +export function erase(a: T, xs: T[]): T[] { + return xs.filter(x => x !== a); +} + +/** + * Finds the array of all elements in the first array not contained in the second array. + * The order of result values are determined by the first array. + */ +export function difference(xs: T[], ys: T[]): T[] { + return xs.filter(x => !ys.includes(x)); +} + +/** + * Remove all but the first element from every group of equivalent elements + */ +export function unique(xs: T[]): T[] { + return [...new Set(xs)]; +} + +export function sum(xs: number[]): number { + return xs.reduce((a, b) => a + b, 0); +} + +export function maximum(xs: number[]): number { + return Math.max(...xs); +} + +/** + * Splits an array based on the equivalence relation. + * The concatenation of the result is equal to the argument. + */ +export function groupBy(f: EndoRelation, xs: T[]): T[][] { + const groups = [] as T[][]; + for (const x of xs) { + if (groups.length !== 0 && f(groups[groups.length - 1][0], x)) { + groups[groups.length - 1].push(x); + } else { + groups.push([x]); + } + } + return groups; +} + +/** + * Splits an array based on the equivalence relation induced by the function. + * The concatenation of the result is equal to the argument. + */ +export function groupOn(f: (x: T) => S, xs: T[]): T[][] { + return groupBy((a, b) => f(a) === f(b), xs); +} + +export function groupByX(collections: T[], keySelector: (x: T) => string) { + return collections.reduce((obj: Record, item: T) => { + const key = keySelector(item); + if (!Object.prototype.hasOwnProperty.call(obj, key)) { + obj[key] = []; + } + + obj[key].push(item); + + return obj; + }, {}); +} + +/** + * Compare two arrays by lexicographical order + */ +export function lessThan(xs: number[], ys: number[]): boolean { + for (let i = 0; i < Math.min(xs.length, ys.length); i++) { + if (xs[i] < ys[i]) return true; + if (xs[i] > ys[i]) return false; + } + return xs.length < ys.length; +} + +/** + * Returns the longest prefix of elements that satisfy the predicate + */ +export function takeWhile(f: Predicate, xs: T[]): T[] { + const ys = []; + for (const x of xs) { + if (f(x)) { + ys.push(x); + } else { + break; + } + } + return ys; +} + +export function cumulativeSum(xs: number[]): number[] { + const ys = Array.from(xs); // deep copy + for (let i = 1; i < ys.length; i++) ys[i] += ys[i - 1]; + return ys; +} + +export function toArray(x: T | T[] | undefined): T[] { + return Array.isArray(x) ? x : x != null ? [x] : []; +} + +export function toSingle(x: T | T[] | undefined): T | undefined { + return Array.isArray(x) ? x[0] : x; +} diff --git a/packages/backend/src/misc/prelude/await-all.ts b/packages/backend/src/prelude/await-all.ts similarity index 79% rename from packages/backend/src/misc/prelude/await-all.ts rename to packages/backend/src/prelude/await-all.ts index 6b8a91f8a5..b955c3a5d8 100644 --- a/packages/backend/src/misc/prelude/await-all.ts +++ b/packages/backend/src/prelude/await-all.ts @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - export type Promiseable = { [K in keyof T]: Promise | T[K]; }; @@ -15,7 +10,7 @@ export async function awaitAll(obj: Promiseable): Promise { const resolvedValues = await Promise.all(values.map(value => (!value || !value.constructor || value.constructor.name !== 'Object') ? value - : awaitAll(value), + : awaitAll(value) )); for (let i = 0; i < keys.length; i++) { diff --git a/packages/backend/src/prelude/math.ts b/packages/backend/src/prelude/math.ts new file mode 100644 index 0000000000..07b94bec30 --- /dev/null +++ b/packages/backend/src/prelude/math.ts @@ -0,0 +1,3 @@ +export function gcd(a: number, b: number): number { + return b === 0 ? a : gcd(b, a % b); +} diff --git a/packages/backend/src/prelude/maybe.ts b/packages/backend/src/prelude/maybe.ts new file mode 100644 index 0000000000..df7c4ed52a --- /dev/null +++ b/packages/backend/src/prelude/maybe.ts @@ -0,0 +1,20 @@ +export interface IMaybe { + isJust(): this is IJust; +} + +export interface IJust extends IMaybe { + get(): T; +} + +export function just(value: T): IJust { + return { + isJust: () => true, + get: () => value, + }; +} + +export function nothing(): IMaybe { + return { + isJust: () => false, + }; +} diff --git a/packages/backend/src/prelude/relation.ts b/packages/backend/src/prelude/relation.ts new file mode 100644 index 0000000000..1f4703f52f --- /dev/null +++ b/packages/backend/src/prelude/relation.ts @@ -0,0 +1,5 @@ +export type Predicate = (a: T) => boolean; + +export type Relation = (a: T, b: U) => boolean; + +export type EndoRelation = Relation; diff --git a/packages/backend/src/prelude/string.ts b/packages/backend/src/prelude/string.ts new file mode 100644 index 0000000000..b907e0a2e1 --- /dev/null +++ b/packages/backend/src/prelude/string.ts @@ -0,0 +1,15 @@ +export function concat(xs: string[]): string { + return xs.join(''); +} + +export function capitalize(s: string): string { + return toUpperCase(s.charAt(0)) + toLowerCase(s.slice(1)); +} + +export function toUpperCase(s: string): string { + return s.toUpperCase(); +} + +export function toLowerCase(s: string): string { + return s.toLowerCase(); +} diff --git a/packages/backend/src/prelude/symbol.ts b/packages/backend/src/prelude/symbol.ts new file mode 100644 index 0000000000..51e12f7450 --- /dev/null +++ b/packages/backend/src/prelude/symbol.ts @@ -0,0 +1 @@ +export const fallback = Symbol('fallback'); diff --git a/packages/backend/src/prelude/time.ts b/packages/backend/src/prelude/time.ts new file mode 100644 index 0000000000..34e8b6b17c --- /dev/null +++ b/packages/backend/src/prelude/time.ts @@ -0,0 +1,39 @@ +const dateTimeIntervals = { + 'day': 86400000, + 'hour': 3600000, + 'ms': 1, +}; + +export function dateUTC(time: number[]): Date { + const d = time.length === 2 ? Date.UTC(time[0], time[1]) + : time.length === 3 ? Date.UTC(time[0], time[1], time[2]) + : time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3]) + : time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4]) + : time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5]) + : time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6]) + : null; + + if (!d) throw 'wrong number of arguments'; + + return new Date(d); +} + +export function isTimeSame(a: Date, b: Date): boolean { + return a.getTime() === b.getTime(); +} + +export function isTimeBefore(a: Date, b: Date): boolean { + return (a.getTime() - b.getTime()) < 0; +} + +export function isTimeAfter(a: Date, b: Date): boolean { + return (a.getTime() - b.getTime()) > 0; +} + +export function addTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date { + return new Date(x.getTime() + (value * dateTimeIntervals[span])); +} + +export function subtractTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date { + return new Date(x.getTime() - (value * dateTimeIntervals[span])); +} diff --git a/packages/backend/src/prelude/url.ts b/packages/backend/src/prelude/url.ts new file mode 100644 index 0000000000..a4f2f7f5a8 --- /dev/null +++ b/packages/backend/src/prelude/url.ts @@ -0,0 +1,13 @@ +export function query(obj: Record): string { + const params = Object.entries(obj) + .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) + .reduce((a, [k, v]) => (a[k] = v, a), {} as Record); + + return Object.entries(params) + .map((e) => `${e[0]}=${encodeURIComponent(e[1])}`) + .join('&'); +} + +export function appendQuery(url: string, query: string): string { + return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`; +} diff --git a/packages/backend/src/misc/prelude/xml.ts b/packages/backend/src/prelude/xml.ts similarity index 88% rename from packages/backend/src/misc/prelude/xml.ts rename to packages/backend/src/prelude/xml.ts index bca116a7ec..b4469a1d8d 100644 --- a/packages/backend/src/misc/prelude/xml.ts +++ b/packages/backend/src/prelude/xml.ts @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - const map: Record = { '&': '&', '<': '<', diff --git a/packages/backend/src/queue/QueueLoggerService.ts b/packages/backend/src/queue/QueueLoggerService.ts deleted file mode 100644 index 618d1d5c2f..0000000000 --- a/packages/backend/src/queue/QueueLoggerService.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import type Logger from '@/logger.js'; -import { LoggerService } from '@/core/LoggerService.js'; - -@Injectable() -export class QueueLoggerService { - public logger: Logger; - - constructor( - private loggerService: LoggerService, - ) { - this.logger = this.loggerService.getLogger('queue', 'orange'); - } -} diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts deleted file mode 100644 index 29dc78605b..0000000000 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Module } from '@nestjs/common'; -import { CoreModule } from '@/core/CoreModule.js'; -import { GlobalModule } from '@/GlobalModule.js'; -import { QueueLoggerService } from './QueueLoggerService.js'; -import { QueueProcessorService } from './QueueProcessorService.js'; -import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; -import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; -import { InboxProcessorService } from './processors/InboxProcessorService.js'; -import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js'; -import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; -import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; -import { CleanProcessorService } from './processors/CleanProcessorService.js'; -import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js'; -import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js'; -import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js'; -import { DeleteFileProcessorService } from './processors/DeleteFileProcessorService.js'; -import { ExportAccountDataProcessorService } from './processors/ExportAccountDataProcessorService.js'; -import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js'; -import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmojisProcessorService.js'; -import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js'; -import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js'; -import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js'; -import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js'; -import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js'; -import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js'; -import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js'; -import { ImportNotesProcessorService } from './processors/ImportNotesProcessorService.js'; -import { ImportFollowingProcessorService } from './processors/ImportFollowingProcessorService.js'; -import { ImportMutingProcessorService } from './processors/ImportMutingProcessorService.js'; -import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js'; -import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js'; -import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; -import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; -import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; -import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; -import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js'; - -@Module({ - imports: [ - GlobalModule, - CoreModule, - ], - providers: [ - QueueLoggerService, - TickChartsProcessorService, - ResyncChartsProcessorService, - CleanChartsProcessorService, - CheckExpiredMutingsProcessorService, - CleanProcessorService, - DeleteDriveFilesProcessorService, - ExportAccountDataProcessorService, - ExportCustomEmojisProcessorService, - ExportNotesProcessorService, - ExportFavoritesProcessorService, - ExportFollowingProcessorService, - ExportMutingProcessorService, - ExportBlockingProcessorService, - ExportUserListsProcessorService, - ExportAntennasProcessorService, - ImportNotesProcessorService, - ImportFollowingProcessorService, - ImportMutingProcessorService, - ImportBlockingProcessorService, - ImportUserListsProcessorService, - ImportCustomEmojisProcessorService, - ImportAntennasProcessorService, - DeleteAccountProcessorService, - DeleteFileProcessorService, - CleanRemoteFilesProcessorService, - RelationshipProcessorService, - WebhookDeliverProcessorService, - EndedPollNotificationProcessorService, - DeliverProcessorService, - InboxProcessorService, - AggregateRetentionProcessorService, - QueueProcessorService, - ], - exports: [ - QueueProcessorService, - ], -}) -export class QueueProcessorModule {} diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts deleted file mode 100644 index ea3ecd4ded..0000000000 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ /dev/null @@ -1,375 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import * as Bull from 'bullmq'; -import type { Config } from '@/config.js'; -import { DI } from '@/di-symbols.js'; -import type Logger from '@/logger.js'; -import { bindThis } from '@/decorators.js'; -import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js'; -import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; -import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; -import { InboxProcessorService } from './processors/InboxProcessorService.js'; -import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js'; -import { ExportAccountDataProcessorService } from './processors/ExportAccountDataProcessorService.js'; -import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmojisProcessorService.js'; -import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js'; -import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js'; -import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js'; -import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js'; -import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js'; -import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js'; -import { ImportFollowingProcessorService } from './processors/ImportFollowingProcessorService.js'; -import { ImportMutingProcessorService } from './processors/ImportMutingProcessorService.js'; -import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js'; -import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js'; -import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js'; -import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js'; -import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js'; -import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; -import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js'; -import { DeleteFileProcessorService } from './processors/DeleteFileProcessorService.js'; -import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js'; -import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; -import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; -import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; -import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; -import { CleanProcessorService } from './processors/CleanProcessorService.js'; -import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; -import { QueueLoggerService } from './QueueLoggerService.js'; -import { QUEUE, baseQueueOptions } from './const.js'; -import { ImportNotesProcessorService } from './processors/ImportNotesProcessorService.js'; - -// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019 -function httpRelatedBackoff(attemptsMade: number) { - const baseDelay = 60 * 1000; // 1min - const maxBackoff = 8 * 60 * 60 * 1000; // 8hours - let backoff = (Math.pow(2, attemptsMade) - 1) * baseDelay; - backoff = Math.min(backoff, maxBackoff); - backoff += Math.round(backoff * Math.random() * 0.2); - return backoff; -} - -function getJobInfo(job: Bull.Job | undefined, increment = false): string { - if (job == null) return '-'; - - const age = Date.now() - job.timestamp; - - const formated = age > 60000 ? `${Math.floor(age / 1000 / 60)}m` - : age > 10000 ? `${Math.floor(age / 1000)}s` - : `${age}ms`; - - // onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする - const currentAttempts = job.attemptsMade + (increment ? 1 : 0); - const maxAttempts = job.opts ? job.opts.attempts : 0; - - return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`; -} - -@Injectable() -export class QueueProcessorService implements OnApplicationShutdown { - private logger: Logger; - private systemQueueWorker: Bull.Worker; - private dbQueueWorker: Bull.Worker; - private deliverQueueWorker: Bull.Worker; - private inboxQueueWorker: Bull.Worker; - private webhookDeliverQueueWorker: Bull.Worker; - private relationshipQueueWorker: Bull.Worker; - private objectStorageQueueWorker: Bull.Worker; - private endedPollNotificationQueueWorker: Bull.Worker; - - constructor( - @Inject(DI.config) - private config: Config, - - private queueLoggerService: QueueLoggerService, - private webhookDeliverProcessorService: WebhookDeliverProcessorService, - private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, - private deliverProcessorService: DeliverProcessorService, - private inboxProcessorService: InboxProcessorService, - private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService, - private exportAccountDataProcessorService: ExportAccountDataProcessorService, - private exportCustomEmojisProcessorService: ExportCustomEmojisProcessorService, - private exportNotesProcessorService: ExportNotesProcessorService, - private exportFavoritesProcessorService: ExportFavoritesProcessorService, - private exportFollowingProcessorService: ExportFollowingProcessorService, - private exportMutingProcessorService: ExportMutingProcessorService, - private exportBlockingProcessorService: ExportBlockingProcessorService, - private exportUserListsProcessorService: ExportUserListsProcessorService, - private exportAntennasProcessorService: ExportAntennasProcessorService, - private importFollowingProcessorService: ImportFollowingProcessorService, - private importNotesProcessorService: ImportNotesProcessorService, - private importMutingProcessorService: ImportMutingProcessorService, - private importBlockingProcessorService: ImportBlockingProcessorService, - private importUserListsProcessorService: ImportUserListsProcessorService, - private importCustomEmojisProcessorService: ImportCustomEmojisProcessorService, - private importAntennasProcessorService: ImportAntennasProcessorService, - private deleteAccountProcessorService: DeleteAccountProcessorService, - private deleteFileProcessorService: DeleteFileProcessorService, - private cleanRemoteFilesProcessorService: CleanRemoteFilesProcessorService, - private relationshipProcessorService: RelationshipProcessorService, - private tickChartsProcessorService: TickChartsProcessorService, - private resyncChartsProcessorService: ResyncChartsProcessorService, - private cleanChartsProcessorService: CleanChartsProcessorService, - private aggregateRetentionProcessorService: AggregateRetentionProcessorService, - private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, - private cleanProcessorService: CleanProcessorService, - ) { - this.logger = this.queueLoggerService.logger; - - function renderError(e: Error): any { - if (e) { // 何故かeがundefinedで来ることがある - return { - stack: e.stack, - message: e.message, - name: e.name, - }; - } else { - return { - stack: '?', - message: '?', - name: '?', - }; - } - } - - //#region system - this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => { - switch (job.name) { - case 'tickCharts': return this.tickChartsProcessorService.process(); - case 'resyncCharts': return this.resyncChartsProcessorService.process(); - case 'cleanCharts': return this.cleanChartsProcessorService.process(); - case 'aggregateRetention': return this.aggregateRetentionProcessorService.process(); - case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process(); - case 'clean': return this.cleanProcessorService.process(); - default: throw new Error(`unrecognized job type ${job.name} for system`); - } - }, { - ...baseQueueOptions(this.config, QUEUE.SYSTEM), - autorun: false, - }); - - const systemLogger = this.logger.createSubLogger('system'); - - this.systemQueueWorker - .on('active', (job) => systemLogger.debug(`active id=${job.id}`)) - .on('completed', (job, result) => systemLogger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => systemLogger.warn(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) })) - .on('error', (err: Error) => systemLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => systemLogger.warn(`stalled id=${jobId}`)); - //#endregion - - //#region db - this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => { - switch (job.name) { - case 'deleteDriveFiles': return this.deleteDriveFilesProcessorService.process(job); - case 'exportAccountData': return this.exportAccountDataProcessorService.process(job); - case 'exportCustomEmojis': return this.exportCustomEmojisProcessorService.process(job); - case 'exportNotes': return this.exportNotesProcessorService.process(job); - case 'exportFavorites': return this.exportFavoritesProcessorService.process(job); - case 'exportFollowing': return this.exportFollowingProcessorService.process(job); - case 'exportMuting': return this.exportMutingProcessorService.process(job); - case 'exportBlocking': return this.exportBlockingProcessorService.process(job); - case 'exportUserLists': return this.exportUserListsProcessorService.process(job); - case 'exportAntennas': return this.exportAntennasProcessorService.process(job); - case 'importFollowing': return this.importFollowingProcessorService.process(job); - case 'importNotes': return this.importNotesProcessorService.process(job); - case 'importTweetsToDb': return this.importNotesProcessorService.processTwitterDb(job); - case 'importIGToDb': return this.importNotesProcessorService.processIGDb(job); - case 'importFBToDb': return this.importNotesProcessorService.processFBDb(job); - case 'importMastoToDb': return this.importNotesProcessorService.processMastoToDb(job); - case 'importPleroToDb': return this.importNotesProcessorService.processPleroToDb(job); - case 'importKeyNotesToDb': return this.importNotesProcessorService.processKeyNotesToDb(job); - case 'importFollowingToDb': return this.importFollowingProcessorService.processDb(job); - case 'importMuting': return this.importMutingProcessorService.process(job); - case 'importBlocking': return this.importBlockingProcessorService.process(job); - case 'importBlockingToDb': return this.importBlockingProcessorService.processDb(job); - case 'importUserLists': return this.importUserListsProcessorService.process(job); - case 'importCustomEmojis': return this.importCustomEmojisProcessorService.process(job); - case 'importAntennas': return this.importAntennasProcessorService.process(job); - case 'deleteAccount': return this.deleteAccountProcessorService.process(job); - default: throw new Error(`unrecognized job type ${job.name} for db`); - } - }, { - ...baseQueueOptions(this.config, QUEUE.DB), - autorun: false, - }); - - const dbLogger = this.logger.createSubLogger('db'); - - this.dbQueueWorker - .on('active', (job) => dbLogger.debug(`active id=${job.id}`)) - .on('completed', (job, result) => dbLogger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => dbLogger.warn(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) })) - .on('error', (err: Error) => dbLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => dbLogger.warn(`stalled id=${jobId}`)); - //#endregion - - //#region deliver - this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => this.deliverProcessorService.process(job), { - ...baseQueueOptions(this.config, QUEUE.DELIVER), - autorun: false, - concurrency: this.config.deliverJobConcurrency ?? 128, - limiter: { - max: this.config.deliverJobPerSec ?? 128, - duration: 1000, - }, - settings: { - backoffStrategy: httpRelatedBackoff, - }, - }); - - const deliverLogger = this.logger.createSubLogger('deliver'); - - this.deliverQueueWorker - .on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('completed', (job, result) => deliverLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('failed', (job, err) => deliverLogger.warn(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`)) - .on('error', (err: Error) => deliverLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => deliverLogger.warn(`stalled id=${jobId}`)); - //#endregion - - //#region inbox - this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => this.inboxProcessorService.process(job), { - ...baseQueueOptions(this.config, QUEUE.INBOX), - autorun: false, - concurrency: this.config.inboxJobConcurrency ?? 16, - limiter: { - max: this.config.inboxJobPerSec ?? 32, - duration: 1000, - }, - settings: { - backoffStrategy: httpRelatedBackoff, - }, - }); - - const inboxLogger = this.logger.createSubLogger('inbox'); - - this.inboxQueueWorker - .on('active', (job) => inboxLogger.debug(`active ${getJobInfo(job, true)}`)) - .on('completed', (job, result) => inboxLogger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) - .on('failed', (job, err) => inboxLogger.warn(`failed(${err.stack}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job, e: renderError(err) })) - .on('error', (err: Error) => inboxLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => inboxLogger.warn(`stalled id=${jobId}`)); - //#endregion - - //#region webhook deliver - this.webhookDeliverQueueWorker = new Bull.Worker(QUEUE.WEBHOOK_DELIVER, (job) => this.webhookDeliverProcessorService.process(job), { - ...baseQueueOptions(this.config, QUEUE.WEBHOOK_DELIVER), - autorun: false, - concurrency: 64, - limiter: { - max: 64, - duration: 1000, - }, - settings: { - backoffStrategy: httpRelatedBackoff, - }, - }); - - const webhookLogger = this.logger.createSubLogger('webhook'); - - this.webhookDeliverQueueWorker - .on('active', (job) => webhookLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('completed', (job, result) => webhookLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('failed', (job, err) => webhookLogger.warn(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`)) - .on('error', (err: Error) => webhookLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => webhookLogger.warn(`stalled id=${jobId}`)); - //#endregion - - //#region relationship - this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => { - switch (job.name) { - case 'follow': return this.relationshipProcessorService.processFollow(job); - case 'unfollow': return this.relationshipProcessorService.processUnfollow(job); - case 'block': return this.relationshipProcessorService.processBlock(job); - case 'unblock': return this.relationshipProcessorService.processUnblock(job); - default: throw new Error(`unrecognized job type ${job.name} for relationship`); - } - }, { - ...baseQueueOptions(this.config, QUEUE.RELATIONSHIP), - autorun: false, - concurrency: this.config.relashionshipJobConcurrency ?? 16, - limiter: { - max: this.config.relashionshipJobPerSec ?? 64, - duration: 1000, - }, - }); - - const relationshipLogger = this.logger.createSubLogger('relationship'); - - this.relationshipQueueWorker - .on('active', (job) => relationshipLogger.debug(`active id=${job.id}`)) - .on('completed', (job, result) => relationshipLogger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => relationshipLogger.warn(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) })) - .on('error', (err: Error) => relationshipLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => relationshipLogger.warn(`stalled id=${jobId}`)); - //#endregion - - //#region object storage - this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => { - switch (job.name) { - case 'deleteFile': return this.deleteFileProcessorService.process(job); - case 'cleanRemoteFiles': return this.cleanRemoteFilesProcessorService.process(job); - default: throw new Error(`unrecognized job type ${job.name} for objectStorage`); - } - }, { - ...baseQueueOptions(this.config, QUEUE.OBJECT_STORAGE), - autorun: false, - concurrency: 16, - }); - - const objectStorageLogger = this.logger.createSubLogger('objectStorage'); - - this.objectStorageQueueWorker - .on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`)) - .on('completed', (job, result) => objectStorageLogger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => objectStorageLogger.warn(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) })) - .on('error', (err: Error) => objectStorageLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => objectStorageLogger.warn(`stalled id=${jobId}`)); - //#endregion - - //#region ended poll notification - this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => this.endedPollNotificationProcessorService.process(job), { - ...baseQueueOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION), - autorun: false, - }); - //#endregion - } - - @bindThis - public async start(): Promise { - await Promise.all([ - this.systemQueueWorker.run(), - this.dbQueueWorker.run(), - this.deliverQueueWorker.run(), - this.inboxQueueWorker.run(), - this.webhookDeliverQueueWorker.run(), - this.relationshipQueueWorker.run(), - this.objectStorageQueueWorker.run(), - this.endedPollNotificationQueueWorker.run(), - ]); - } - - @bindThis - public async stop(): Promise { - await Promise.all([ - this.systemQueueWorker.close(), - this.dbQueueWorker.close(), - this.deliverQueueWorker.close(), - this.inboxQueueWorker.close(), - this.webhookDeliverQueueWorker.close(), - this.relationshipQueueWorker.close(), - this.objectStorageQueueWorker.close(), - this.endedPollNotificationQueueWorker.close(), - ]); - } - - @bindThis - public async onApplicationShutdown(signal?: string | undefined): Promise { - await this.stop(); - } -} diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts deleted file mode 100644 index 87d075304d..0000000000 --- a/packages/backend/src/queue/const.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Config } from '@/config.js'; -import type * as Bull from 'bullmq'; - -export const QUEUE = { - DELIVER: 'deliver', - INBOX: 'inbox', - SYSTEM: 'system', - ENDED_POLL_NOTIFICATION: 'endedPollNotification', - DB: 'db', - RELATIONSHIP: 'relationship', - OBJECT_STORAGE: 'objectStorage', - WEBHOOK_DELIVER: 'webhookDeliver', -}; - -export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.QueueOptions { - return { - connection: { - ...config.redisForJobQueue, - keyPrefix: undefined, - }, - prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue:${queueName}` : `queue:${queueName}`, - }; -} diff --git a/packages/backend/src/queue/get-job-info.ts b/packages/backend/src/queue/get-job-info.ts new file mode 100644 index 0000000000..d33e349c36 --- /dev/null +++ b/packages/backend/src/queue/get-job-info.ts @@ -0,0 +1,15 @@ +import Bull from 'bull'; + +export function getJobInfo(job: Bull.Job, increment = false) { + const age = Date.now() - job.timestamp; + + const formated = age > 60000 ? `${Math.floor(age / 1000 / 60)}m` + : age > 10000 ? `${Math.floor(age / 1000)}s` + : `${age}ms`; + + // onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする + const currentAttempts = job.attemptsMade + (increment ? 1 : 0); + const maxAttempts = job.opts ? job.opts.attempts : 0; + + return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`; +} diff --git a/packages/backend/src/queue/index.ts b/packages/backend/src/queue/index.ts new file mode 100644 index 0000000000..ebb3a77cab --- /dev/null +++ b/packages/backend/src/queue/index.ts @@ -0,0 +1,342 @@ +import httpSignature from '@peertube/http-signature'; +import { v4 as uuid } from 'uuid'; + +import config from '@/config/index.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { IActivity } from '@/remote/activitypub/type.js'; +import { Webhook, webhookEventTypes } from '@/models/entities/webhook.js'; +import { envOption } from '../env.js'; + +import processDeliver from './processors/deliver.js'; +import processInbox from './processors/inbox.js'; +import processDb from './processors/db/index.js'; +import processObjectStorage from './processors/object-storage/index.js'; +import processSystemQueue from './processors/system/index.js'; +import processWebhookDeliver from './processors/webhook-deliver.js'; +import { endedPollNotification } from './processors/ended-poll-notification.js'; +import { queueLogger } from './logger.js'; +import { getJobInfo } from './get-job-info.js'; +import { systemQueue, dbQueue, deliverQueue, inboxQueue, objectStorageQueue, endedPollNotificationQueue, webhookDeliverQueue } from './queues.js'; +import { ThinUser } from './types.js'; + +function renderError(e: Error): any { + return { + stack: e.stack, + message: e.message, + name: e.name, + }; +} + +const systemLogger = queueLogger.createSubLogger('system'); +const deliverLogger = queueLogger.createSubLogger('deliver'); +const webhookLogger = queueLogger.createSubLogger('webhook'); +const inboxLogger = queueLogger.createSubLogger('inbox'); +const dbLogger = queueLogger.createSubLogger('db'); +const objectStorageLogger = queueLogger.createSubLogger('objectStorage'); + +systemQueue + .on('waiting', (jobId) => systemLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => systemLogger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => systemLogger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => systemLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) + .on('error', (job: any, err: Error) => systemLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => systemLogger.warn(`stalled id=${job.id}`)); + +deliverQueue + .on('waiting', (jobId) => deliverLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('completed', (job, result) => deliverLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('failed', (job, err) => deliverLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`)) + .on('error', (job: any, err: Error) => deliverLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => deliverLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`)); + +inboxQueue + .on('waiting', (jobId) => inboxLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => inboxLogger.debug(`active ${getJobInfo(job, true)}`)) + .on('completed', (job, result) => inboxLogger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) + .on('failed', (job, err) => inboxLogger.warn(`failed(${err}) ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`, { job, e: renderError(err) })) + .on('error', (job: any, err: Error) => inboxLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => inboxLogger.warn(`stalled ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`)); + +dbQueue + .on('waiting', (jobId) => dbLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => dbLogger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => dbLogger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => dbLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) + .on('error', (job: any, err: Error) => dbLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => dbLogger.warn(`stalled id=${job.id}`)); + +objectStorageQueue + .on('waiting', (jobId) => objectStorageLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => objectStorageLogger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => objectStorageLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) + .on('error', (job: any, err: Error) => objectStorageLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => objectStorageLogger.warn(`stalled id=${job.id}`)); + +webhookDeliverQueue + .on('waiting', (jobId) => webhookLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => webhookLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('completed', (job, result) => webhookLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('failed', (job, err) => webhookLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`)) + .on('error', (job: any, err: Error) => webhookLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => webhookLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`)); + +export function deliver(user: ThinUser, content: unknown, to: string | null) { + if (content == null) return null; + if (to == null) return null; + + const data = { + user: { + id: user.id, + }, + content, + to, + }; + + return deliverQueue.add(data, { + attempts: config.deliverJobMaxAttempts || 12, + timeout: 1 * 60 * 1000, // 1min + backoff: { + type: 'apBackoff', + }, + removeOnComplete: true, + removeOnFail: true, + }); +} + +export function inbox(activity: IActivity, signature: httpSignature.IParsedSignature) { + const data = { + activity: activity, + signature, + }; + + return inboxQueue.add(data, { + attempts: config.inboxJobMaxAttempts || 8, + timeout: 5 * 60 * 1000, // 5min + backoff: { + type: 'apBackoff', + }, + removeOnComplete: true, + removeOnFail: true, + }); +} + +export function createDeleteDriveFilesJob(user: ThinUser) { + return dbQueue.add('deleteDriveFiles', { + user: user, + }, { + removeOnComplete: true, + removeOnFail: true, + }); +} + +export function createExportCustomEmojisJob(user: ThinUser) { + return dbQueue.add('exportCustomEmojis', { + user: user, + }, { + removeOnComplete: true, + removeOnFail: true, + }); +} + +export function createExportNotesJob(user: ThinUser) { + return dbQueue.add('exportNotes', { + user: user, + }, { + removeOnComplete: true, + removeOnFail: true, + }); +} + +export function createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) { + return dbQueue.add('exportFollowing', { + user: user, + excludeMuting, + excludeInactive, + }, { + removeOnComplete: true, + removeOnFail: true, + }); +} + +export function createExportMuteJob(user: ThinUser) { + return dbQueue.add('exportMute', { + user: user, + }, { + removeOnComplete: true, + removeOnFail: true, + }); +} + +export function createExportBlockingJob(user: ThinUser) { + return dbQueue.add('exportBlocking', { + user: user, + }, { + removeOnComplete: true, + removeOnFail: true, + }); +} + +export function createExportUserListsJob(user: ThinUser) { + return dbQueue.add('exportUserLists', { + user: user, + }, { + removeOnComplete: true, + removeOnFail: true, + }); +} + +export function createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) { + return dbQueue.add('importFollowing', { + user: user, + fileId: fileId, + }, { + removeOnComplete: true, + removeOnFail: true, + }); +} + +export function createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) { + return dbQueue.add('importMuting', { + user: user, + fileId: fileId, + }, { + removeOnComplete: true, + removeOnFail: true, + }); +} + +export function createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) { + return dbQueue.add('importBlocking', { + user: user, + fileId: fileId, + }, { + removeOnComplete: true, + removeOnFail: true, + }); +} + +export function createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) { + return dbQueue.add('importUserLists', { + user: user, + fileId: fileId, + }, { + removeOnComplete: true, + removeOnFail: true, + }); +} + +export function createImportCustomEmojisJob(user: ThinUser, fileId: DriveFile['id']) { + return dbQueue.add('importCustomEmojis', { + user: user, + fileId: fileId, + }, { + removeOnComplete: true, + removeOnFail: true, + }); +} + +export function createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) { + return dbQueue.add('deleteAccount', { + user: user, + soft: opts.soft, + }, { + removeOnComplete: true, + removeOnFail: true, + }); +} + +export function createDeleteObjectStorageFileJob(key: string) { + return objectStorageQueue.add('deleteFile', { + key: key, + }, { + removeOnComplete: true, + removeOnFail: true, + }); +} + +export function createCleanRemoteFilesJob() { + return objectStorageQueue.add('cleanRemoteFiles', {}, { + removeOnComplete: true, + removeOnFail: true, + }); +} + +export function webhookDeliver(webhook: Webhook, type: typeof webhookEventTypes[number], content: unknown) { + const data = { + type, + content, + webhookId: webhook.id, + userId: webhook.userId, + to: webhook.url, + secret: webhook.secret, + createdAt: Date.now(), + eventId: uuid(), + }; + + return webhookDeliverQueue.add(data, { + attempts: 4, + timeout: 1 * 60 * 1000, // 1min + backoff: { + type: 'apBackoff', + }, + removeOnComplete: true, + removeOnFail: true, + }); +} + +export default function() { + if (envOption.onlyServer) return; + + deliverQueue.process(config.deliverJobConcurrency || 128, processDeliver); + inboxQueue.process(config.inboxJobConcurrency || 16, processInbox); + endedPollNotificationQueue.process(endedPollNotification); + webhookDeliverQueue.process(64, processWebhookDeliver); + processDb(dbQueue); + processObjectStorage(objectStorageQueue); + + systemQueue.add('tickCharts', { + }, { + repeat: { cron: '55 * * * *' }, + removeOnComplete: true, + }); + + systemQueue.add('resyncCharts', { + }, { + repeat: { cron: '0 0 * * *' }, + removeOnComplete: true, + }); + + systemQueue.add('cleanCharts', { + }, { + repeat: { cron: '0 0 * * *' }, + removeOnComplete: true, + }); + + systemQueue.add('clean', { + }, { + repeat: { cron: '0 0 * * *' }, + removeOnComplete: true, + }); + + systemQueue.add('checkExpiredMutings', { + }, { + repeat: { cron: '*/5 * * * *' }, + removeOnComplete: true, + }); + + processSystemQueue(systemQueue); +} + +export function destroy() { + deliverQueue.once('cleaned', (jobs, status) => { + deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); + }); + deliverQueue.clean(0, 'delayed'); + + inboxQueue.once('cleaned', (jobs, status) => { + inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); + }); + inboxQueue.clean(0, 'delayed'); +} diff --git a/packages/backend/src/queue/initialize.ts b/packages/backend/src/queue/initialize.ts new file mode 100644 index 0000000000..eef4080af3 --- /dev/null +++ b/packages/backend/src/queue/initialize.ts @@ -0,0 +1,34 @@ +import Bull from 'bull'; +import config from '@/config/index.js'; + +export function initialize(name: string, limitPerSec = -1) { + return new Bull(name, { + redis: { + port: config.redis.port, + host: config.redis.host, + family: config.redis.family == null ? 0 : config.redis.family, + password: config.redis.pass, + db: config.redis.db || 0, + }, + prefix: config.redis.prefix ? `${config.redis.prefix}:queue` : 'queue', + limiter: limitPerSec > 0 ? { + max: limitPerSec, + duration: 1000, + } : undefined, + settings: { + backoffStrategies: { + apBackoff, + }, + }, + }); +} + +// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019 +function apBackoff(attemptsMade: number, err: Error) { + const baseDelay = 60 * 1000; // 1min + const maxBackoff = 8 * 60 * 60 * 1000; // 8hours + let backoff = (Math.pow(2, attemptsMade) - 1) * baseDelay; + backoff = Math.min(backoff, maxBackoff); + backoff += Math.round(backoff * Math.random() * 0.2); + return backoff; +} diff --git a/packages/backend/src/queue/logger.ts b/packages/backend/src/queue/logger.ts new file mode 100644 index 0000000000..2843a3c263 --- /dev/null +++ b/packages/backend/src/queue/logger.ts @@ -0,0 +1,3 @@ +import Logger from '@/services/logger.js'; + +export const queueLogger = new Logger('queue', 'orange'); diff --git a/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts b/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts deleted file mode 100644 index 9f49d85c7f..0000000000 --- a/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, MoreThan } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type Logger from '@/logger.js'; -import { bindThis } from '@/decorators.js'; -import type { RetentionAggregationsRepository, UsersRepository } from '@/models/_.js'; -import { deepClone } from '@/misc/clone.js'; -import { IdService } from '@/core/IdService.js'; -import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; - -@Injectable() -export class AggregateRetentionProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.retentionAggregationsRepository) - private retentionAggregationsRepository: RetentionAggregationsRepository, - - private idService: IdService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('aggregate-retention'); - } - - @bindThis - public async process(): Promise { - this.logger.info('Aggregating retention...'); - - const now = new Date(); - const dateKey = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`; - - // 過去(だいたい)30日分のレコードを取得 - const pastRecords = await this.retentionAggregationsRepository.findBy({ - createdAt: MoreThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 31))), - }); - - // 今日登録したユーザーを全て取得 - const targetUsers = await this.usersRepository.findBy({ - host: IsNull(), - id: MoreThan(this.idService.gen(Date.now() - (1000 * 60 * 60 * 24))), - }); - const targetUserIds = targetUsers.map(u => u.id); - - try { - await this.retentionAggregationsRepository.insert({ - id: this.idService.gen(), - createdAt: now, - updatedAt: now, - dateKey, - userIds: targetUserIds, - usersCount: targetUserIds.length, - }); - } catch (err) { - if (isDuplicateKeyValueError(err)) { - this.logger.succ('Skip because it has already been processed by another worker.'); - return; - } - throw err; - } - - // 今日活動したユーザーを全て取得 - const activeUsers = await this.usersRepository.findBy({ - host: IsNull(), - lastActiveDate: MoreThan(new Date(Date.now() - (1000 * 60 * 60 * 24))), - }); - const activeUsersIds = activeUsers.map(u => u.id); - - for (const record of pastRecords) { - const retention = record.userIds.filter(id => activeUsersIds.includes(id)).length; - - const data = deepClone(record.data); - data[dateKey] = retention; - - this.retentionAggregationsRepository.update(record.id, { - updatedAt: now, - data, - }); - } - - this.logger.succ('Retention aggregated.'); - } -} diff --git a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts deleted file mode 100644 index 9b07389dc3..0000000000 --- a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { MutingsRepository } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import { bindThis } from '@/decorators.js'; -import { UserMutingService } from '@/core/UserMutingService.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; - -@Injectable() -export class CheckExpiredMutingsProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - private userMutingService: UserMutingService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('check-expired-mutings'); - } - - @bindThis - public async process(): Promise { - this.logger.info('Checking expired mutings...'); - - const expired = await this.mutingsRepository.createQueryBuilder('muting') - .where('muting.expiresAt IS NOT NULL') - .andWhere('muting.expiresAt < :now', { now: new Date() }) - .innerJoinAndSelect('muting.mutee', 'mutee') - .getMany(); - - if (expired.length > 0) { - await this.userMutingService.unmute(expired); - } - - this.logger.succ('All expired mutings checked.'); - } -} diff --git a/packages/backend/src/queue/processors/CleanChartsProcessorService.ts b/packages/backend/src/queue/processors/CleanChartsProcessorService.ts deleted file mode 100644 index 55c444eee6..0000000000 --- a/packages/backend/src/queue/processors/CleanChartsProcessorService.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import type Logger from '@/logger.js'; -import FederationChart from '@/core/chart/charts/federation.js'; -import NotesChart from '@/core/chart/charts/notes.js'; -import UsersChart from '@/core/chart/charts/users.js'; -import ActiveUsersChart from '@/core/chart/charts/active-users.js'; -import InstanceChart from '@/core/chart/charts/instance.js'; -import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; -import PerUserPvChart from '@/core/chart/charts/per-user-pv.js'; -import DriveChart from '@/core/chart/charts/drive.js'; -import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js'; -import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; -import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js'; -import ApRequestChart from '@/core/chart/charts/ap-request.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; - -@Injectable() -export class CleanChartsProcessorService { - private logger: Logger; - - constructor( - private federationChart: FederationChart, - private notesChart: NotesChart, - private usersChart: UsersChart, - private activeUsersChart: ActiveUsersChart, - private instanceChart: InstanceChart, - private perUserNotesChart: PerUserNotesChart, - private perUserPvChart: PerUserPvChart, - private driveChart: DriveChart, - private perUserReactionsChart: PerUserReactionsChart, - private perUserFollowingChart: PerUserFollowingChart, - private perUserDriveChart: PerUserDriveChart, - private apRequestChart: ApRequestChart, - - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('clean-charts'); - } - - @bindThis - public async process(): Promise { - this.logger.info('Clean charts...'); - - await Promise.all([ - this.federationChart.clean(), - this.notesChart.clean(), - this.usersChart.clean(), - this.activeUsersChart.clean(), - this.instanceChart.clean(), - this.perUserNotesChart.clean(), - this.perUserPvChart.clean(), - this.driveChart.clean(), - this.perUserReactionsChart.clean(), - this.perUserFollowingChart.clean(), - this.perUserDriveChart.clean(), - this.apRequestChart.clean(), - ]); - - this.logger.succ('All charts successfully cleaned.'); - } -} diff --git a/packages/backend/src/queue/processors/CleanProcessorService.ts b/packages/backend/src/queue/processors/CleanProcessorService.ts deleted file mode 100644 index e252c5d8a1..0000000000 --- a/packages/backend/src/queue/processors/CleanProcessorService.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { In, LessThan } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { AntennasRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import type { Config } from '@/config.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; - -@Injectable() -export class CleanProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.userIpsRepository) - private userIpsRepository: UserIpsRepository, - - @Inject(DI.antennasRepository) - private antennasRepository: AntennasRepository, - - @Inject(DI.roleAssignmentsRepository) - private roleAssignmentsRepository: RoleAssignmentsRepository, - - private queueLoggerService: QueueLoggerService, - private idService: IdService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('clean'); - } - - @bindThis - public async process(): Promise { - this.logger.info('Cleaning...'); - - this.userIpsRepository.delete({ - createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))), - }); - - // 使われてないアンテナを停止 - if (this.config.deactivateAntennaThreshold > 0) { - this.antennasRepository.update({ - lastUsedAt: LessThan(new Date(Date.now() - this.config.deactivateAntennaThreshold)), - }, { - isActive: false, - }); - } - - const expiredRoleAssignments = await this.roleAssignmentsRepository.createQueryBuilder('assign') - .where('assign.expiresAt IS NOT NULL') - .andWhere('assign.expiresAt < :now', { now: new Date() }) - .getMany(); - - if (expiredRoleAssignments.length > 0) { - await this.roleAssignmentsRepository.delete({ - id: In(expiredRoleAssignments.map(x => x.id)), - }); - } - - this.logger.succ('Cleaned.'); - } -} diff --git a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts deleted file mode 100644 index b62cc8a8fd..0000000000 --- a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, MoreThan, Not } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { MiDriveFile, DriveFilesRepository } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import { DriveService } from '@/core/DriveService.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; - -@Injectable() -export class CleanRemoteFilesProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private driveService: DriveService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('clean-remote-files'); - } - - @bindThis - public async process(job: Bull.Job>): Promise { - this.logger.info('Deleting cached remote files...'); - - let deletedCount = 0; - let cursor: MiDriveFile['id'] | null = null; - - while (true) { - const files = await this.driveFilesRepository.find({ - where: { - userHost: Not(IsNull()), - isLink: false, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 8, - order: { - id: 1, - }, - }); - - if (files.length === 0) { - job.updateProgress(100); - break; - } - - cursor = files.at(-1)?.id ?? null; - - await Promise.all(files.map(file => this.driveService.deleteFileSync(file, true))); - - deletedCount += 8; - - const total = await this.driveFilesRepository.countBy({ - userHost: Not(IsNull()), - isLink: false, - }); - - job.updateProgress(deletedCount / total); - } - - this.logger.succ('All cached remote files has been deleted.'); - } -} diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts deleted file mode 100644 index 56369f3a7a..0000000000 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ /dev/null @@ -1,170 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { MoreThan } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository, NoteReactionsRepository, NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import { DriveService } from '@/core/DriveService.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiNote } from '@/models/Note.js'; -import type { MiNoteReaction } from '@/models/NoteReaction.js'; -import { EmailService } from '@/core/EmailService.js'; -import { bindThis } from '@/decorators.js'; -import { SearchService } from '@/core/SearchService.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; -import type { DbUserDeleteJobData } from '../types.js'; -import { ReactionService } from '@/core/ReactionService.js'; - -@Injectable() -export class DeleteAccountProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - @Inject(DI.noteReactionsRepository) - private noteReactionsRepository: NoteReactionsRepository, - - private driveService: DriveService, - private emailService: EmailService, - private queueLoggerService: QueueLoggerService, - private searchService: SearchService, - private reactionService: ReactionService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('delete-account'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - this.logger.info(`Deleting account of ${job.data.user.id} ...`); - - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - { // Delete notes - let cursor: MiNote['id'] | null = null; - - while (true) { - const notes = await this.notesRepository.find({ - where: { - userId: user.id, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }) as MiNote[]; - - if (notes.length === 0) { - break; - } - - cursor = notes.at(-1)?.id ?? null; - - await this.notesRepository.delete(notes.map(note => note.id)); - - for (const note of notes) { - await this.searchService.unindexNote(note); - } - } - - this.logger.succ('All of notes deleted'); - } - - { // Delete reactions - let cursor: MiNoteReaction['id'] | null = null; - - while (true) { - const reactions = await this.noteReactionsRepository.find({ - where: { - userId: user.id, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }) as MiNoteReaction[]; - - if (reactions.length === 0) { - break; - } - - cursor = reactions.at(-1)?.id ?? null; - - for (const reaction of reactions) { - const note = await this.notesRepository.findOneBy({ id: reaction.noteId }) as MiNote; - - await this.reactionService.delete(user, note); - } - } - - this.logger.succ('All reactions have been deleted'); - } - - { // Delete files - let cursor: MiDriveFile['id'] | null = null; - - while (true) { - const files = await this.driveFilesRepository.find({ - where: { - userId: user.id, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 10, - order: { - id: 1, - }, - }) as MiDriveFile[]; - - if (files.length === 0) { - break; - } - - cursor = files.at(-1)?.id ?? null; - - for (const file of files) { - await this.driveService.deleteFileSync(file); - } - } - - this.logger.succ('All of files deleted'); - } - - { // Send email notification - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - if (profile.email && profile.emailVerified) { - this.emailService.sendEmail(profile.email, 'Account deleted', - 'Your account has been deleted.', - 'Your account has been deleted.'); - } - } - - // soft指定されている場合は物理削除しない - if (job.data.soft) { - // nop - } else { - await this.usersRepository.delete(job.data.user.id); - } - - return 'Account deleted'; - } -} diff --git a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts deleted file mode 100644 index 6d0a45bcc0..0000000000 --- a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { MoreThan } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { UsersRepository, DriveFilesRepository, MiDriveFile } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import { DriveService } from '@/core/DriveService.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; -import type { DbJobDataWithUser } from '../types.js'; - -@Injectable() -export class DeleteDriveFilesProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private driveService: DriveService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('delete-drive-files'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - this.logger.info(`Deleting drive files of ${job.data.user.id} ...`); - - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - let deletedCount = 0; - let cursor: MiDriveFile['id'] | null = null; - - while (true) { - const files = await this.driveFilesRepository.find({ - where: { - userId: user.id, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }); - - if (files.length === 0) { - job.updateProgress(100); - break; - } - - cursor = files.at(-1)?.id ?? null; - - for (const file of files) { - await this.driveService.deleteFileSync(file); - deletedCount++; - } - - const total = await this.driveFilesRepository.countBy({ - userId: user.id, - }); - - job.updateProgress(deletedCount / total); - } - - this.logger.succ(`All drive files (${deletedCount}) of ${user.id} has been deleted.`); - } -} diff --git a/packages/backend/src/queue/processors/DeleteFileProcessorService.ts b/packages/backend/src/queue/processors/DeleteFileProcessorService.ts deleted file mode 100644 index a4638bfaaf..0000000000 --- a/packages/backend/src/queue/processors/DeleteFileProcessorService.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import type Logger from '@/logger.js'; -import { DriveService } from '@/core/DriveService.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; -import type { ObjectStorageFileJobData } from '../types.js'; - -@Injectable() -export class DeleteFileProcessorService { - private logger: Logger; - - constructor( - private driveService: DriveService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('delete-file'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - const key: string = job.data.key; - - await this.driveService.deleteObjectStorageFile(key); - - return 'Success'; - } -} diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts deleted file mode 100644 index b419134d3d..0000000000 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ /dev/null @@ -1,139 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import * as Bull from 'bullmq'; -import { DI } from '@/di-symbols.js'; -import type { InstancesRepository } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import { MetaService } from '@/core/MetaService.js'; -import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; -import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; -import { MemorySingleCache } from '@/misc/cache.js'; -import type { MiInstance } from '@/models/Instance.js'; -import InstanceChart from '@/core/chart/charts/instance.js'; -import ApRequestChart from '@/core/chart/charts/ap-request.js'; -import FederationChart from '@/core/chart/charts/federation.js'; -import { StatusError } from '@/misc/status-error.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type { DeliverJobData } from '../types.js'; - -@Injectable() -export class DeliverProcessorService { - private logger: Logger; - private suspendedHostsCache: MemorySingleCache; - private latest: string | null; - - constructor( - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, - - private metaService: MetaService, - private utilityService: UtilityService, - private federatedInstanceService: FederatedInstanceService, - private fetchInstanceMetadataService: FetchInstanceMetadataService, - private apRequestService: ApRequestService, - private instanceChart: InstanceChart, - private apRequestChart: ApRequestChart, - private federationChart: FederationChart, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('deliver'); - this.suspendedHostsCache = new MemorySingleCache(1000 * 60 * 60); - } - - @bindThis - public async process(job: Bull.Job): Promise { - const { host } = new URL(job.data.to); - - // ブロックしてたら中断 - const meta = await this.metaService.fetch(); - const punyHost = this.utilityService.toPuny(host); - if (this.utilityService.isBlockedHost(meta.blockedHosts, punyHost)) { - return 'skip (blocked)'; - } - if (meta.allowlistMode && !this.utilityService.isAllowedHost(meta.allowedHosts, punyHost)) { - return 'skip (not allowed)'; - } - - // isSuspendedなら中断 - let suspendedHosts = this.suspendedHostsCache.get(); - if (suspendedHosts == null) { - suspendedHosts = await this.instancesRepository.find({ - where: { - isSuspended: true, - }, - }); - this.suspendedHostsCache.set(suspendedHosts); - } - if (suspendedHosts.map(x => x.host).includes(this.utilityService.toPuny(host))) { - return 'skip (suspended)'; - } - - try { - await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content); - - // Update stats - this.federatedInstanceService.fetch(host).then(i => { - if (i.isNotResponding) { - this.federatedInstanceService.update(i.id, { - isNotResponding: false, - }); - } - - this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - this.apRequestChart.deliverSucc(); - this.federationChart.deliverd(i.host, true); - - if (meta.enableChartsForFederatedInstances) { - this.instanceChart.requestSent(i.host, true); - } - }); - - return 'Success'; - } catch (res) { - // Update stats - this.federatedInstanceService.fetch(host).then(i => { - if (!i.isNotResponding) { - this.federatedInstanceService.update(i.id, { - isNotResponding: true, - }); - } - - this.apRequestChart.deliverFail(); - this.federationChart.deliverd(i.host, false); - - if (meta.enableChartsForFederatedInstances) { - this.instanceChart.requestSent(i.host, false); - } - }); - - if (res instanceof StatusError) { - // 4xx - if (res.isClientError) { - // 相手が閉鎖していることを明示しているため、配送停止する - if (job.data.isSharedInbox && res.statusCode === 410) { - this.federatedInstanceService.fetch(host).then(i => { - this.federatedInstanceService.update(i.id, { - isSuspended: true, - }); - }); - throw new Bull.UnrecoverableError(`${host} is gone`); - } - throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`); - } - - // 5xx etc. - throw new Error(`${res.statusCode} ${res.statusMessage}`); - } else { - // DNS error, socket error, timeout ... - throw res; - } - } - } -} diff --git a/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts b/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts deleted file mode 100644 index 4a48084436..0000000000 --- a/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { PollVotesRepository, NotesRepository } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import { NotificationService } from '@/core/NotificationService.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; -import type { EndedPollNotificationJobData } from '../types.js'; - -@Injectable() -export class EndedPollNotificationProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.pollVotesRepository) - private pollVotesRepository: PollVotesRepository, - - private notificationService: NotificationService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('ended-poll-notification'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - const note = await this.notesRepository.findOneBy({ id: job.data.noteId }); - if (note == null || !note.hasPoll) { - return; - } - - const votes = await this.pollVotesRepository.createQueryBuilder('vote') - .select('vote.userId') - .where('vote.noteId = :noteId', { noteId: note.id }) - .innerJoinAndSelect('vote.user', 'user') - .andWhere('user.host IS NULL') - .getMany(); - - const userIds = [...new Set([note.userId, ...votes.map(v => v.userId)])]; - - for (const userId of userIds) { - this.notificationService.createNotification(userId, 'pollEnded', { - noteId: note.id, - }); - } - } -} diff --git a/packages/backend/src/queue/processors/ExportAccountDataProcessorService.ts b/packages/backend/src/queue/processors/ExportAccountDataProcessorService.ts deleted file mode 100644 index f6a4e8e2ec..0000000000 --- a/packages/backend/src/queue/processors/ExportAccountDataProcessorService.ts +++ /dev/null @@ -1,813 +0,0 @@ -import * as fs from 'node:fs'; -import { Inject, Injectable } from '@nestjs/common'; -import { In, IsNull, MoreThan, Not } from 'typeorm'; -import { format as dateFormat } from 'date-fns'; -import mime from 'mime-types'; -import archiver from 'archiver'; -import { DI } from '@/di-symbols.js'; -import type { AntennasRepository, BlockingsRepository, DriveFilesRepository, FollowingsRepository, MiBlocking, MiFollowing, MiMuting, MiNote, MiNoteFavorite, MiPoll, MiUser, MutingsRepository, NoteFavoritesRepository, NotesRepository, PollsRepository, SigninsRepository, UserListMembershipsRepository, UserListsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; -import type { Config } from '@/config.js'; -import type Logger from '@/logger.js'; -import { DriveService } from '@/core/DriveService.js'; -import { IdService } from '@/core/IdService.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { createTemp, createTempDir } from '@/misc/create-temp.js'; -import { bindThis } from '@/decorators.js'; -import { Packed } from '@/misc/json-schema.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { DownloadService } from '@/core/DownloadService.js'; -import { EmailService } from '@/core/EmailService.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; - -@Injectable() -export class ExportAccountDataProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.noteFavoritesRepository) - private noteFavoritesRepository: NoteFavoritesRepository, - - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - @Inject(DI.antennasRepository) - private antennasRepository: AntennasRepository, - - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - @Inject(DI.signinsRepository) - private signinsRepository: SigninsRepository, - - private utilityService: UtilityService, - private driveService: DriveService, - private idService: IdService, - private driveFileEntityService: DriveFileEntityService, - private downloadService: DownloadService, - private emailService: EmailService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('export-account-data'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - this.logger.info('Exporting Account Data...'); - - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - const profile = await this.userProfilesRepository.findOneBy({ userId: job.data.user.id }); - if (profile == null) { - return; - } - - const [path, cleanup] = await createTempDir(); - - this.logger.info(`Temp dir is ${path}`); - - // User Export - - const userPath = path + '/user.json'; - - fs.writeFileSync(userPath, '', 'utf-8'); - - const userStream = fs.createWriteStream(userPath, { flags: 'a' }); - - const writeUser = (text: string): Promise => { - return new Promise((res, rej) => { - userStream.write(text, err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - }; - - await writeUser(`{"metaVersion":1,"host":"${this.config.host}","exportedAt":"${new Date().toString()}","user":[`); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { host, uri, sharedInbox, followersUri, lastFetchedAt, inbox, ...userTrimmed } = user; - - await writeUser(JSON.stringify(userTrimmed)); - - await writeUser(']}'); - - userStream.end(); - - // Profile Export - - const profilePath = path + '/profile.json'; - - fs.writeFileSync(profilePath, '', 'utf-8'); - - const profileStream = fs.createWriteStream(profilePath, { flags: 'a' }); - - const writeProfile = (text: string): Promise => { - return new Promise((res, rej) => { - profileStream.write(text, err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - }; - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { emailVerifyCode, twoFactorBackupSecret, twoFactorSecret, password, twoFactorTempSecret, userHost, ...profileTrimmed } = profile; - - await writeProfile(`{"metaVersion":1,"host":"${this.config.host}","exportedAt":"${new Date().toString()}","profile":[`); - - await writeProfile(JSON.stringify(profileTrimmed)); - - await writeProfile(']}'); - - profileStream.end(); - - // Stored IPs export - - const signins = await this.signinsRepository.findBy({ userId: user.id }); - - const ipPath = path + '/ips.json'; - - fs.writeFileSync(ipPath, '', 'utf-8'); - - const ipStream = fs.createWriteStream(ipPath, { flags: 'a' }); - - const writeIPs = (text: string): Promise => { - return new Promise((res, rej) => { - ipStream.write(text, err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - }; - - await writeIPs(`{"metaVersion":1,"host":"${this.config.host}","exportedAt":"${new Date().toString()}","ips":[`); - - for (const signin of signins) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { userId, id, user, ...signinTrimmed } = signin; - const isFirst = signins.indexOf(signin) === 0; - - await writeIPs(isFirst ? JSON.stringify(signinTrimmed) : ',\n' + JSON.stringify(signinTrimmed)); - } - - await writeIPs(']}'); - - ipStream.end(); - - // Note Export - - const notesPath = path + '/notes.json'; - - fs.writeFileSync(notesPath, '', 'utf-8'); - - const notesStream = fs.createWriteStream(notesPath, { flags: 'a' }); - - const writeNotes = (text: string): Promise => { - return new Promise((res, rej) => { - notesStream.write(text, err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - }; - - await writeNotes(`{"metaVersion":1,"host":"${this.config.host}","exportedAt":"${new Date().toString()}","notes":[`); - - let noteCursor: MiNote['id'] | null = null; - let exportedNotesCount = 0; - - while (true) { - const notes = await this.notesRepository.find({ - where: { - userId: user.id, - ...(noteCursor ? { id: MoreThan(noteCursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }) as MiNote[]; - - if (notes.length === 0) { - break; - } - - noteCursor = notes.at(-1)?.id ?? null; - - for (const note of notes) { - let poll: MiPoll | undefined; - if (note.hasPoll) { - poll = await this.pollsRepository.findOneByOrFail({ noteId: note.id }); - } - const files = await this.driveFileEntityService.packManyByIds(note.fileIds); - const content = JSON.stringify(this.noteSerialize(note, poll, files)); - const isFirst = exportedNotesCount === 0; - await writeNotes(isFirst ? content : ',\n' + content); - exportedNotesCount++; - } - } - - await writeNotes(']}'); - - notesStream.end(); - - // Following Export - - const followingsPath = path + '/followings.json'; - - fs.writeFileSync(followingsPath, '', 'utf-8'); - - const followingStream = fs.createWriteStream(followingsPath, { flags: 'a' }); - - const writeFollowing = (text: string): Promise => { - return new Promise((res, rej) => { - followingStream.write(text, err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - }; - - await writeFollowing(`{"metaVersion":1,"host":"${this.config.host}","exportedAt":"${new Date().toString()}","followings":[`); - - let followingsCursor: MiFollowing['id'] | null = null; - let exportedFollowingsCount = 0; - - const mutings = await this.mutingsRepository.findBy({ - muterId: user.id, - }); - - while (true) { - const followings = await this.followingsRepository.find({ - where: { - followerId: user.id, - ...(mutings.length > 0 ? { followeeId: Not(In(mutings.map(x => x.muteeId))) } : {}), - ...(followingsCursor ? { id: MoreThan(followingsCursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }) as MiFollowing[]; - - if (followings.length === 0) { - break; - } - - followingsCursor = followings.at(-1)?.id ?? null; - - for (const following of followings) { - const u = await this.usersRepository.findOneBy({ id: following.followeeId }); - if (u == null) { - continue; - } - - if (u.updatedAt && (Date.now() - u.updatedAt.getTime() > 1000 * 60 * 60 * 24 * 90)) { - continue; - } - - const isFirst = exportedFollowingsCount === 0; - const content = this.utilityService.getFullApAccount(u.username, u.host); - await writeFollowing(isFirst ? `"${content}"` : ',\n' + `"${content}"`); - exportedFollowingsCount++; - } - } - - await writeFollowing(']}'); - - followingStream.end(); - - // Followers Export - - const followersPath = path + '/followers.json'; - - fs.writeFileSync(followersPath, '', 'utf-8'); - - const followerStream = fs.createWriteStream(followersPath, { flags: 'a' }); - - const writeFollowers = (text: string): Promise => { - return new Promise((res, rej) => { - followerStream.write(text, err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - }; - - await writeFollowers(`{"metaVersion":1,"host":"${this.config.host}","exportedAt":"${new Date().toString()}","followers":[`); - - let followersCursor: MiFollowing['id'] | null = null; - let exportedFollowersCount = 0; - - while (true) { - const followers = await this.followingsRepository.find({ - where: { - followeeId: user.id, - ...(followersCursor ? { id: MoreThan(followersCursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }) as MiFollowing[]; - - if (followers.length === 0) { - break; - } - - followersCursor = followers.at(-1)?.id ?? null; - - for (const follower of followers) { - const u = await this.usersRepository.findOneBy({ id: follower.followerId }); - if (u == null) { - continue; - } - - const isFirst = exportedFollowersCount === 0; - const content = this.utilityService.getFullApAccount(u.username, u.host); - await writeFollowers(isFirst ? `"${content}"` : ',\n' + `"${content}"`); - exportedFollowersCount++; - } - } - - await writeFollowers(']}'); - - followerStream.end(); - - // Drive Export - - const filesPath = path + '/drive.json'; - - fs.writeFileSync(filesPath, '', 'utf-8'); - - const filesStream = fs.createWriteStream(filesPath, { flags: 'a' }); - - const writeDrive = (text: string): Promise => { - return new Promise((res, rej) => { - filesStream.write(text, err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - }; - - fs.mkdirSync(`${path}/files`); - - await writeDrive(`{"metaVersion":1,"host":"${this.config.host}","exportedAt":"${new Date().toString()}","drive":[`); - - const driveFiles = await this.driveFilesRepository.find({ where: { userId: user.id } }); - - for (const file of driveFiles) { - const ext = mime.extension(file.type); - const fileName = file.name + '.' + ext; - const filePath = path + '/files/' + fileName; - fs.writeFileSync(filePath, '', 'binary'); - let downloaded = false; - - try { - await this.downloadService.downloadUrl(file.url, filePath); - downloaded = true; - } catch (e) { - this.logger.error(e instanceof Error ? e : new Error(e as string)); - } - - if (!downloaded) { - fs.unlinkSync(filePath); - } - - const content = JSON.stringify({ - fileName: fileName, - file: file, - }); - const isFirst = driveFiles.indexOf(file) === 0; - - await writeDrive(isFirst ? content : ',\n' + content); - } - - await writeDrive(']}'); - - filesStream.end(); - - // Muting Export - - const mutingPath = path + '/mutings.json'; - - fs.writeFileSync(mutingPath, '', 'utf-8'); - - const mutingStream = fs.createWriteStream(mutingPath, { flags: 'a' }); - - const writeMuting = (text: string): Promise => { - return new Promise((res, rej) => { - mutingStream.write(text, err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - }; - - await writeMuting(`{"metaVersion":1,"host":"${this.config.host}","exportedAt":"${new Date().toString()}","mutings":[`); - - let exportedMutingCount = 0; - let mutingCursor: MiMuting['id'] | null = null; - - while (true) { - const mutes = await this.mutingsRepository.find({ - where: { - muterId: user.id, - expiresAt: IsNull(), - ...(mutingCursor ? { id: MoreThan(mutingCursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }); - - if (mutes.length === 0) { - break; - } - - mutingCursor = mutes.at(-1)?.id ?? null; - - for (const mute of mutes) { - const u = await this.usersRepository.findOneBy({ id: mute.muteeId }); - - if (u == null) { - exportedMutingCount++; continue; - } - - const content = this.utilityService.getFullApAccount(u.username, u.host); - const isFirst = exportedMutingCount === 0; - await writeMuting(isFirst ? `"${content}"` : ',\n' + `"${content}"`); - exportedMutingCount++; - } - } - - await writeMuting(']}'); - - mutingStream.end(); - - // Blockings Export - - const blockingPath = path + '/blockings.json'; - - fs.writeFileSync(blockingPath, '', 'utf-8'); - - const blockingStream = fs.createWriteStream(blockingPath, { flags: 'a' }); - - const writeBlocking = (text: string): Promise => { - return new Promise((res, rej) => { - blockingStream.write(text, err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - }; - - await writeBlocking(`{"metaVersion":1,"host":"${this.config.host}","exportedAt":"${new Date().toString()}","blockings":[`); - - let exportedBlockingCount = 0; - let blockingCursor: MiBlocking['id'] | null = null; - - while (true) { - const blockings = await this.blockingsRepository.find({ - where: { - blockerId: user.id, - ...(blockingCursor ? { id: MoreThan(blockingCursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }); - - if (blockings.length === 0) { - break; - } - - blockingCursor = blockings.at(-1)?.id ?? null; - - for (const block of blockings) { - const u = await this.usersRepository.findOneBy({ id: block.blockeeId }); - - if (u == null) { - exportedBlockingCount++; continue; - } - - const content = this.utilityService.getFullApAccount(u.username, u.host); - const isFirst = exportedBlockingCount === 0; - await writeBlocking(isFirst ? `"${content}"` : ',\n' + `"${content}"`); - exportedBlockingCount++; - } - } - - await writeBlocking(']}'); - - blockingStream.end(); - - // Favorites export - - const favoritePath = path + '/favorites.json'; - - fs.writeFileSync(favoritePath, '', 'utf-8'); - - const favoriteStream = fs.createWriteStream(favoritePath, { flags: 'a' }); - - const writeFavorite = (text: string): Promise => { - return new Promise((res, rej) => { - favoriteStream.write(text, err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - }; - - await writeFavorite(`{"metaVersion":1,"host":"${this.config.host}","exportedAt":"${new Date().toString()}","favorites":[`); - - let exportedFavoritesCount = 0; - let favoriteCursor: MiNoteFavorite['id'] | null = null; - - while (true) { - const favorites = await this.noteFavoritesRepository.find({ - where: { - userId: user.id, - ...(favoriteCursor ? { id: MoreThan(favoriteCursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - relations: ['note', 'note.user'], - }) as (MiNoteFavorite & { note: MiNote & { user: MiUser } })[]; - - if (favorites.length === 0) { - break; - } - - favoriteCursor = favorites.at(-1)?.id ?? null; - - for (const favorite of favorites) { - let poll: MiPoll | undefined; - if (favorite.note.hasPoll) { - poll = await this.pollsRepository.findOneByOrFail({ noteId: favorite.note.id }); - } - const content = JSON.stringify(this.favoriteSerialize(favorite, poll)); - const isFirst = exportedFavoritesCount === 0; - await writeFavorite(isFirst ? content : ',\n' + content); - exportedFavoritesCount++; - } - } - - await writeFavorite(']}'); - - favoriteStream.end(); - - // Antennas export - - const antennaPath = path + '/antennas.json'; - - fs.writeFileSync(antennaPath, '', 'utf-8'); - - const antennaStream = fs.createWriteStream(antennaPath, { flags: 'a' }); - - const writeAntenna = (text: string): Promise => { - return new Promise((res, rej) => { - antennaStream.write(text, err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - }; - - await writeAntenna(`{"metaVersion":1,"host":"${this.config.host}","exportedAt":"${new Date().toString()}","antennas":[`); - - const antennas = await this.antennasRepository.findBy({ userId: user.id }); - - for (const [index, antenna] of antennas.entries()) { - let users: MiUser[] | undefined; - if (antenna.userListId !== null) { - const memberships = await this.userListMembershipsRepository.findBy({ userListId: antenna.userListId }); - users = await this.usersRepository.findBy({ - id: In(memberships.map(j => j.userId)), - }); - } - - await writeAntenna(JSON.stringify({ - name: antenna.name, - src: antenna.src, - keywords: antenna.keywords, - excludeKeywords: antenna.excludeKeywords, - users: antenna.users, - userListAccts: typeof users !== 'undefined' ? users.map((u) => { - return this.utilityService.getFullApAccount(u.username, u.host); // acct - }) : null, - caseSensitive: antenna.caseSensitive, - localOnly: antenna.localOnly, - withReplies: antenna.withReplies, - withFile: antenna.withFile, - notify: antenna.notify, - })); - - if (antennas.length - 1 !== index) { - await writeAntenna(', '); - } - } - - await writeAntenna(']}'); - - antennaStream.end(); - - // Lists export - - const listPath = path + '/lists.csv'; - - fs.writeFileSync(listPath, '', 'utf-8'); - - const listStream = fs.createWriteStream(listPath, { flags: 'a' }); - - const writeList = (text: string): Promise => { - return new Promise((res, rej) => { - listStream.write(text, err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - }; - - const lists = await this.userListsRepository.findBy({ - userId: user.id, - }); - - for (const list of lists) { - const memberships = await this.userListMembershipsRepository.findBy({ userListId: list.id }); - const users = await this.usersRepository.findBy({ - id: In(memberships.map(j => j.userId)), - }); - - for (const u of users) { - const acct = this.utilityService.getFullApAccount(u.username, u.host); - const content = `${list.name},${acct}`; - await writeList(content + '\n'); - } - } - - listStream.end(); - - // Create archive - await new Promise(async (resolve) => { - const [archivePath, archiveCleanup] = await createTemp(); - const archiveStream = fs.createWriteStream(archivePath); - const archive = archiver('zip', { - zlib: { level: 0 }, - }); - archiveStream.on('close', async () => { - this.logger.succ(`Exported to: ${archivePath}`); - - const fileName = 'data-request-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.zip'; - const driveFile = await this.driveService.addFile({ user, path: archivePath, name: fileName, force: true }); - - this.logger.succ(`Exported to: ${driveFile.id}`); - cleanup(); - archiveCleanup(); - if (profile.email) { - this.emailService.sendEmail(profile.email, - 'Your data archive is ready', - `Click the following link to download the archive: ${driveFile.url}
It is also available in your drive.`, - `Click the following link to download the archive: ${driveFile.url}\r\n\r\nIt is also available in your drive.`, - ); - } - resolve(); - }); - archive.pipe(archiveStream); - archive.directory(path, false); - archive.finalize(); - }); - } - - private noteSerialize(note: MiNote, poll: MiPoll | null = null, files: Packed<'DriveFile'>[]): Record { - return { - id: note.id, - text: note.text, - createdAt: this.idService.parse(note.id).date.toISOString(), - fileIds: note.fileIds, - files: files, - replyId: note.replyId, - renoteId: note.renoteId, - poll: poll, - cw: note.cw, - visibility: note.visibility, - visibleUserIds: note.visibleUserIds, - localOnly: note.localOnly, - reactionAcceptance: note.reactionAcceptance, - }; - } - - private favoriteSerialize(favorite: MiNoteFavorite & { note: MiNote & { user: MiUser } }, poll: MiPoll | null = null): Record { - return { - id: favorite.id, - createdAt: this.idService.parse(favorite.id).date.toISOString(), - note: { - id: favorite.note.id, - text: favorite.note.text, - createdAt: this.idService.parse(favorite.note.id).date.toISOString(), - fileIds: favorite.note.fileIds, - replyId: favorite.note.replyId, - renoteId: favorite.note.renoteId, - poll: poll, - cw: favorite.note.cw, - visibility: favorite.note.visibility, - visibleUserIds: favorite.note.visibleUserIds, - localOnly: favorite.note.localOnly, - reactionAcceptance: favorite.note.reactionAcceptance, - uri: favorite.note.uri, - url: favorite.note.url, - user: { - id: favorite.note.user.id, - name: favorite.note.user.name, - username: favorite.note.user.username, - host: favorite.note.user.host, - uri: favorite.note.user.uri, - }, - }, - }; - } -} diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts deleted file mode 100644 index d0968d2923..0000000000 --- a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import fs from 'node:fs'; -import { Inject, Injectable } from '@nestjs/common'; -import { format as DateFormat } from 'date-fns'; -import { In } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { AntennasRepository, UsersRepository, UserListMembershipsRepository, MiUser } from '@/models/_.js'; -import Logger from '@/logger.js'; -import { DriveService } from '@/core/DriveService.js'; -import { bindThis } from '@/decorators.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type { DBExportAntennasData } from '../types.js'; -import type * as Bull from 'bullmq'; - -@Injectable() -export class ExportAntennasProcessorService { - private logger: Logger; - - constructor ( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.antennasRepository) - private antennsRepository: AntennasRepository, - - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - private driveService: DriveService, - private utilityService: UtilityService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('export-antennas'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - const [path, cleanup] = await createTemp(); - const stream = fs.createWriteStream(path, { flags: 'a' }); - const write = (input: string): Promise => { - return new Promise((resolve, reject) => { - stream.write(input, err => { - if (err) { - this.logger.error(err); - reject(); - } else { - resolve(); - } - }); - }); - }; - try { - const antennas = await this.antennsRepository.findBy({ userId: job.data.user.id }); - write('['); - for (const [index, antenna] of antennas.entries()) { - let users: MiUser[] | undefined; - if (antenna.userListId !== null) { - const memberships = await this.userListMembershipsRepository.findBy({ userListId: antenna.userListId }); - users = await this.usersRepository.findBy({ - id: In(memberships.map(j => j.userId)), - }); - } - write(JSON.stringify({ - name: antenna.name, - src: antenna.src, - keywords: antenna.keywords, - excludeKeywords: antenna.excludeKeywords, - users: antenna.users, - userListAccts: typeof users !== 'undefined' ? users.map((u) => { - return this.utilityService.getFullApAccount(u.username, u.host); // acct - }) : null, - caseSensitive: antenna.caseSensitive, - localOnly: antenna.localOnly, - withReplies: antenna.withReplies, - withFile: antenna.withFile, - notify: antenna.notify, - })); - if (antennas.length - 1 !== index) { - write(', '); - } - } - write(']'); - stream.end(); - - const fileName = 'antennas-' + DateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); - this.logger.succ('Exported to: ' + driveFile.id); - } finally { - cleanup(); - } - } -} - diff --git a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts deleted file mode 100644 index 0a37e3ca1e..0000000000 --- a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts +++ /dev/null @@ -1,116 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as fs from 'node:fs'; -import { Inject, Injectable } from '@nestjs/common'; -import { MoreThan } from 'typeorm'; -import { format as dateFormat } from 'date-fns'; -import { DI } from '@/di-symbols.js'; -import type { UsersRepository, BlockingsRepository, MiBlocking } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import { DriveService } from '@/core/DriveService.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; -import type { DbJobDataWithUser } from '../types.js'; - -@Injectable() -export class ExportBlockingProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - - private utilityService: UtilityService, - private driveService: DriveService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('export-blocking'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - this.logger.info(`Exporting blocking of ${job.data.user.id} ...`); - - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - // Create temp file - const [path, cleanup] = await createTemp(); - - this.logger.info(`Temp file is ${path}`); - - try { - const stream = fs.createWriteStream(path, { flags: 'a' }); - - let exportedCount = 0; - let cursor: MiBlocking['id'] | null = null; - - while (true) { - const blockings = await this.blockingsRepository.find({ - where: { - blockerId: user.id, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }); - - if (blockings.length === 0) { - job.updateProgress(100); - break; - } - - cursor = blockings.at(-1)?.id ?? null; - - for (const block of blockings) { - const u = await this.usersRepository.findOneBy({ id: block.blockeeId }); - if (u == null) { - exportedCount++; continue; - } - - const content = this.utilityService.getFullApAccount(u.username, u.host); - await new Promise((res, rej) => { - stream.write(content + '\n', err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - exportedCount++; - } - - const total = await this.blockingsRepository.countBy({ - blockerId: user.id, - }); - - job.updateProgress(exportedCount / total); - } - - stream.end(); - this.logger.succ(`Exported to: ${path}`); - - const fileName = 'blocking-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); - - this.logger.succ(`Exported to: ${driveFile.id}`); - } finally { - cleanup(); - } - } -} diff --git a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts deleted file mode 100644 index d5387fe42e..0000000000 --- a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts +++ /dev/null @@ -1,146 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as fs from 'node:fs'; -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull } from 'typeorm'; -import { format as dateFormat } from 'date-fns'; -import mime from 'mime-types'; -import archiver from 'archiver'; -import { DI } from '@/di-symbols.js'; -import type { EmojisRepository, UsersRepository } from '@/models/_.js'; -import type { Config } from '@/config.js'; -import type Logger from '@/logger.js'; -import { DriveService } from '@/core/DriveService.js'; -import { createTemp, createTempDir } from '@/misc/create-temp.js'; -import { DownloadService } from '@/core/DownloadService.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; - -@Injectable() -export class ExportCustomEmojisProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private driveService: DriveService, - private downloadService: DownloadService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('export-custom-emojis'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - this.logger.info('Exporting custom emojis ...'); - - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - const [path, cleanup] = await createTempDir(); - - this.logger.info(`Temp dir is ${path}`); - - const metaPath = path + '/meta.json'; - - fs.writeFileSync(metaPath, '', 'utf-8'); - - const metaStream = fs.createWriteStream(metaPath, { flags: 'a' }); - - const writeMeta = (text: string): Promise => { - return new Promise((res, rej) => { - metaStream.write(text, err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - }; - - await writeMeta(`{"metaVersion":2,"host":"${this.config.host}","exportedAt":"${new Date().toString()}","emojis":[`); - - const customEmojis = await this.emojisRepository.find({ - where: { - host: IsNull(), - }, - order: { - id: 'ASC', - }, - }); - - for (const emoji of customEmojis) { - if (!/^[a-zA-Z0-9_]+$/.test(emoji.name)) { - this.logger.error(`invalid emoji name: ${emoji.name}`); - continue; - } - const ext = mime.extension(emoji.type ?? 'image/png'); - const fileName = emoji.name + (ext ? '.' + ext : ''); - const emojiPath = path + '/' + fileName; - fs.writeFileSync(emojiPath, '', 'binary'); - let downloaded = false; - - try { - await this.downloadService.downloadUrl(emoji.originalUrl, emojiPath); - downloaded = true; - } catch (e) { // TODO: 何度か再試行 - this.logger.error(e instanceof Error ? e : new Error(e as string)); - } - - if (!downloaded) { - fs.unlinkSync(emojiPath); - } - - const content = JSON.stringify({ - fileName: fileName, - downloaded: downloaded, - emoji: emoji, - }); - const isFirst = customEmojis.indexOf(emoji) === 0; - - await writeMeta(isFirst ? content : ',\n' + content); - } - - await writeMeta(']}'); - - metaStream.end(); - - // Create archive - await new Promise(async (resolve) => { - const [archivePath, archiveCleanup] = await createTemp(); - const archiveStream = fs.createWriteStream(archivePath); - const archive = archiver('zip', { - zlib: { level: 0 }, - }); - archiveStream.on('close', async () => { - this.logger.succ(`Exported to: ${archivePath}`); - - const fileName = 'custom-emojis-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.zip'; - const driveFile = await this.driveService.addFile({ user, path: archivePath, name: fileName, force: true }); - - this.logger.succ(`Exported to: ${driveFile.id}`); - cleanup(); - archiveCleanup(); - resolve(); - }); - archive.pipe(archiveStream); - archive.directory(path, false); - archive.finalize(); - }); - } -} diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts deleted file mode 100644 index af2a3434a9..0000000000 --- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts +++ /dev/null @@ -1,160 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as fs from 'node:fs'; -import { Inject, Injectable } from '@nestjs/common'; -import { MoreThan } from 'typeorm'; -import { format as dateFormat } from 'date-fns'; -import { DI } from '@/di-symbols.js'; -import type { MiNoteFavorite, NoteFavoritesRepository, PollsRepository, MiUser, UsersRepository } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import { DriveService } from '@/core/DriveService.js'; -import { createTemp } from '@/misc/create-temp.js'; -import type { MiPoll } from '@/models/Poll.js'; -import type { MiNote } from '@/models/Note.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; -import type { DbJobDataWithUser } from '../types.js'; - -@Injectable() -export class ExportFavoritesProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - @Inject(DI.noteFavoritesRepository) - private noteFavoritesRepository: NoteFavoritesRepository, - - private driveService: DriveService, - private queueLoggerService: QueueLoggerService, - private idService: IdService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('export-favorites'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - this.logger.info(`Exporting favorites of ${job.data.user.id} ...`); - - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - // Create temp file - const [path, cleanup] = await createTemp(); - - this.logger.info(`Temp file is ${path}`); - - try { - const stream = fs.createWriteStream(path, { flags: 'a' }); - - const write = (text: string): Promise => { - return new Promise((res, rej) => { - stream.write(text, err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - }; - - await write('['); - - let exportedFavoritesCount = 0; - let cursor: MiNoteFavorite['id'] | null = null; - - while (true) { - const favorites = await this.noteFavoritesRepository.find({ - where: { - userId: user.id, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - relations: ['note', 'note.user'], - }) as (MiNoteFavorite & { note: MiNote & { user: MiUser } })[]; - - if (favorites.length === 0) { - job.updateProgress(100); - break; - } - - cursor = favorites.at(-1)?.id ?? null; - - for (const favorite of favorites) { - let poll: MiPoll | undefined; - if (favorite.note.hasPoll) { - poll = await this.pollsRepository.findOneByOrFail({ noteId: favorite.note.id }); - } - const content = JSON.stringify(this.serialize(favorite, poll)); - const isFirst = exportedFavoritesCount === 0; - await write(isFirst ? content : ',\n' + content); - exportedFavoritesCount++; - } - - const total = await this.noteFavoritesRepository.countBy({ - userId: user.id, - }); - - job.updateProgress(exportedFavoritesCount / total); - } - - await write(']'); - - stream.end(); - this.logger.succ(`Exported to: ${path}`); - - const fileName = 'favorites-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); - - this.logger.succ(`Exported to: ${driveFile.id}`); - } finally { - cleanup(); - } - } - - private serialize(favorite: MiNoteFavorite & { note: MiNote & { user: MiUser } }, poll: MiPoll | null = null): Record { - return { - id: favorite.id, - createdAt: this.idService.parse(favorite.id).date.toISOString(), - note: { - id: favorite.note.id, - text: favorite.note.text, - createdAt: this.idService.parse(favorite.note.id).date.toISOString(), - fileIds: favorite.note.fileIds, - replyId: favorite.note.replyId, - renoteId: favorite.note.renoteId, - poll: poll, - cw: favorite.note.cw, - visibility: favorite.note.visibility, - visibleUserIds: favorite.note.visibleUserIds, - localOnly: favorite.note.localOnly, - reactionAcceptance: favorite.note.reactionAcceptance, - uri: favorite.note.uri, - url: favorite.note.url, - user: { - id: favorite.note.user.id, - name: favorite.note.user.name, - username: favorite.note.user.username, - host: favorite.note.user.host, - uri: favorite.note.user.uri, - }, - }, - }; - } -} diff --git a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts deleted file mode 100644 index c9739eb1cb..0000000000 --- a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts +++ /dev/null @@ -1,120 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as fs from 'node:fs'; -import { Inject, Injectable } from '@nestjs/common'; -import { In, MoreThan, Not } from 'typeorm'; -import { format as dateFormat } from 'date-fns'; -import { DI } from '@/di-symbols.js'; -import type { UsersRepository, FollowingsRepository, MutingsRepository } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import { DriveService } from '@/core/DriveService.js'; -import { createTemp } from '@/misc/create-temp.js'; -import type { MiFollowing } from '@/models/Following.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; -import type { DbExportFollowingData } from '../types.js'; - -@Injectable() -export class ExportFollowingProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - private utilityService: UtilityService, - private driveService: DriveService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('export-following'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - this.logger.info(`Exporting following of ${job.data.user.id} ...`); - - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - // Create temp file - const [path, cleanup] = await createTemp(); - - this.logger.info(`Temp file is ${path}`); - - try { - const stream = fs.createWriteStream(path, { flags: 'a' }); - - let cursor: MiFollowing['id'] | null = null; - - const mutings = job.data.excludeMuting ? await this.mutingsRepository.findBy({ - muterId: user.id, - }) : []; - - while (true) { - const followings = await this.followingsRepository.find({ - where: { - followerId: user.id, - ...(mutings.length > 0 ? { followeeId: Not(In(mutings.map(x => x.muteeId))) } : {}), - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }) as MiFollowing[]; - - if (followings.length === 0) { - break; - } - - cursor = followings.at(-1)?.id ?? null; - - for (const following of followings) { - const u = await this.usersRepository.findOneBy({ id: following.followeeId }); - if (u == null) { - continue; - } - - if (job.data.excludeInactive && u.updatedAt && (Date.now() - u.updatedAt.getTime() > 1000 * 60 * 60 * 24 * 90)) { - continue; - } - - const content = this.utilityService.getFullApAccount(u.username, u.host); - await new Promise((res, rej) => { - stream.write(content + '\n', err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - } - } - - stream.end(); - this.logger.succ(`Exported to: ${path}`); - - const fileName = 'following-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); - - this.logger.succ(`Exported to: ${driveFile.id}`); - } finally { - cleanup(); - } - } -} diff --git a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts deleted file mode 100644 index c8425c1f2d..0000000000 --- a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts +++ /dev/null @@ -1,117 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as fs from 'node:fs'; -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, MoreThan } from 'typeorm'; -import { format as dateFormat } from 'date-fns'; -import { DI } from '@/di-symbols.js'; -import type { MutingsRepository, UsersRepository, MiMuting } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import { DriveService } from '@/core/DriveService.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; -import type { DbJobDataWithUser } from '../types.js'; - -@Injectable() -export class ExportMutingProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - private utilityService: UtilityService, - private driveService: DriveService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('export-muting'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - this.logger.info(`Exporting muting of ${job.data.user.id} ...`); - - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - // Create temp file - const [path, cleanup] = await createTemp(); - - this.logger.info(`Temp file is ${path}`); - - try { - const stream = fs.createWriteStream(path, { flags: 'a' }); - - let exportedCount = 0; - let cursor: MiMuting['id'] | null = null; - - while (true) { - const mutes = await this.mutingsRepository.find({ - where: { - muterId: user.id, - expiresAt: IsNull(), - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }); - - if (mutes.length === 0) { - job.updateProgress(100); - break; - } - - cursor = mutes.at(-1)?.id ?? null; - - for (const mute of mutes) { - const u = await this.usersRepository.findOneBy({ id: mute.muteeId }); - if (u == null) { - exportedCount++; continue; - } - - const content = this.utilityService.getFullApAccount(u.username, u.host); - await new Promise((res, rej) => { - stream.write(content + '\n', err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - exportedCount++; - } - - const total = await this.mutingsRepository.countBy({ - muterId: user.id, - }); - - job.updateProgress(exportedCount / total); - } - - stream.end(); - this.logger.succ(`Exported to: ${path}`); - - const fileName = 'mute-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); - - this.logger.succ(`Exported to: ${driveFile.id}`); - } finally { - cleanup(); - } - } -} diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts deleted file mode 100644 index cd4ccb0b07..0000000000 --- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts +++ /dev/null @@ -1,151 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as fs from 'node:fs'; -import { Inject, Injectable } from '@nestjs/common'; -import { MoreThan } from 'typeorm'; -import { format as dateFormat } from 'date-fns'; -import { DI } from '@/di-symbols.js'; -import type { NotesRepository, PollsRepository, UsersRepository } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import { DriveService } from '@/core/DriveService.js'; -import { createTemp } from '@/misc/create-temp.js'; -import type { MiPoll } from '@/models/Poll.js'; -import type { MiNote } from '@/models/Note.js'; -import { bindThis } from '@/decorators.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { Packed } from '@/misc/json-schema.js'; -import { IdService } from '@/core/IdService.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; -import type { DbJobDataWithUser } from '../types.js'; - -@Injectable() -export class ExportNotesProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - private driveService: DriveService, - private queueLoggerService: QueueLoggerService, - private driveFileEntityService: DriveFileEntityService, - private idService: IdService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('export-notes'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - this.logger.info(`Exporting notes of ${job.data.user.id} ...`); - - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - // Create temp file - const [path, cleanup] = await createTemp(); - - this.logger.info(`Temp file is ${path}`); - - try { - const stream = fs.createWriteStream(path, { flags: 'a' }); - - const write = (text: string): Promise => { - return new Promise((res, rej) => { - stream.write(text, err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - }; - - await write('['); - - let exportedNotesCount = 0; - let cursor: MiNote['id'] | null = null; - - while (true) { - const notes = await this.notesRepository.find({ - where: { - userId: user.id, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }) as MiNote[]; - - if (notes.length === 0) { - job.updateProgress(100); - break; - } - - cursor = notes.at(-1)?.id ?? null; - - for (const note of notes) { - let poll: MiPoll | undefined; - if (note.hasPoll) { - poll = await this.pollsRepository.findOneByOrFail({ noteId: note.id }); - } - const files = await this.driveFileEntityService.packManyByIds(note.fileIds); - const content = JSON.stringify(this.serialize(note, poll, files)); - const isFirst = exportedNotesCount === 0; - await write(isFirst ? content : ',\n' + content); - exportedNotesCount++; - } - - const total = await this.notesRepository.countBy({ - userId: user.id, - }); - - job.updateProgress(exportedNotesCount / total); - } - - await write(']'); - - stream.end(); - this.logger.succ(`Exported to: ${path}`); - - const fileName = 'notes-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); - - this.logger.succ(`Exported to: ${driveFile.id}`); - } finally { - cleanup(); - } - } - - private serialize(note: MiNote, poll: MiPoll | null = null, files: Packed<'DriveFile'>[]): Record { - return { - id: note.id, - text: note.text, - createdAt: this.idService.parse(note.id).date.toISOString(), - fileIds: note.fileIds, - files: files, - replyId: note.replyId, - renoteId: note.renoteId, - poll: poll, - cw: note.cw, - visibility: note.visibility, - visibleUserIds: note.visibleUserIds, - localOnly: note.localOnly, - reactionAcceptance: note.reactionAcceptance, - }; - } -} diff --git a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts deleted file mode 100644 index a3f9441dc2..0000000000 --- a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as fs from 'node:fs'; -import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; -import { format as dateFormat } from 'date-fns'; -import { DI } from '@/di-symbols.js'; -import type { UserListMembershipsRepository, UserListsRepository, UsersRepository } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import { DriveService } from '@/core/DriveService.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; -import type { DbJobDataWithUser } from '../types.js'; - -@Injectable() -export class ExportUserListsProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - private utilityService: UtilityService, - private driveService: DriveService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('export-user-lists'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - this.logger.info(`Exporting user lists of ${job.data.user.id} ...`); - - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - const lists = await this.userListsRepository.findBy({ - userId: user.id, - }); - - // Create temp file - const [path, cleanup] = await createTemp(); - - this.logger.info(`Temp file is ${path}`); - - try { - const stream = fs.createWriteStream(path, { flags: 'a' }); - - for (const list of lists) { - const memberships = await this.userListMembershipsRepository.findBy({ userListId: list.id }); - const users = await this.usersRepository.findBy({ - id: In(memberships.map(j => j.userId)), - }); - - for (const u of users) { - const acct = this.utilityService.getFullApAccount(u.username, u.host); - const content = `${list.name},${acct}`; - await new Promise((res, rej) => { - stream.write(content + '\n', err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - } - } - - stream.end(); - this.logger.succ(`Exported to: ${path}`); - - const fileName = 'user-lists-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); - - this.logger.succ(`Exported to: ${driveFile.id}`); - } finally { - cleanup(); - } - } -} diff --git a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts deleted file mode 100644 index 291ea14b67..0000000000 --- a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable, Inject } from '@nestjs/common'; -import _Ajv from 'ajv'; -import { IdService } from '@/core/IdService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import Logger from '@/logger.js'; -import type { AntennasRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import { DBAntennaImportJobData } from '../types.js'; -import type * as Bull from 'bullmq'; - -const Ajv = _Ajv.default; - -const validate = new Ajv().compile({ - type: 'object', - properties: { - name: { type: 'string', minLength: 1, maxLength: 100 }, - src: { type: 'string', enum: ['home', 'all', 'users', 'list'] }, - userListAccts: { - type: 'array', - items: { - type: 'string', - }, - nullable: true, - }, - keywords: { type: 'array', items: { - type: 'array', items: { - type: 'string', - }, - } }, - excludeKeywords: { type: 'array', items: { - type: 'array', items: { - type: 'string', - }, - } }, - users: { type: 'array', items: { - type: 'string', - } }, - caseSensitive: { type: 'boolean' }, - localOnly: { type: 'boolean' }, - withReplies: { type: 'boolean' }, - withFile: { type: 'boolean' }, - notify: { type: 'boolean' }, - }, - required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile', 'notify'], -}); - -@Injectable() -export class ImportAntennasProcessorService { - private logger: Logger; - - constructor ( - @Inject(DI.antennasRepository) - private antennasRepository: AntennasRepository, - - private queueLoggerService: QueueLoggerService, - private idService: IdService, - private globalEventService: GlobalEventService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('import-antennas'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - const now = new Date(); - try { - for (const antenna of job.data.antenna) { - if (antenna.keywords.length === 0 || antenna.keywords[0].every(x => x === '')) continue; - if (!validate(antenna)) { - this.logger.warn('Validation Failed'); - continue; - } - const result = await this.antennasRepository.insert({ - id: this.idService.gen(now.getTime()), - lastUsedAt: now, - userId: job.data.user.id, - name: antenna.name, - src: antenna.src === 'list' && antenna.userListAccts ? 'users' : antenna.src, - userListId: null, - keywords: antenna.keywords, - excludeKeywords: antenna.excludeKeywords, - users: (antenna.src === 'list' && antenna.userListAccts !== null ? antenna.userListAccts : antenna.users).filter(Boolean), - caseSensitive: antenna.caseSensitive, - localOnly: antenna.localOnly, - withReplies: antenna.withReplies, - withFile: antenna.withFile, - notify: antenna.notify, - }).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0])); - this.logger.succ('Antenna created: ' + result.id); - this.globalEventService.publishInternalEvent('antennaCreated', result); - } - } catch (err: any) { - this.logger.error(err); - } - } -} diff --git a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts deleted file mode 100644 index 64520b770b..0000000000 --- a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { UsersRepository, DriveFilesRepository } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import * as Acct from '@/misc/acct.js'; -import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; -import { DownloadService } from '@/core/DownloadService.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; -import { QueueService } from '@/core/QueueService.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; -import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js'; - -@Injectable() -export class ImportBlockingProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private queueService: QueueService, - private utilityService: UtilityService, - private remoteUserResolveService: RemoteUserResolveService, - private downloadService: DownloadService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('import-blocking'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - this.logger.info(`Importing blocking of ${job.data.user.id} ...`); - - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - const file = await this.driveFilesRepository.findOneBy({ - id: job.data.fileId, - }); - if (file == null) { - return; - } - - const csv = await this.downloadService.downloadTextFile(file.url); - const targets = csv.trim().split('\n'); - this.queueService.createImportBlockingToDbJob({ id: user.id }, targets); - - this.logger.succ('Import jobs created'); - } - - @bindThis - public async processDb(job: Bull.Job): Promise { - const line = job.data.target; - const user = job.data.user; - - try { - const acct = line.split(',')[0].trim(); - const { username, host } = Acct.parse(acct); - - if (!host) return; - - let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({ - host: IsNull(), - usernameLower: username.toLowerCase(), - }) : await this.usersRepository.findOneBy({ - host: this.utilityService.toPuny(host), - usernameLower: username.toLowerCase(), - }); - - if (host == null && target == null) return; - - if (target == null) { - target = await this.remoteUserResolveService.resolveUser(username, host); - } - - if (target == null) { - throw new Error(`Unable to resolve user: @${username}@${host}`); - } - - // skip myself - if (target.id === job.data.user.id) return; - - this.logger.info(`Block ${target.id} ...`); - - this.queueService.createBlockJob([{ from: { id: user.id }, to: { id: target.id }, silent: true }]); - } catch (e) { - this.logger.warn(`Error: ${e}`); - } - } -} diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts deleted file mode 100644 index a52af54a39..0000000000 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ /dev/null @@ -1,120 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as fs from 'node:fs'; -import { Inject, Injectable } from '@nestjs/common'; -import { ZipReader } from 'slacc'; -import { DI } from '@/di-symbols.js'; -import type { EmojisRepository, DriveFilesRepository } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import { CustomEmojiService } from '@/core/CustomEmojiService.js'; -import { createTempDir } from '@/misc/create-temp.js'; -import { DriveService } from '@/core/DriveService.js'; -import { DownloadService } from '@/core/DownloadService.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; -import type { DbUserImportJobData } from '../types.js'; - -// TODO: 名前衝突時の動作を選べるようにする -@Injectable() -export class ImportCustomEmojisProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private customEmojiService: CustomEmojiService, - private driveService: DriveService, - private downloadService: DownloadService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('import-custom-emojis'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - this.logger.info('Importing custom emojis ...'); - - const file = await this.driveFilesRepository.findOneBy({ - id: job.data.fileId, - }); - if (file == null) { - return; - } - - const [path, cleanup] = await createTempDir(); - - this.logger.info(`Temp dir is ${path}`); - - const destPath = path + '/emojis.zip'; - - try { - fs.writeFileSync(destPath, '', 'binary'); - await this.downloadService.downloadUrl(file.url, destPath); - } catch (e) { // TODO: 何度か再試行 - if (e instanceof Error || typeof e === 'string') { - this.logger.error(e); - } - throw e; - } - - const outputPath = path + '/emojis'; - try { - this.logger.succ(`Unzipping to ${outputPath}`); - ZipReader.withDestinationPath(outputPath).viaBuffer(await fs.promises.readFile(destPath)); - const metaRaw = fs.readFileSync(outputPath + '/meta.json', 'utf-8'); - const meta = JSON.parse(metaRaw); - - for (const record of meta.emojis) { - if (!record.downloaded) continue; - if (!/^[a-zA-Z0-9_]+?([a-zA-Z0-9\.]+)?$/.test(record.fileName)) { - this.logger.error(`invalid filename: ${record.fileName}`); - continue; - } - const emojiInfo = record.emoji; - if (!/^[a-zA-Z0-9_]+$/.test(emojiInfo.name)) { - this.logger.error(`invalid emojiname: ${emojiInfo.name}`); - continue; - } - const emojiPath = outputPath + '/' + record.fileName; - await this.emojisRepository.delete({ - name: emojiInfo.name, - }); - const driveFile = await this.driveService.addFile({ - user: null, - path: emojiPath, - name: record.fileName, - force: true, - }); - await this.customEmojiService.add({ - name: emojiInfo.name, - category: emojiInfo.category, - host: null, - aliases: emojiInfo.aliases, - driveFile, - license: emojiInfo.license, - isSensitive: emojiInfo.isSensitive, - localOnly: emojiInfo.localOnly, - roleIdsThatCanBeUsedThisEmojiAsReaction: [], - }); - } - - cleanup(); - - this.logger.succ('Imported'); - } catch (e) { - if (e instanceof Error || typeof e === 'string') { - this.logger.error(e); - } - cleanup(); - throw e; - } - } -} diff --git a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts deleted file mode 100644 index e75499a56f..0000000000 --- a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { UsersRepository, DriveFilesRepository } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import * as Acct from '@/misc/acct.js'; -import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; -import { DownloadService } from '@/core/DownloadService.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; -import { QueueService } from '@/core/QueueService.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; -import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js'; - -@Injectable() -export class ImportFollowingProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private queueService: QueueService, - private utilityService: UtilityService, - private remoteUserResolveService: RemoteUserResolveService, - private downloadService: DownloadService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('import-following'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - this.logger.info(`Importing following of ${job.data.user.id} ...`); - - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - const file = await this.driveFilesRepository.findOneBy({ - id: job.data.fileId, - }); - if (file == null) { - return; - } - - const csv = await this.downloadService.downloadTextFile(file.url); - const targets = csv.trim().split('\n'); - this.queueService.createImportFollowingToDbJob({ id: user.id }, targets, job.data.withReplies); - - this.logger.succ('Import jobs created'); - } - - @bindThis - public async processDb(job: Bull.Job): Promise { - const line = job.data.target; - const user = job.data.user; - - try { - const acct = line.split(',')[0].trim(); - const { username, host } = Acct.parse(acct); - - if (!host) return; - - let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({ - host: IsNull(), - usernameLower: username.toLowerCase(), - }) : await this.usersRepository.findOneBy({ - host: this.utilityService.toPuny(host), - usernameLower: username.toLowerCase(), - }); - - if (host == null && target == null) return; - - if (target == null) { - target = await this.remoteUserResolveService.resolveUser(username, host); - } - - if (target == null) { - throw new Error(`Unable to resolve user: @${username}@${host}`); - } - - // skip myself - if (target.id === job.data.user.id) return; - - this.logger.info(`Follow ${target.id} ${job.data.withReplies ? 'with replies' : 'without replies'} ...`); - - this.queueService.createFollowJob([{ from: user, to: { id: target.id }, silent: true, withReplies: job.data.withReplies }]); - } catch (e) { - this.logger.warn(`Error: ${e}`); - } - } -} diff --git a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts deleted file mode 100644 index 9db4e5d8e0..0000000000 --- a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { UsersRepository, DriveFilesRepository } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import * as Acct from '@/misc/acct.js'; -import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; -import { DownloadService } from '@/core/DownloadService.js'; -import { UserMutingService } from '@/core/UserMutingService.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; -import type { DbUserImportJobData } from '../types.js'; - -@Injectable() -export class ImportMutingProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private utilityService: UtilityService, - private userMutingService: UserMutingService, - private remoteUserResolveService: RemoteUserResolveService, - private downloadService: DownloadService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('import-muting'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - this.logger.info(`Importing muting of ${job.data.user.id} ...`); - - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - const file = await this.driveFilesRepository.findOneBy({ - id: job.data.fileId, - }); - if (file == null) { - return; - } - - const csv = await this.downloadService.downloadTextFile(file.url); - - let linenum = 0; - - for (const line of csv.trim().split('\n')) { - linenum++; - - try { - const acct = line.split(',')[0].trim(); - const { username, host } = Acct.parse(acct); - - if (!host) continue; - - let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({ - host: IsNull(), - usernameLower: username.toLowerCase(), - }) : await this.usersRepository.findOneBy({ - host: this.utilityService.toPuny(host), - usernameLower: username.toLowerCase(), - }); - - if (host == null && target == null) continue; - - if (target == null) { - target = await this.remoteUserResolveService.resolveUser(username, host); - } - - if (target == null) { - throw new Error(`cannot resolve user: @${username}@${host}`); - } - - // skip myself - if (target.id === job.data.user.id) continue; - - this.logger.info(`Mute[${linenum}] ${target.id} ...`); - - await this.userMutingService.mute(user, target); - } catch (e) { - this.logger.warn(`Error in line:${linenum} ${e}`); - } - } - - this.logger.succ('Imported'); - } -} diff --git a/packages/backend/src/queue/processors/ImportNotesProcessorService.ts b/packages/backend/src/queue/processors/ImportNotesProcessorService.ts deleted file mode 100644 index d64a861b03..0000000000 --- a/packages/backend/src/queue/processors/ImportNotesProcessorService.ts +++ /dev/null @@ -1,709 +0,0 @@ -import * as fs from 'node:fs'; -import * as vm from 'node:vm'; -import * as crypto from 'node:crypto'; -import { Inject, Injectable } from '@nestjs/common'; -import { ZipReader } from 'slacc'; -import { DI } from '@/di-symbols.js'; -import type { UsersRepository, DriveFilesRepository, MiDriveFile, MiNote, NotesRepository, MiUser, DriveFoldersRepository, MiDriveFolder } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import { DownloadService } from '@/core/DownloadService.js'; -import { bindThis } from '@/decorators.js'; -import { QueueService } from '@/core/QueueService.js'; -import { createTemp, createTempDir } from '@/misc/create-temp.js'; -import { NoteCreateService } from '@/core/NoteCreateService.js'; -import { DriveService } from '@/core/DriveService.js'; -import { MfmService } from '@/core/MfmService.js'; -import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; -import { extractApHashtagObjects } from '@/core/activitypub/models/tag.js'; -import { IdService } from '@/core/IdService.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; -import type { DbNoteImportToDbJobData, DbNoteImportJobData, DbNoteWithParentImportToDbJobData } from '../types.js'; - -@Injectable() -export class ImportNotesProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - @Inject(DI.driveFoldersRepository) - private driveFoldersRepository: DriveFoldersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - private queueService: QueueService, - private noteCreateService: NoteCreateService, - private mfmService: MfmService, - private apNoteService: ApNoteService, - private driveService: DriveService, - private downloadService: DownloadService, - private idService: IdService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('import-notes'); - } - - @bindThis - private async uploadFiles(dir: string, user: MiUser, folder?: MiDriveFolder['id']) { - const fileList = fs.readdirSync(dir); - for await (const file of fileList) { - const name = `${dir}/${file}`; - if (fs.statSync(name).isDirectory()) { - await this.uploadFiles(name, user, folder); - } else { - const exists = await this.driveFilesRepository.findOneBy({ name: file, userId: user.id, folderId: folder }); - - if (file.endsWith('.srt')) return; - - if (!exists) { - await this.driveService.addFile({ - user: user, - path: name, - name: file, - folderId: folder, - }); - } - } - } - } - - @bindThis - private async recreateChain(idFieldPath: string[], replyFieldPath: string[], arr: any[], includeOrphans: boolean): Promise { - type NotesMap = { - [id: string]: any; - }; - const notesTree: any[] = []; - const noteById: NotesMap = {}; - const notesWaitingForParent: NotesMap = {}; - - for await (const note of arr) { - const noteId = idFieldPath.reduce( - (obj, step) => obj[step], - note, - ); - - noteById[noteId] = note; - note.childNotes = []; - - const children = notesWaitingForParent[noteId]; - if (children) { - note.childNotes.push(...children); - delete notesWaitingForParent[noteId]; - } - - const noteReplyId = replyFieldPath.reduce( - (obj, step) => obj[step], - note, - ); - if (noteReplyId == null) { - notesTree.push(note); - continue; - } - - const parent = noteById[noteReplyId]; - if (parent) { - parent.childNotes.push(note); - } else { - notesWaitingForParent[noteReplyId] ||= []; - notesWaitingForParent[noteReplyId].push(note); - } - } - - if (includeOrphans) { - notesTree.push(...Object.values(notesWaitingForParent).flat(1)); - } - - return notesTree; - } - - @bindThis - private isIterable(obj: any) { - if (obj == null) { - return false; - } - return typeof obj[Symbol.iterator] === 'function'; - } - - private parseTwitterFile(str : string) : null | [{ tweet: any }] { - const removed = str.replace(new RegExp('window\\.YTD\\.tweets\\.part0 = ', 'g'), ''); - - try { - return JSON.parse(removed); - } catch (error) { - //The format is not what we expected. Either this file was tampered with or twitters exports changed - return null; - } - } - - @bindThis - public async process(job: Bull.Job): Promise { - this.logger.info(`Starting note import of ${job.data.user.id} ...`); - - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - const file = await this.driveFilesRepository.findOneBy({ - id: job.data.fileId, - }); - if (file == null) { - return; - } - - let folder = await this.driveFoldersRepository.findOneBy({ name: 'Imports', userId: job.data.user.id }); - if (folder == null) { - await this.driveFoldersRepository.insert({ id: this.idService.gen(), name: 'Imports', userId: job.data.user.id }); - folder = await this.driveFoldersRepository.findOneBy({ name: 'Imports', userId: job.data.user.id }); - } - - const type = job.data.type; - - if (type === 'Twitter' || file.name.startsWith('twitter') && file.name.endsWith('.zip')) { - const [path, cleanup] = await createTempDir(); - - this.logger.info(`Temp dir is ${path}`); - - const destPath = path + '/twitter.zip'; - - try { - fs.writeFileSync(destPath, '', 'binary'); - await this.downloadService.downloadUrl(file.url, destPath); - } catch (e) { // TODO: 何度か再試行 - if (e instanceof Error || typeof e === 'string') { - this.logger.error(e); - } - throw e; - } - - const outputPath = path + '/twitter'; - try { - this.logger.succ(`Unzipping to ${outputPath}`); - ZipReader.withDestinationPath(outputPath).viaBuffer(await fs.promises.readFile(destPath)); - - const unprocessedTweetJson = this.parseTwitterFile(fs.readFileSync(outputPath + '/data/tweets.js', 'utf-8')); - - //Make sure that it isnt null (because if something went wrong in parseTwitterFile it returns null) - if (unprocessedTweetJson) { - const tweets = Object.keys(unprocessedTweetJson).reduce((m, key, i, obj) => { - return m.concat(unprocessedTweetJson[i].tweet); - }, []); - - const processedTweets = await this.recreateChain(['id_str'], ['in_reply_to_status_id_str'], tweets, false); - this.queueService.createImportTweetsToDbJob(job.data.user, processedTweets, null); - } else { - this.logger.warn('Failed to import twitter notes due to malformed file'); - } - } finally { - cleanup(); - } - } else if (type === 'Facebook' || file.name.startsWith('facebook-') && file.name.endsWith('.zip')) { - const [path, cleanup] = await createTempDir(); - - this.logger.info(`Temp dir is ${path}`); - - const destPath = path + '/facebook.zip'; - - try { - fs.writeFileSync(destPath, '', 'binary'); - await this.downloadService.downloadUrl(file.url, destPath); - } catch (e) { // TODO: 何度か再試行 - if (e instanceof Error || typeof e === 'string') { - this.logger.error(e); - } - throw e; - } - - const outputPath = path + '/facebook'; - try { - this.logger.succ(`Unzipping to ${outputPath}`); - ZipReader.withDestinationPath(outputPath).viaBuffer(await fs.promises.readFile(destPath)); - const postsJson = fs.readFileSync(outputPath + '/your_activity_across_facebook/posts/your_posts__check_ins__photos_and_videos_1.json', 'utf-8'); - const posts = JSON.parse(postsJson); - const facebookFolder = await this.driveFoldersRepository.findOneBy({ name: 'Facebook', userId: job.data.user.id, parentId: folder?.id }); - if (facebookFolder == null && folder) { - await this.driveFoldersRepository.insert({ id: this.idService.gen(), name: 'Facebook', userId: job.data.user.id, parentId: folder.id }); - const createdFolder = await this.driveFoldersRepository.findOneBy({ name: 'Facebook', userId: job.data.user.id, parentId: folder.id }); - if (createdFolder) await this.uploadFiles(outputPath + '/your_activity_across_facebook/posts/media', user, createdFolder.id); - } - this.queueService.createImportFBToDbJob(job.data.user, posts); - } finally { - cleanup(); - } - } else if (file.name.endsWith('.zip')) { - const [path, cleanup] = await createTempDir(); - - this.logger.info(`Temp dir is ${path}`); - - const destPath = path + '/unknown.zip'; - - try { - fs.writeFileSync(destPath, '', 'binary'); - await this.downloadService.downloadUrl(file.url, destPath); - } catch (e) { // TODO: 何度か再試行 - if (e instanceof Error || typeof e === 'string') { - this.logger.error(e); - } - throw e; - } - - const outputPath = path + '/unknown'; - try { - this.logger.succ(`Unzipping to ${outputPath}`); - ZipReader.withDestinationPath(outputPath).viaBuffer(await fs.promises.readFile(destPath)); - const isInstagram = type === 'Instagram' || fs.existsSync(outputPath + '/instagram_live') || fs.existsSync(outputPath + '/instagram_ads_and_businesses'); - const isOutbox = type === 'Mastodon' || fs.existsSync(outputPath + '/outbox.json'); - if (isInstagram) { - const postsJson = fs.readFileSync(outputPath + '/content/posts_1.json', 'utf-8'); - const posts = JSON.parse(postsJson); - const igFolder = await this.driveFoldersRepository.findOneBy({ name: 'Instagram', userId: job.data.user.id, parentId: folder?.id }); - if (igFolder == null && folder) { - await this.driveFoldersRepository.insert({ id: this.idService.gen(), name: 'Instagram', userId: job.data.user.id, parentId: folder.id }); - const createdFolder = await this.driveFoldersRepository.findOneBy({ name: 'Instagram', userId: job.data.user.id, parentId: folder.id }); - if (createdFolder) await this.uploadFiles(outputPath + '/media/posts', user, createdFolder.id); - } - this.queueService.createImportIGToDbJob(job.data.user, posts); - } else if (isOutbox) { - const actorJson = fs.readFileSync(outputPath + '/actor.json', 'utf-8'); - const actor = JSON.parse(actorJson); - const isPleroma = actor['@context'].some((v: any) => typeof v === 'string' && v.match(/litepub(.*)/)); - if (isPleroma) { - const outboxJson = fs.readFileSync(outputPath + '/outbox.json', 'utf-8'); - const outbox = JSON.parse(outboxJson); - const processedToots = await this.recreateChain(['object', 'id'], ['object', 'inReplyTo'], outbox.orderedItems.filter((x: any) => x.type === 'Create' && x.object.type === 'Note'), true); - this.queueService.createImportPleroToDbJob(job.data.user, processedToots, null); - } else { - const outboxJson = fs.readFileSync(outputPath + '/outbox.json', 'utf-8'); - const outbox = JSON.parse(outboxJson); - let mastoFolder = await this.driveFoldersRepository.findOneBy({ name: 'Mastodon', userId: job.data.user.id, parentId: folder?.id }); - if (mastoFolder == null && folder) { - await this.driveFoldersRepository.insert({ id: this.idService.gen(), name: 'Mastodon', userId: job.data.user.id, parentId: folder.id }); - mastoFolder = await this.driveFoldersRepository.findOneBy({ name: 'Mastodon', userId: job.data.user.id, parentId: folder.id }); - } - if (fs.existsSync(outputPath + '/media_attachments/files') && mastoFolder) { - await this.uploadFiles(outputPath + '/media_attachments/files', user, mastoFolder.id); - } - const processedToots = await this.recreateChain(['object', 'id'], ['object', 'inReplyTo'], outbox.orderedItems.filter((x: any) => x.type === 'Create' && x.object.type === 'Note'), true); - this.queueService.createImportMastoToDbJob(job.data.user, processedToots, null); - } - } - } finally { - cleanup(); - } - } else if (job.data.type === 'Misskey' || file.name.startsWith('notes-') && file.name.endsWith('.json')) { - const [path, cleanup] = await createTemp(); - - this.logger.info(`Temp dir is ${path}`); - - try { - fs.writeFileSync(path, '', 'utf-8'); - await this.downloadService.downloadUrl(file.url, path); - } catch (e) { // TODO: 何度か再試行 - if (e instanceof Error || typeof e === 'string') { - this.logger.error(e); - } - throw e; - } - - const notesJson = fs.readFileSync(path, 'utf-8'); - const notes = JSON.parse(notesJson); - const processedNotes = await this.recreateChain(['id'], ['replyId'], notes, false); - this.queueService.createImportKeyNotesToDbJob(job.data.user, processedNotes, null); - cleanup(); - } - - this.logger.succ('Import jobs created'); - } - - @bindThis - public async processKeyNotesToDb(job: Bull.Job): Promise { - const note = job.data.target; - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - if (note.renoteId) return; - - const parentNote = job.data.note ? await this.notesRepository.findOneBy({ id: job.data.note }) : null; - - const folder = await this.driveFoldersRepository.findOneBy({ name: 'Imports', userId: job.data.user.id }); - if (folder == null) return; - - const files: MiDriveFile[] = []; - const date = new Date(note.createdAt); - - if (note.files && this.isIterable(note.files)) { - let keyFolder = await this.driveFoldersRepository.findOneBy({ name: 'Misskey', userId: job.data.user.id, parentId: folder.id }); - if (keyFolder == null) { - await this.driveFoldersRepository.insert({ id: this.idService.gen(), name: 'Misskey', userId: job.data.user.id, parentId: folder.id }); - keyFolder = await this.driveFoldersRepository.findOneBy({ name: 'Misskey', userId: job.data.user.id, parentId: folder.id }); - } - - for await (const file of note.files) { - const [filePath, cleanup] = await createTemp(); - const slashdex = file.url.lastIndexOf('/'); - const name = file.url.substring(slashdex + 1); - - const exists = await this.driveFilesRepository.findOneBy({ name: name, userId: user.id }) ?? await this.driveFilesRepository.findOneBy({ name: name, userId: user.id, folderId: keyFolder?.id }); - - if (!exists) { - try { - await this.downloadService.downloadUrl(file.url, filePath); - } catch (e) { // TODO: 何度か再試行 - this.logger.error(e instanceof Error ? e : new Error(e as string)); - } - const driveFile = await this.driveService.addFile({ - user: user, - path: filePath, - name: name, - folderId: keyFolder?.id, - }); - files.push(driveFile); - } else { - files.push(exists); - } - - cleanup(); - } - } - - const createdNote = await this.noteCreateService.import(user, { createdAt: date, reply: parentNote, text: note.text, apMentions: new Array(0), visibility: note.visibility, localOnly: note.localOnly, files: files, cw: note.cw }); - if (note.childNotes) this.queueService.createImportKeyNotesToDbJob(user, note.childNotes, createdNote.id); - } - - @bindThis - public async processMastoToDb(job: Bull.Job): Promise { - const toot = job.data.target; - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - const followers = toot.to.some((str: string) => str.includes('/followers')); - - if (toot.directMessage || !toot.to.includes('https://www.w3.org/ns/activitystreams#Public') && !followers) return; - - const visibility = followers ? toot.cc.includes('https://www.w3.org/ns/activitystreams#Public') ? 'home' : 'followers' : 'public'; - - const date = new Date(toot.object.published); - let text = undefined; - const files: MiDriveFile[] = []; - let reply: MiNote | null = null; - - if (toot.object.inReplyTo != null) { - const parentNote = job.data.note ? await this.notesRepository.findOneBy({ id: job.data.note }) : null; - if (parentNote) { - reply = parentNote; - } else { - try { - reply = await this.apNoteService.resolveNote(toot.object.inReplyTo); - } catch (error) { - reply = null; - } - } - } - - const hashtags = extractApHashtagObjects(toot.object.tag).map((x) => x.name).filter((x): x is string => x != null); - - try { - text = await this.mfmService.fromHtml(toot.object.content, hashtags); - } catch (error) { - text = undefined; - } - - if (toot.object.attachment && this.isIterable(toot.object.attachment)) { - for await (const file of toot.object.attachment) { - const slashdex = file.url.lastIndexOf('/'); - const name = file.url.substring(slashdex + 1); - const exists = await this.driveFilesRepository.findOneBy({ name: name, userId: user.id }); - if (exists) { - files.push(exists); - } - } - } - - const createdNote = await this.noteCreateService.import(user, { createdAt: date, text: text, files: files, visibility: visibility, apMentions: new Array(0), cw: toot.object.sensitive ? toot.object.summary : null, reply: reply }); - if (toot.childNotes) this.queueService.createImportMastoToDbJob(user, toot.childNotes, createdNote.id); - } - - @bindThis - public async processPleroToDb(job: Bull.Job): Promise { - const post = job.data.target; - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - if (post.directMessage) return; - - const date = new Date(post.object.published); - let text = undefined; - const files: MiDriveFile[] = []; - let reply: MiNote | null = null; - - const folder = await this.driveFoldersRepository.findOneBy({ name: 'Imports', userId: job.data.user.id }); - if (folder == null) return; - - if (post.object.inReplyTo != null) { - const parentNote = job.data.note ? await this.notesRepository.findOneBy({ id: job.data.note }) : null; - if (parentNote) { - reply = parentNote; - } else { - try { - reply = await this.apNoteService.resolveNote(post.object.inReplyTo); - } catch (error) { - reply = null; - } - } - } - - const hashtags = extractApHashtagObjects(post.object.tag).map((x) => x.name).filter((x): x is string => x != null); - - try { - text = await this.mfmService.fromHtml(post.object.content, hashtags); - } catch (error) { - text = undefined; - } - - if (post.object.attachment && this.isIterable(post.object.attachment)) { - let pleroFolder = await this.driveFoldersRepository.findOneBy({ name: 'Pleroma', userId: job.data.user.id, parentId: folder.id }); - if (pleroFolder == null) { - await this.driveFoldersRepository.insert({ id: this.idService.gen(), name: 'Pleroma', userId: job.data.user.id, parentId: folder.id }); - pleroFolder = await this.driveFoldersRepository.findOneBy({ name: 'Pleroma', userId: job.data.user.id, parentId: folder.id }); - } - - for await (const file of post.object.attachment) { - const slashdex = file.url.lastIndexOf('/'); - const filename = file.url.substring(slashdex + 1); - const hash = crypto.createHash('md5').update(file.url).digest('base64url'); - const name = `${hash}-${filename}`; - const [filePath, cleanup] = await createTemp(); - - const exists = await this.driveFilesRepository.findOneBy({ name: name, userId: user.id }) ?? await this.driveFilesRepository.findOneBy({ name: name, userId: user.id, folderId: pleroFolder?.id }); - - if (!exists) { - try { - await this.downloadService.downloadUrl(file.url, filePath); - } catch (e) { // TODO: 何度か再試行 - this.logger.error(e instanceof Error ? e : new Error(e as string)); - } - const driveFile = await this.driveService.addFile({ - user: user, - path: filePath, - name: name, - comment: file.name, - folderId: pleroFolder?.id, - }); - files.push(driveFile); - } else { - files.push(exists); - } - - cleanup(); - } - } - - const createdNote = await this.noteCreateService.import(user, { createdAt: date, text: text, files: files, apMentions: new Array(0), cw: post.object.sensitive ? post.object.summary : null, reply: reply }); - if (post.childNotes) this.queueService.createImportPleroToDbJob(user, post.childNotes, createdNote.id); - } - - @bindThis - public async processIGDb(job: Bull.Job): Promise { - const post = job.data.target; - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - let date; - let title; - const files: MiDriveFile[] = []; - - function decodeIGString(str: string) { - const arr = []; - for (let i = 0; i < str.length; i++) { - arr.push(str.charCodeAt(i)); - } - return Buffer.from(arr).toString('utf8'); - } - - if (post.media && this.isIterable(post.media) && post.media.length > 1) { - date = new Date(post.creation_timestamp * 1000); - title = decodeIGString(post.title); - for await (const file of post.media) { - const slashdex = file.uri.lastIndexOf('/'); - const name = file.uri.substring(slashdex + 1); - const exists = await this.driveFilesRepository.findOneBy({ name: name, userId: user.id }) ?? await this.driveFilesRepository.findOneBy({ name: `${name}.jpg`, userId: user.id }) ?? await this.driveFilesRepository.findOneBy({ name: `${name}.mp4`, userId: user.id }); - if (exists) { - files.push(exists); - } - } - } else if (post.media && this.isIterable(post.media) && !(post.media.length > 1)) { - date = new Date(post.media[0].creation_timestamp * 1000); - title = decodeIGString(post.media[0].title); - const slashdex = post.media[0].uri.lastIndexOf('/'); - const name = post.media[0].uri.substring(slashdex + 1); - const exists = await this.driveFilesRepository.findOneBy({ name: name, userId: user.id }) ?? await this.driveFilesRepository.findOneBy({ name: `${name}.jpg`, userId: user.id }) ?? await this.driveFilesRepository.findOneBy({ name: `${name}.mp4`, userId: user.id }); - if (exists) { - files.push(exists); - } - } - - await this.noteCreateService.import(user, { createdAt: date, text: title, files: files }); - } - - @bindThis - public async processTwitterDb(job: Bull.Job): Promise { - const tweet = job.data.target; - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - const folder = await this.driveFoldersRepository.findOneBy({ name: 'Imports', userId: job.data.user.id }); - if (folder == null) return; - - const parentNote = job.data.note ? await this.notesRepository.findOneBy({ id: job.data.note }) : null; - - async function replaceTwitterUrls(full_text: string, urls: any) { - let full_textedit = full_text; - urls.forEach((url: any) => { - full_textedit = full_textedit.replaceAll(url.url, url.expanded_url); - }); - return full_textedit; - } - - async function replaceTwitterMentions(full_text: string, mentions: any) { - let full_textedit = full_text; - mentions.forEach((mention: any) => { - full_textedit = full_textedit.replaceAll(`@${mention.screen_name}`, `[@${mention.screen_name}](https://nitter.net/${mention.screen_name})`); - }); - return full_textedit; - } - - try { - const date = new Date(tweet.created_at); - const textReplaceURLs = tweet.entities.urls && tweet.entities.urls.length > 0 ? await replaceTwitterUrls(tweet.full_text, tweet.entities.urls) : tweet.full_text; - const text = tweet.entities.user_mentions && tweet.entities.user_mentions.length > 0 ? await replaceTwitterMentions(textReplaceURLs, tweet.entities.user_mentions) : textReplaceURLs; - const files: MiDriveFile[] = []; - - if (tweet.extended_entities && this.isIterable(tweet.extended_entities.media)) { - let twitFolder = await this.driveFoldersRepository.findOneBy({ name: 'Twitter', userId: job.data.user.id, parentId: folder.id }); - if (twitFolder == null) { - await this.driveFoldersRepository.insert({ id: this.idService.gen(), name: 'Twitter', userId: job.data.user.id, parentId: folder.id }); - twitFolder = await this.driveFoldersRepository.findOneBy({ name: 'Twitter', userId: job.data.user.id, parentId: folder.id }); - } - - for await (const file of tweet.extended_entities.media) { - if (file.video_info) { - const [filePath, cleanup] = await createTemp(); - const slashdex = file.video_info.variants[0].url.lastIndexOf('/'); - const name = file.video_info.variants[0].url.substring(slashdex + 1); - - const exists = await this.driveFilesRepository.findOneBy({ name: name, userId: user.id }) ?? await this.driveFilesRepository.findOneBy({ name: name, userId: user.id, folderId: twitFolder?.id }); - - const videos = file.video_info.variants.filter((x: any) => x.content_type === 'video/mp4'); - - if (!exists) { - try { - await this.downloadService.downloadUrl(videos[0].url, filePath); - } catch (e) { // TODO: 何度か再試行 - this.logger.error(e instanceof Error ? e : new Error(e as string)); - } - const driveFile = await this.driveService.addFile({ - user: user, - path: filePath, - name: name, - folderId: twitFolder?.id, - }); - files.push(driveFile); - } else { - files.push(exists); - } - - cleanup(); - } else if (file.media_url_https) { - const [filePath, cleanup] = await createTemp(); - const slashdex = file.media_url_https.lastIndexOf('/'); - const name = file.media_url_https.substring(slashdex + 1); - - const exists = await this.driveFilesRepository.findOneBy({ name: name, userId: user.id }); - - if (!exists) { - try { - await this.downloadService.downloadUrl(file.media_url_https, filePath); - } catch (e) { // TODO: 何度か再試行 - this.logger.error(e instanceof Error ? e : new Error(e as string)); - } - - const driveFile = await this.driveService.addFile({ - user: user, - path: filePath, - name: name, - folderId: twitFolder?.id, - }); - files.push(driveFile); - } else { - files.push(exists); - } - cleanup(); - } - } - } - const createdNote = await this.noteCreateService.import(user, { createdAt: date, reply: parentNote, text: text, files: files }); - if (tweet.childNotes) this.queueService.createImportTweetsToDbJob(user, tweet.childNotes, createdNote.id); - } catch (e) { - this.logger.warn(`Error: ${e}`); - } - } - - @bindThis - public async processFBDb(job: Bull.Job): Promise { - const post = job.data.target; - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - if (!this.isIterable(post.data) || this.isIterable(post.data) && post.data[0].post === undefined) return; - - const date = new Date(post.timestamp * 1000); - const title = decodeFBString(post.data[0].post); - const files: MiDriveFile[] = []; - - function decodeFBString(str: string) { - const arr = []; - for (let i = 0; i < str.length; i++) { - arr.push(str.charCodeAt(i)); - } - return Buffer.from(arr).toString('utf8'); - } - - if (post.attachments && this.isIterable(post.attachments)) { - const media = []; - for await (const data of post.attachments[0].data) { - if (data.media) { - media.push(data.media); - } - } - - for await (const file of media) { - const slashdex = file.uri.lastIndexOf('/'); - const name = file.uri.substring(slashdex + 1); - const exists = await this.driveFilesRepository.findOneBy({ name: name, userId: user.id }); - if (exists) { - files.push(exists); - } - } - } - - await this.noteCreateService.import(user, { createdAt: date, text: title, files: files }); - } -} diff --git a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts deleted file mode 100644 index 5dd3fbe887..0000000000 --- a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { UsersRepository, DriveFilesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import * as Acct from '@/misc/acct.js'; -import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; -import { DownloadService } from '@/core/DownloadService.js'; -import { UserListService } from '@/core/UserListService.js'; -import { IdService } from '@/core/IdService.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; -import type { DbUserImportJobData } from '../types.js'; - -@Injectable() -export class ImportUserListsProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - private utilityService: UtilityService, - private idService: IdService, - private userListService: UserListService, - private remoteUserResolveService: RemoteUserResolveService, - private downloadService: DownloadService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('import-user-lists'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - this.logger.info(`Importing user lists of ${job.data.user.id} ...`); - - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); - if (user == null) { - return; - } - - const file = await this.driveFilesRepository.findOneBy({ - id: job.data.fileId, - }); - if (file == null) { - return; - } - - const csv = await this.downloadService.downloadTextFile(file.url); - - let linenum = 0; - - for (const line of csv.trim().split('\n')) { - linenum++; - - try { - const listName = line.split(',')[0].trim(); - const { username, host } = Acct.parse(line.split(',')[1].trim()); - - let list = await this.userListsRepository.findOneBy({ - userId: user.id, - name: listName, - }); - - if (list == null) { - list = await this.userListsRepository.insert({ - id: this.idService.gen(), - userId: user.id, - name: listName, - }).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0])); - } - - let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({ - host: IsNull(), - usernameLower: username.toLowerCase(), - }) : await this.usersRepository.findOneBy({ - host: this.utilityService.toPuny(host!), - usernameLower: username.toLowerCase(), - }); - - if (target == null) { - target = await this.remoteUserResolveService.resolveUser(username, host); - } - - if (await this.userListMembershipsRepository.findOneBy({ userListId: list!.id, userId: target.id }) != null) continue; - - this.userListService.addMember(target, list!, user); - } catch (e) { - this.logger.warn(`Error in line:${linenum} ${e}`); - } - } - - this.logger.succ('Imported'); - } -} diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts deleted file mode 100644 index 1d89912bf6..0000000000 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ /dev/null @@ -1,204 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { URL } from 'node:url'; -import { Injectable } from '@nestjs/common'; -import httpSignature from '@peertube/http-signature'; -import * as Bull from 'bullmq'; -import type Logger from '@/logger.js'; -import { MetaService } from '@/core/MetaService.js'; -import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; -import InstanceChart from '@/core/chart/charts/instance.js'; -import ApRequestChart from '@/core/chart/charts/ap-request.js'; -import FederationChart from '@/core/chart/charts/federation.js'; -import { getApId } from '@/core/activitypub/type.js'; -import type { MiRemoteUser } from '@/models/User.js'; -import type { MiUserPublickey } from '@/models/UserPublickey.js'; -import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; -import { StatusError } from '@/misc/status-error.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; -import { LdSignatureService } from '@/core/activitypub/LdSignatureService.js'; -import { ApInboxService } from '@/core/activitypub/ApInboxService.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type { InboxJobData } from '../types.js'; - -@Injectable() -export class InboxProcessorService { - private logger: Logger; - - constructor( - private utilityService: UtilityService, - private metaService: MetaService, - private apInboxService: ApInboxService, - private federatedInstanceService: FederatedInstanceService, - private fetchInstanceMetadataService: FetchInstanceMetadataService, - private ldSignatureService: LdSignatureService, - private apPersonService: ApPersonService, - private apDbResolverService: ApDbResolverService, - private instanceChart: InstanceChart, - private apRequestChart: ApRequestChart, - private federationChart: FederationChart, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('inbox'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - const signature = job.data.signature; // HTTP-signature - const activity = job.data.activity; - - //#region Log - const info = Object.assign({}, activity); - delete info['@context']; - this.logger.debug(JSON.stringify(info, null, 2)); - //#endregion - - const host = this.utilityService.toPuny(new URL(signature.keyId).hostname); - - // ブロックしてたら中断 - const meta = await this.metaService.fetch(); - if (this.utilityService.isBlockedHost(meta.blockedHosts, host)) { - return `Blocked request: ${host}`; - } - - if (meta.allowlistMode && !this.utilityService.isAllowedHost(meta.allowedHosts, host)) { - return `Blocked request: ${host}`; - } - - const keyIdLower = signature.keyId.toLowerCase(); - if (keyIdLower.startsWith('acct:')) { - return `Old keyId is no longer supported. ${keyIdLower}`; - } - - // HTTP-Signature keyIdを元にDBから取得 - let authUser: { - user: MiRemoteUser; - key: MiUserPublickey | null; - } | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId); - - // keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得 - if (authUser == null) { - try { - authUser = await this.apDbResolverService.getAuthUserFromApId(getApId(activity.actor)); - } catch (err) { - // 対象が4xxならスキップ - if (err instanceof StatusError) { - if (err.isClientError) { - throw new Bull.UnrecoverableError(`skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`); - } - throw new Error(`Error in actor ${activity.actor} - ${err.statusCode}`); - } - } - } - - // それでもわからなければ終了 - if (authUser == null) { - throw new Bull.UnrecoverableError('skip: failed to resolve user'); - } - - // publicKey がなくても終了 - if (authUser.key == null) { - throw new Bull.UnrecoverableError('skip: failed to resolve user publicKey'); - } - - // HTTP-Signatureの検証 - let httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); - - // また、signatureのsignerは、activity.actorと一致する必要がある - if (!httpSignatureValidated || authUser.user.uri !== activity.actor) { - let renewKeyFailed = true; - - if (!httpSignatureValidated) { - authUser.key = await this.apDbResolverService.refetchPublicKeyForApId(authUser.user); - - if (authUser.key != null) { - httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); - renewKeyFailed = false; - } - } - - // 一致しなくても、でもLD-Signatureがありそうならそっちも見る - if (activity.signature && renewKeyFailed) { - if (activity.signature.type !== 'RsaSignature2017') { - throw new Bull.UnrecoverableError(`skip: unsupported LD-signature type ${activity.signature.type}`); - } - - // activity.signature.creator: https://example.oom/users/user#main-key - // みたいになっててUserを引っ張れば公開キーも入ることを期待する - if (activity.signature.creator) { - const candicate = activity.signature.creator.replace(/#.*/, ''); - await this.apPersonService.resolvePerson(candicate).catch(() => null); - } - - // keyIdからLD-Signatureのユーザーを取得 - authUser = await this.apDbResolverService.getAuthUserFromKeyId(activity.signature.creator); - if (authUser == null) { - throw new Bull.UnrecoverableError('skip: LD-Signatureのユーザーが取得できませんでした'); - } - - if (authUser.key == null) { - throw new Bull.UnrecoverableError('skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした'); - } - - // LD-Signature検証 - const ldSignature = this.ldSignatureService.use(); - const verified = await ldSignature.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false); - if (!verified) { - throw new Bull.UnrecoverableError('skip: LD-Signatureの検証に失敗しました'); - } - - // もう一度actorチェック - if (authUser.user.uri !== activity.actor) { - throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`); - } - - // ブロックしてたら中断 - const ldHost = this.utilityService.extractDbHost(authUser.user.uri); - if (this.utilityService.isBlockedHost(meta.blockedHosts, ldHost)) { - throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`); - } - if (meta.allowlistMode && !this.utilityService.isAllowedHost(meta.allowedHosts, ldHost)) { - throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`); - } - } else { - throw new Bull.UnrecoverableError(`skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`); - } - } - - // activity.idがあればホストが署名者のホストであることを確認する - if (typeof activity.id === 'string') { - const signerHost = this.utilityService.extractDbHost(authUser.user.uri!); - const activityIdHost = this.utilityService.extractDbHost(activity.id); - if (signerHost !== activityIdHost) { - throw new Bull.UnrecoverableError(`skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`); - } - } - - // Update stats - this.federatedInstanceService.fetch(authUser.user.host).then(i => { - this.federatedInstanceService.update(i.id, { - latestRequestReceivedAt: new Date(), - isNotResponding: false, - }); - - this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - - this.apRequestChart.inbox(); - this.federationChart.inbox(i.host); - - if (meta.enableChartsForFederatedInstances) { - this.instanceChart.requestReceived(i.host); - } - }); - - // アクティビティを処理 - await this.apInboxService.performActivity(authUser.user, activity); - return 'ok'; - } -} diff --git a/packages/backend/src/queue/processors/RelationshipProcessorService.ts b/packages/backend/src/queue/processors/RelationshipProcessorService.ts deleted file mode 100644 index b2d8e3631f..0000000000 --- a/packages/backend/src/queue/processors/RelationshipProcessorService.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; - -import { UserFollowingService } from '@/core/UserFollowingService.js'; -import { UserBlockingService } from '@/core/UserBlockingService.js'; -import { bindThis } from '@/decorators.js'; -import type Logger from '@/logger.js'; - -import type { UsersRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { MiLocalUser, MiRemoteUser } from '@/models/User.js'; -import { RelationshipJobData } from '../types.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; - -@Injectable() -export class RelationshipProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private queueLoggerService: QueueLoggerService, - private userFollowingService: UserFollowingService, - private userBlockingService: UserBlockingService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('follow-block'); - } - - @bindThis - public async processFollow(job: Bull.Job): Promise { - this.logger.info(`${job.data.from.id} is trying to follow ${job.data.to.id} ${job.data.withReplies ? "with replies" : "without replies"}`); - await this.userFollowingService.follow(job.data.from, job.data.to, { - requestId: job.data.requestId, - silent: job.data.silent, - withReplies: job.data.withReplies, - }); - return 'ok'; - } - - @bindThis - public async processUnfollow(job: Bull.Job): Promise { - this.logger.info(`${job.data.from.id} is trying to unfollow ${job.data.to.id}`); - const [follower, followee] = await Promise.all([ - this.usersRepository.findOneByOrFail({ id: job.data.from.id }), - this.usersRepository.findOneByOrFail({ id: job.data.to.id }), - ]) as [MiLocalUser | MiRemoteUser, MiLocalUser | MiRemoteUser]; - await this.userFollowingService.unfollow(follower, followee, job.data.silent); - return 'ok'; - } - - @bindThis - public async processBlock(job: Bull.Job): Promise { - this.logger.info(`${job.data.from.id} is trying to block ${job.data.to.id}`); - const [blockee, blocker] = await Promise.all([ - this.usersRepository.findOneByOrFail({ id: job.data.from.id }), - this.usersRepository.findOneByOrFail({ id: job.data.to.id }), - ]); - await this.userBlockingService.block(blockee, blocker, job.data.silent); - return 'ok'; - } - - @bindThis - public async processUnblock(job: Bull.Job): Promise { - this.logger.info(`${job.data.from.id} is trying to unblock ${job.data.to.id}`); - const [blockee, blocker] = await Promise.all([ - this.usersRepository.findOneByOrFail({ id: job.data.from.id }), - this.usersRepository.findOneByOrFail({ id: job.data.to.id }), - ]); - await this.userBlockingService.unblock(blockee, blocker); - return 'ok'; - } -} diff --git a/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts b/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts deleted file mode 100644 index b3b055ef8c..0000000000 --- a/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import type Logger from '@/logger.js'; -import NotesChart from '@/core/chart/charts/notes.js'; -import UsersChart from '@/core/chart/charts/users.js'; -import DriveChart from '@/core/chart/charts/drive.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; - -@Injectable() -export class ResyncChartsProcessorService { - private logger: Logger; - - constructor( - private notesChart: NotesChart, - private usersChart: UsersChart, - private driveChart: DriveChart, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('resync-charts'); - } - - @bindThis - public async process(): Promise { - this.logger.info('Resync charts...'); - - // TODO: ユーザーごとのチャートも更新する - // TODO: インスタンスごとのチャートも更新する - await Promise.all([ - this.driveChart.resync(), - this.notesChart.resync(), - this.usersChart.resync(), - ]); - - this.logger.succ('All charts successfully resynced.'); - } -} diff --git a/packages/backend/src/queue/processors/TickChartsProcessorService.ts b/packages/backend/src/queue/processors/TickChartsProcessorService.ts deleted file mode 100644 index 7b1efb71e0..0000000000 --- a/packages/backend/src/queue/processors/TickChartsProcessorService.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import type Logger from '@/logger.js'; -import FederationChart from '@/core/chart/charts/federation.js'; -import NotesChart from '@/core/chart/charts/notes.js'; -import UsersChart from '@/core/chart/charts/users.js'; -import ActiveUsersChart from '@/core/chart/charts/active-users.js'; -import InstanceChart from '@/core/chart/charts/instance.js'; -import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; -import PerUserPvChart from '@/core/chart/charts/per-user-pv.js'; -import DriveChart from '@/core/chart/charts/drive.js'; -import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js'; -import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; -import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js'; -import ApRequestChart from '@/core/chart/charts/ap-request.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; - -@Injectable() -export class TickChartsProcessorService { - private logger: Logger; - - constructor( - private federationChart: FederationChart, - private notesChart: NotesChart, - private usersChart: UsersChart, - private activeUsersChart: ActiveUsersChart, - private instanceChart: InstanceChart, - private perUserNotesChart: PerUserNotesChart, - private perUserPvChart: PerUserPvChart, - private driveChart: DriveChart, - private perUserReactionsChart: PerUserReactionsChart, - private perUserFollowingChart: PerUserFollowingChart, - private perUserDriveChart: PerUserDriveChart, - private apRequestChart: ApRequestChart, - - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('tick-charts'); - } - - @bindThis - public async process(): Promise { - this.logger.info('Tick charts...'); - - await Promise.all([ - this.federationChart.tick(false), - this.notesChart.tick(false), - this.usersChart.tick(false), - this.activeUsersChart.tick(false), - this.instanceChart.tick(false), - this.perUserNotesChart.tick(false), - this.perUserPvChart.tick(false), - this.driveChart.tick(false), - this.perUserReactionsChart.tick(false), - this.perUserFollowingChart.tick(false), - this.perUserDriveChart.tick(false), - this.apRequestChart.tick(false), - ]); - - this.logger.succ('All charts successfully ticked.'); - } -} diff --git a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts b/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts deleted file mode 100644 index a41f5565c8..0000000000 --- a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import * as Bull from 'bullmq'; -import { DI } from '@/di-symbols.js'; -import type { WebhooksRepository } from '@/models/_.js'; -import type { Config } from '@/config.js'; -import type Logger from '@/logger.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { StatusError } from '@/misc/status-error.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type { WebhookDeliverJobData } from '../types.js'; - -@Injectable() -export class WebhookDeliverProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.webhooksRepository) - private webhooksRepository: WebhooksRepository, - - private httpRequestService: HttpRequestService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('webhook'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - try { - this.logger.debug(`delivering ${job.data.webhookId}`); - - const res = await this.httpRequestService.send(job.data.to, { - method: 'POST', - headers: { - 'User-Agent': 'Misskey-Hooks', - 'X-Misskey-Host': this.config.host, - 'X-Misskey-Hook-Id': job.data.webhookId, - 'X-Misskey-Hook-Secret': job.data.secret, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - server: this.config.url, - hookId: job.data.webhookId, - userId: job.data.userId, - eventId: job.data.eventId, - createdAt: job.data.createdAt, - type: job.data.type, - body: job.data.content, - }), - }); - - this.webhooksRepository.update({ id: job.data.webhookId }, { - latestSentAt: new Date(), - latestStatus: res.status, - }); - - return 'Success'; - } catch (res) { - this.webhooksRepository.update({ id: job.data.webhookId }, { - latestSentAt: new Date(), - latestStatus: res instanceof StatusError ? res.statusCode : 1, - }); - - if (res instanceof StatusError) { - // 4xx - if (res.isClientError) { - throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`); - } - - // 5xx etc. - throw new Error(`${res.statusCode} ${res.statusMessage}`); - } else { - // DNS error, socket error, timeout ... - throw res; - } - } - } -} diff --git a/packages/backend/src/queue/processors/db/delete-account.ts b/packages/backend/src/queue/processors/db/delete-account.ts new file mode 100644 index 0000000000..c1657b4be6 --- /dev/null +++ b/packages/backend/src/queue/processors/db/delete-account.ts @@ -0,0 +1,94 @@ +import Bull from 'bull'; +import { queueLogger } from '../../logger.js'; +import { DriveFiles, Notes, UserProfiles, Users } from '@/models/index.js'; +import { DbUserDeleteJobData } from '@/queue/types.js'; +import { Note } from '@/models/entities/note.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { MoreThan } from 'typeorm'; +import { deleteFileSync } from '@/services/drive/delete-file.js'; +import { sendEmail } from '@/services/send-email.js'; + +const logger = queueLogger.createSubLogger('delete-account'); + +export async function deleteAccount(job: Bull.Job): Promise { + logger.info(`Deleting account of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + return; + } + + { // Delete notes + let cursor: Note['id'] | null = null; + + while (true) { + const notes = await Notes.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }) as Note[]; + + if (notes.length === 0) { + break; + } + + cursor = notes[notes.length - 1].id; + + await Notes.delete(notes.map(note => note.id)); + } + + logger.succ(`All of notes deleted`); + } + + { // Delete files + let cursor: DriveFile['id'] | null = null; + + while (true) { + const files = await DriveFiles.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 10, + order: { + id: 1, + }, + }) as DriveFile[]; + + if (files.length === 0) { + break; + } + + cursor = files[files.length - 1].id; + + for (const file of files) { + await deleteFileSync(file); + } + } + + logger.succ(`All of files deleted`); + } + + { // Send email notification + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + if (profile.email && profile.emailVerified) { + sendEmail(profile.email, 'Account deleted', + `Your account has been deleted.`, + `Your account has been deleted.`); + } + } + + // soft指定されている場合は物理削除しない + if (job.data.soft) { + // nop + } else { + await Users.delete(job.data.user.id); + } + + return 'Account deleted'; +} diff --git a/packages/backend/src/queue/processors/db/delete-drive-files.ts b/packages/backend/src/queue/processors/db/delete-drive-files.ts new file mode 100644 index 0000000000..b3832d9f04 --- /dev/null +++ b/packages/backend/src/queue/processors/db/delete-drive-files.ts @@ -0,0 +1,56 @@ +import Bull from 'bull'; + +import { queueLogger } from '../../logger.js'; +import { deleteFileSync } from '@/services/drive/delete-file.js'; +import { Users, DriveFiles } from '@/models/index.js'; +import { MoreThan } from 'typeorm'; +import { DbUserJobData } from '@/queue/types.js'; + +const logger = queueLogger.createSubLogger('delete-drive-files'); + +export async function deleteDriveFiles(job: Bull.Job, done: any): Promise { + logger.info(`Deleting drive files of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + let deletedCount = 0; + let cursor: any = null; + + while (true) { + const files = await DriveFiles.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }); + + if (files.length === 0) { + job.progress(100); + break; + } + + cursor = files[files.length - 1].id; + + for (const file of files) { + await deleteFileSync(file); + deletedCount++; + } + + const total = await DriveFiles.countBy({ + userId: user.id, + }); + + job.progress(deletedCount / total); + } + + logger.succ(`All drive files (${deletedCount}) of ${user.id} has been deleted.`); + done(); +} diff --git a/packages/backend/src/queue/processors/db/export-blocking.ts b/packages/backend/src/queue/processors/db/export-blocking.ts new file mode 100644 index 0000000000..f5e0424a79 --- /dev/null +++ b/packages/backend/src/queue/processors/db/export-blocking.ts @@ -0,0 +1,93 @@ +import Bull from 'bull'; +import * as fs from 'node:fs'; + +import { queueLogger } from '../../logger.js'; +import { addFile } from '@/services/drive/add-file.js'; +import { format as dateFormat } from 'date-fns'; +import { getFullApAccount } from '@/misc/convert-host.js'; +import { createTemp } from '@/misc/create-temp.js'; +import { Users, Blockings } from '@/models/index.js'; +import { MoreThan } from 'typeorm'; +import { DbUserJobData } from '@/queue/types.js'; + +const logger = queueLogger.createSubLogger('export-blocking'); + +export async function exportBlocking(job: Bull.Job, done: any): Promise { + logger.info(`Exporting blocking of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + logger.info(`Temp file is ${path}`); + + try { + const stream = fs.createWriteStream(path, { flags: 'a' }); + + let exportedCount = 0; + let cursor: any = null; + + while (true) { + const blockings = await Blockings.find({ + where: { + blockerId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }); + + if (blockings.length === 0) { + job.progress(100); + break; + } + + cursor = blockings[blockings.length - 1].id; + + for (const block of blockings) { + const u = await Users.findOneBy({ id: block.blockeeId }); + if (u == null) { + exportedCount++; continue; + } + + const content = getFullApAccount(u.username, u.host); + await new Promise((res, rej) => { + stream.write(content + '\n', err => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + exportedCount++; + } + + const total = await Blockings.countBy({ + blockerId: user.id, + }); + + job.progress(exportedCount / total); + } + + stream.end(); + logger.succ(`Exported to: ${path}`); + + const fileName = 'blocking-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; + const driveFile = await addFile({ user, path, name: fileName, force: true }); + + logger.succ(`Exported to: ${driveFile.id}`); + } finally { + cleanup(); + } + + done(); +} diff --git a/packages/backend/src/queue/processors/db/export-custom-emojis.ts b/packages/backend/src/queue/processors/db/export-custom-emojis.ts new file mode 100644 index 0000000000..3da887cda2 --- /dev/null +++ b/packages/backend/src/queue/processors/db/export-custom-emojis.ts @@ -0,0 +1,114 @@ +import Bull from 'bull'; +import * as fs from 'node:fs'; + +import { ulid } from 'ulid'; +import mime from 'mime-types'; +import archiver from 'archiver'; +import { queueLogger } from '../../logger.js'; +import { addFile } from '@/services/drive/add-file.js'; +import { format as dateFormat } from 'date-fns'; +import { Users, Emojis } from '@/models/index.js'; +import { } from '@/queue/types.js'; +import { createTemp, createTempDir } from '@/misc/create-temp.js'; +import { downloadUrl } from '@/misc/download-url.js'; +import config from '@/config/index.js'; +import { IsNull } from 'typeorm'; + +const logger = queueLogger.createSubLogger('export-custom-emojis'); + +export async function exportCustomEmojis(job: Bull.Job, done: () => void): Promise { + logger.info(`Exporting custom emojis ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + const [path, cleanup] = await createTempDir(); + + logger.info(`Temp dir is ${path}`); + + const metaPath = path + '/meta.json'; + + fs.writeFileSync(metaPath, '', 'utf-8'); + + const metaStream = fs.createWriteStream(metaPath, { flags: 'a' }); + + const writeMeta = (text: string): Promise => { + return new Promise((res, rej) => { + metaStream.write(text, err => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + }; + + await writeMeta(`{"metaVersion":2,"host":"${config.host}","exportedAt":"${new Date().toString()}","emojis":[`); + + const customEmojis = await Emojis.find({ + where: { + host: IsNull(), + }, + order: { + id: 'ASC', + }, + }); + + for (const emoji of customEmojis) { + const ext = mime.extension(emoji.type); + const fileName = emoji.name + (ext ? '.' + ext : ''); + const emojiPath = path + '/' + fileName; + fs.writeFileSync(emojiPath, '', 'binary'); + let downloaded = false; + + try { + await downloadUrl(emoji.originalUrl, emojiPath); + downloaded = true; + } catch (e) { // TODO: 何度か再試行 + logger.error(e instanceof Error ? e : new Error(e as string)); + } + + if (!downloaded) { + fs.unlinkSync(emojiPath); + } + + const content = JSON.stringify({ + fileName: fileName, + downloaded: downloaded, + emoji: emoji, + }); + const isFirst = customEmojis.indexOf(emoji) === 0; + + await writeMeta(isFirst ? content : ',\n' + content); + } + + await writeMeta(']}'); + + metaStream.end(); + + // Create archive + const [archivePath, archiveCleanup] = await createTemp(); + const archiveStream = fs.createWriteStream(archivePath); + const archive = archiver('zip', { + zlib: { level: 0 }, + }); + archiveStream.on('close', async () => { + logger.succ(`Exported to: ${archivePath}`); + + const fileName = 'custom-emojis-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.zip'; + const driveFile = await addFile({ user, path: archivePath, name: fileName, force: true }); + + logger.succ(`Exported to: ${driveFile.id}`); + cleanup(); + archiveCleanup(); + done(); + }); + archive.pipe(archiveStream); + archive.directory(path, false); + archive.finalize(); +} diff --git a/packages/backend/src/queue/processors/db/export-following.ts b/packages/backend/src/queue/processors/db/export-following.ts new file mode 100644 index 0000000000..4ac165567b --- /dev/null +++ b/packages/backend/src/queue/processors/db/export-following.ts @@ -0,0 +1,94 @@ +import Bull from 'bull'; +import * as fs from 'node:fs'; + +import { queueLogger } from '../../logger.js'; +import { addFile } from '@/services/drive/add-file.js'; +import { format as dateFormat } from 'date-fns'; +import { getFullApAccount } from '@/misc/convert-host.js'; +import { createTemp } from '@/misc/create-temp.js'; +import { Users, Followings, Mutings } from '@/models/index.js'; +import { In, MoreThan, Not } from 'typeorm'; +import { DbUserJobData } from '@/queue/types.js'; +import { Following } from '@/models/entities/following.js'; + +const logger = queueLogger.createSubLogger('export-following'); + +export async function exportFollowing(job: Bull.Job, done: () => void): Promise { + logger.info(`Exporting following of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + logger.info(`Temp file is ${path}`); + + try { + const stream = fs.createWriteStream(path, { flags: 'a' }); + + let cursor: Following['id'] | null = null; + + const mutings = job.data.excludeMuting ? await Mutings.findBy({ + muterId: user.id, + }) : []; + + while (true) { + const followings = await Followings.find({ + where: { + followerId: user.id, + ...(mutings.length > 0 ? { followeeId: Not(In(mutings.map(x => x.muteeId))) } : {}), + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }) as Following[]; + + if (followings.length === 0) { + break; + } + + cursor = followings[followings.length - 1].id; + + for (const following of followings) { + const u = await Users.findOneBy({ id: following.followeeId }); + if (u == null) { + continue; + } + + if (job.data.excludeInactive && u.updatedAt && (Date.now() - u.updatedAt.getTime() > 1000 * 60 * 60 * 24 * 90)) { + continue; + } + + const content = getFullApAccount(u.username, u.host); + await new Promise((res, rej) => { + stream.write(content + '\n', err => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + } + } + + stream.end(); + logger.succ(`Exported to: ${path}`); + + const fileName = 'following-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; + const driveFile = await addFile({ user, path, name: fileName, force: true }); + + logger.succ(`Exported to: ${driveFile.id}`); + } finally { + cleanup(); + } + + done(); +} diff --git a/packages/backend/src/queue/processors/db/export-mute.ts b/packages/backend/src/queue/processors/db/export-mute.ts new file mode 100644 index 0000000000..6a36cfa072 --- /dev/null +++ b/packages/backend/src/queue/processors/db/export-mute.ts @@ -0,0 +1,94 @@ +import Bull from 'bull'; +import * as fs from 'node:fs'; + +import { queueLogger } from '../../logger.js'; +import { addFile } from '@/services/drive/add-file.js'; +import { format as dateFormat } from 'date-fns'; +import { getFullApAccount } from '@/misc/convert-host.js'; +import { createTemp } from '@/misc/create-temp.js'; +import { Users, Mutings } from '@/models/index.js'; +import { IsNull, MoreThan } from 'typeorm'; +import { DbUserJobData } from '@/queue/types.js'; + +const logger = queueLogger.createSubLogger('export-mute'); + +export async function exportMute(job: Bull.Job, done: any): Promise { + logger.info(`Exporting mute of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + logger.info(`Temp file is ${path}`); + + try { + const stream = fs.createWriteStream(path, { flags: 'a' }); + + let exportedCount = 0; + let cursor: any = null; + + while (true) { + const mutes = await Mutings.find({ + where: { + muterId: user.id, + expiresAt: IsNull(), + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }); + + if (mutes.length === 0) { + job.progress(100); + break; + } + + cursor = mutes[mutes.length - 1].id; + + for (const mute of mutes) { + const u = await Users.findOneBy({ id: mute.muteeId }); + if (u == null) { + exportedCount++; continue; + } + + const content = getFullApAccount(u.username, u.host); + await new Promise((res, rej) => { + stream.write(content + '\n', err => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + exportedCount++; + } + + const total = await Mutings.countBy({ + muterId: user.id, + }); + + job.progress(exportedCount / total); + } + + stream.end(); + logger.succ(`Exported to: ${path}`); + + const fileName = 'mute-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; + const driveFile = await addFile({ user, path, name: fileName, force: true }); + + logger.succ(`Exported to: ${driveFile.id}`); + } finally { + cleanup(); + } + + done(); +} diff --git a/packages/backend/src/queue/processors/db/export-notes.ts b/packages/backend/src/queue/processors/db/export-notes.ts new file mode 100644 index 0000000000..051fcdf385 --- /dev/null +++ b/packages/backend/src/queue/processors/db/export-notes.ts @@ -0,0 +1,118 @@ +import Bull from 'bull'; +import * as fs from 'node:fs'; + +import { queueLogger } from '../../logger.js'; +import { addFile } from '@/services/drive/add-file.js'; +import { format as dateFormat } from 'date-fns'; +import { Users, Notes, Polls } from '@/models/index.js'; +import { MoreThan } from 'typeorm'; +import { Note } from '@/models/entities/note.js'; +import { Poll } from '@/models/entities/poll.js'; +import { DbUserJobData } from '@/queue/types.js'; +import { createTemp } from '@/misc/create-temp.js'; + +const logger = queueLogger.createSubLogger('export-notes'); + +export async function exportNotes(job: Bull.Job, done: any): Promise { + logger.info(`Exporting notes of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + logger.info(`Temp file is ${path}`); + + try { + const stream = fs.createWriteStream(path, { flags: 'a' }); + + const write = (text: string): Promise => { + return new Promise((res, rej) => { + stream.write(text, err => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + }; + + await write('['); + + let exportedNotesCount = 0; + let cursor: Note['id'] | null = null; + + while (true) { + const notes = await Notes.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }) as Note[]; + + if (notes.length === 0) { + job.progress(100); + break; + } + + cursor = notes[notes.length - 1].id; + + for (const note of notes) { + let poll: Poll | undefined; + if (note.hasPoll) { + poll = await Polls.findOneByOrFail({ noteId: note.id }); + } + const content = JSON.stringify(serialize(note, poll)); + const isFirst = exportedNotesCount === 0; + await write(isFirst ? content : ',\n' + content); + exportedNotesCount++; + } + + const total = await Notes.countBy({ + userId: user.id, + }); + + job.progress(exportedNotesCount / total); + } + + await write(']'); + + stream.end(); + logger.succ(`Exported to: ${path}`); + + const fileName = 'notes-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; + const driveFile = await addFile({ user, path, name: fileName, force: true }); + + logger.succ(`Exported to: ${driveFile.id}`); + } finally { + cleanup(); + } + + done(); +} + +function serialize(note: Note, poll: Poll | null = null): Record { + return { + id: note.id, + text: note.text, + createdAt: note.createdAt, + fileIds: note.fileIds, + replyId: note.replyId, + renoteId: note.renoteId, + poll: poll, + cw: note.cw, + visibility: note.visibility, + visibleUserIds: note.visibleUserIds, + localOnly: note.localOnly, + }; +} diff --git a/packages/backend/src/queue/processors/db/export-user-lists.ts b/packages/backend/src/queue/processors/db/export-user-lists.ts new file mode 100644 index 0000000000..71dd72df27 --- /dev/null +++ b/packages/backend/src/queue/processors/db/export-user-lists.ts @@ -0,0 +1,70 @@ +import Bull from 'bull'; +import * as fs from 'node:fs'; + +import { queueLogger } from '../../logger.js'; +import { addFile } from '@/services/drive/add-file.js'; +import { format as dateFormat } from 'date-fns'; +import { getFullApAccount } from '@/misc/convert-host.js'; +import { createTemp } from '@/misc/create-temp.js'; +import { Users, UserLists, UserListJoinings } from '@/models/index.js'; +import { In } from 'typeorm'; +import { DbUserJobData } from '@/queue/types.js'; + +const logger = queueLogger.createSubLogger('export-user-lists'); + +export async function exportUserLists(job: Bull.Job, done: any): Promise { + logger.info(`Exporting user lists of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + const lists = await UserLists.findBy({ + userId: user.id, + }); + + // Create temp file + const [path, cleanup] = await createTemp(); + + logger.info(`Temp file is ${path}`); + + try { + const stream = fs.createWriteStream(path, { flags: 'a' }); + + for (const list of lists) { + const joinings = await UserListJoinings.findBy({ userListId: list.id }); + const users = await Users.findBy({ + id: In(joinings.map(j => j.userId)), + }); + + for (const u of users) { + const acct = getFullApAccount(u.username, u.host); + const content = `${list.name},${acct}`; + await new Promise((res, rej) => { + stream.write(content + '\n', err => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + } + } + + stream.end(); + logger.succ(`Exported to: ${path}`); + + const fileName = 'user-lists-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; + const driveFile = await addFile({ user, path, name: fileName, force: true }); + + logger.succ(`Exported to: ${driveFile.id}`); + } finally { + cleanup(); + } + + done(); +} diff --git a/packages/backend/src/queue/processors/db/import-blocking.ts b/packages/backend/src/queue/processors/db/import-blocking.ts new file mode 100644 index 0000000000..8bddf34bc2 --- /dev/null +++ b/packages/backend/src/queue/processors/db/import-blocking.ts @@ -0,0 +1,75 @@ +import Bull from 'bull'; + +import { queueLogger } from '../../logger.js'; +import * as Acct from '@/misc/acct.js'; +import { resolveUser } from '@/remote/resolve-user.js'; +import { downloadTextFile } from '@/misc/download-text-file.js'; +import { isSelfHost, toPuny } from '@/misc/convert-host.js'; +import { Users, DriveFiles, Blockings } from '@/models/index.js'; +import { DbUserImportJobData } from '@/queue/types.js'; +import block from '@/services/blocking/create.js'; +import { IsNull } from 'typeorm'; + +const logger = queueLogger.createSubLogger('import-blocking'); + +export async function importBlocking(job: Bull.Job, done: any): Promise { + logger.info(`Importing blocking of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + const file = await DriveFiles.findOneBy({ + id: job.data.fileId, + }); + if (file == null) { + done(); + return; + } + + const csv = await downloadTextFile(file.url); + + let linenum = 0; + + for (const line of csv.trim().split('\n')) { + linenum++; + + try { + const acct = line.split(',')[0].trim(); + const { username, host } = Acct.parse(acct); + + let target = isSelfHost(host!) ? await Users.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) : await Users.findOneBy({ + host: toPuny(host!), + usernameLower: username.toLowerCase(), + }); + + if (host == null && target == null) continue; + + if (target == null) { + target = await resolveUser(username, host); + } + + if (target == null) { + throw `cannot resolve user: @${username}@${host}`; + } + + // skip myself + if (target.id === job.data.user.id) continue; + + logger.info(`Block[${linenum}] ${target.id} ...`); + + await block(user, target); + } catch (e) { + logger.warn(`Error in line:${linenum} ${e}`); + } + } + + logger.succ('Imported'); + done(); +} + diff --git a/packages/backend/src/queue/processors/db/import-custom-emojis.ts b/packages/backend/src/queue/processors/db/import-custom-emojis.ts new file mode 100644 index 0000000000..64dfe85374 --- /dev/null +++ b/packages/backend/src/queue/processors/db/import-custom-emojis.ts @@ -0,0 +1,81 @@ +import Bull from 'bull'; +import * as fs from 'node:fs'; +import unzipper from 'unzipper'; + +import { queueLogger } from '../../logger.js'; +import { createTempDir } from '@/misc/create-temp.js'; +import { downloadUrl } from '@/misc/download-url.js'; +import { DriveFiles, Emojis } from '@/models/index.js'; +import { DbUserImportJobData } from '@/queue/types.js'; +import { addFile } from '@/services/drive/add-file.js'; +import { genId } from '@/misc/gen-id.js'; +import { db } from '@/db/postgre.js'; + +const logger = queueLogger.createSubLogger('import-custom-emojis'); + +// TODO: 名前衝突時の動作を選べるようにする +export async function importCustomEmojis(job: Bull.Job, done: any): Promise { + logger.info(`Importing custom emojis ...`); + + const file = await DriveFiles.findOneBy({ + id: job.data.fileId, + }); + if (file == null) { + done(); + return; + } + + const [path, cleanup] = await createTempDir(); + + logger.info(`Temp dir is ${path}`); + + const destPath = path + '/emojis.zip'; + + try { + fs.writeFileSync(destPath, '', 'binary'); + await downloadUrl(file.url, destPath); + } catch (e) { // TODO: 何度か再試行 + if (e instanceof Error || typeof e === 'string') { + logger.error(e); + } + throw e; + } + + const outputPath = path + '/emojis'; + const unzipStream = fs.createReadStream(destPath); + const extractor = unzipper.Extract({ path: outputPath }); + extractor.on('close', async () => { + const metaRaw = fs.readFileSync(outputPath + '/meta.json', 'utf-8'); + const meta = JSON.parse(metaRaw); + + for (const record of meta.emojis) { + if (!record.downloaded) continue; + const emojiInfo = record.emoji; + const emojiPath = outputPath + '/' + record.fileName; + await Emojis.delete({ + name: emojiInfo.name, + }); + const driveFile = await addFile({ user: null, path: emojiPath, name: record.fileName, force: true }); + const emoji = await Emojis.insert({ + id: genId(), + updatedAt: new Date(), + name: emojiInfo.name, + category: emojiInfo.category, + host: null, + aliases: emojiInfo.aliases, + originalUrl: driveFile.url, + publicUrl: driveFile.webpublicUrl ?? driveFile.url, + type: driveFile.webpublicType ?? driveFile.type, + }).then(x => Emojis.findOneByOrFail(x.identifiers[0])); + } + + await db.queryResultCache!.remove(['meta_emojis']); + + cleanup(); + + logger.succ('Imported'); + done(); + }); + unzipStream.pipe(extractor); + logger.succ(`Unzipping to ${outputPath}`); +} diff --git a/packages/backend/src/queue/processors/db/import-following.ts b/packages/backend/src/queue/processors/db/import-following.ts new file mode 100644 index 0000000000..8ce2c367d6 --- /dev/null +++ b/packages/backend/src/queue/processors/db/import-following.ts @@ -0,0 +1,74 @@ +import Bull from 'bull'; + +import { queueLogger } from '../../logger.js'; +import follow from '@/services/following/create.js'; +import * as Acct from '@/misc/acct.js'; +import { resolveUser } from '@/remote/resolve-user.js'; +import { downloadTextFile } from '@/misc/download-text-file.js'; +import { isSelfHost, toPuny } from '@/misc/convert-host.js'; +import { Users, DriveFiles } from '@/models/index.js'; +import { DbUserImportJobData } from '@/queue/types.js'; +import { IsNull } from 'typeorm'; + +const logger = queueLogger.createSubLogger('import-following'); + +export async function importFollowing(job: Bull.Job, done: any): Promise { + logger.info(`Importing following of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + const file = await DriveFiles.findOneBy({ + id: job.data.fileId, + }); + if (file == null) { + done(); + return; + } + + const csv = await downloadTextFile(file.url); + + let linenum = 0; + + for (const line of csv.trim().split('\n')) { + linenum++; + + try { + const acct = line.split(',')[0].trim(); + const { username, host } = Acct.parse(acct); + + let target = isSelfHost(host!) ? await Users.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) : await Users.findOneBy({ + host: toPuny(host!), + usernameLower: username.toLowerCase(), + }); + + if (host == null && target == null) continue; + + if (target == null) { + target = await resolveUser(username, host); + } + + if (target == null) { + throw `cannot resolve user: @${username}@${host}`; + } + + // skip myself + if (target.id === job.data.user.id) continue; + + logger.info(`Follow[${linenum}] ${target.id} ...`); + + follow(user, target); + } catch (e) { + logger.warn(`Error in line:${linenum} ${e}`); + } + } + + logger.succ('Imported'); + done(); +} diff --git a/packages/backend/src/queue/processors/db/import-muting.ts b/packages/backend/src/queue/processors/db/import-muting.ts new file mode 100644 index 0000000000..8552b797be --- /dev/null +++ b/packages/backend/src/queue/processors/db/import-muting.ts @@ -0,0 +1,84 @@ +import Bull from 'bull'; + +import { queueLogger } from '../../logger.js'; +import * as Acct from '@/misc/acct.js'; +import { resolveUser } from '@/remote/resolve-user.js'; +import { downloadTextFile } from '@/misc/download-text-file.js'; +import { isSelfHost, toPuny } from '@/misc/convert-host.js'; +import { Users, DriveFiles, Mutings } from '@/models/index.js'; +import { DbUserImportJobData } from '@/queue/types.js'; +import { User } from '@/models/entities/user.js'; +import { genId } from '@/misc/gen-id.js'; +import { IsNull } from 'typeorm'; + +const logger = queueLogger.createSubLogger('import-muting'); + +export async function importMuting(job: Bull.Job, done: any): Promise { + logger.info(`Importing muting of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + const file = await DriveFiles.findOneBy({ + id: job.data.fileId, + }); + if (file == null) { + done(); + return; + } + + const csv = await downloadTextFile(file.url); + + let linenum = 0; + + for (const line of csv.trim().split('\n')) { + linenum++; + + try { + const acct = line.split(',')[0].trim(); + const { username, host } = Acct.parse(acct); + + let target = isSelfHost(host!) ? await Users.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) : await Users.findOneBy({ + host: toPuny(host!), + usernameLower: username.toLowerCase(), + }); + + if (host == null && target == null) continue; + + if (target == null) { + target = await resolveUser(username, host); + } + + if (target == null) { + throw `cannot resolve user: @${username}@${host}`; + } + + // skip myself + if (target.id === job.data.user.id) continue; + + logger.info(`Mute[${linenum}] ${target.id} ...`); + + await mute(user, target); + } catch (e) { + logger.warn(`Error in line:${linenum} ${e}`); + } + } + + logger.succ('Imported'); + done(); +} + +async function mute(user: User, target: User) { + await Mutings.insert({ + id: genId(), + createdAt: new Date(), + muterId: user.id, + muteeId: target.id, + }); +} diff --git a/packages/backend/src/queue/processors/db/import-user-lists.ts b/packages/backend/src/queue/processors/db/import-user-lists.ts new file mode 100644 index 0000000000..9919b7c53c --- /dev/null +++ b/packages/backend/src/queue/processors/db/import-user-lists.ts @@ -0,0 +1,80 @@ +import Bull from 'bull'; + +import { queueLogger } from '../../logger.js'; +import * as Acct from '@/misc/acct.js'; +import { resolveUser } from '@/remote/resolve-user.js'; +import { pushUserToUserList } from '@/services/user-list/push.js'; +import { downloadTextFile } from '@/misc/download-text-file.js'; +import { isSelfHost, toPuny } from '@/misc/convert-host.js'; +import { DriveFiles, Users, UserLists, UserListJoinings } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { DbUserImportJobData } from '@/queue/types.js'; +import { IsNull } from 'typeorm'; + +const logger = queueLogger.createSubLogger('import-user-lists'); + +export async function importUserLists(job: Bull.Job, done: any): Promise { + logger.info(`Importing user lists of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + const file = await DriveFiles.findOneBy({ + id: job.data.fileId, + }); + if (file == null) { + done(); + return; + } + + const csv = await downloadTextFile(file.url); + + let linenum = 0; + + for (const line of csv.trim().split('\n')) { + linenum++; + + try { + const listName = line.split(',')[0].trim(); + const { username, host } = Acct.parse(line.split(',')[1].trim()); + + let list = await UserLists.findOneBy({ + userId: user.id, + name: listName, + }); + + if (list == null) { + list = await UserLists.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: listName, + }).then(x => UserLists.findOneByOrFail(x.identifiers[0])); + } + + let target = isSelfHost(host!) ? await Users.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) : await Users.findOneBy({ + host: toPuny(host!), + usernameLower: username.toLowerCase(), + }); + + if (target == null) { + target = await resolveUser(username, host); + } + + if (await UserListJoinings.findOneBy({ userListId: list!.id, userId: target.id }) != null) continue; + + pushUserToUserList(target, list!); + } catch (e) { + logger.warn(`Error in line:${linenum} ${e}`); + } + } + + logger.succ('Imported'); + done(); +} diff --git a/packages/backend/src/queue/processors/db/index.ts b/packages/backend/src/queue/processors/db/index.ts new file mode 100644 index 0000000000..e91d569779 --- /dev/null +++ b/packages/backend/src/queue/processors/db/index.ts @@ -0,0 +1,37 @@ +import Bull from 'bull'; +import { DbJobData } from '@/queue/types.js'; +import { deleteDriveFiles } from './delete-drive-files.js'; +import { exportCustomEmojis } from './export-custom-emojis.js'; +import { exportNotes } from './export-notes.js'; +import { exportFollowing } from './export-following.js'; +import { exportMute } from './export-mute.js'; +import { exportBlocking } from './export-blocking.js'; +import { exportUserLists } from './export-user-lists.js'; +import { importFollowing } from './import-following.js'; +import { importUserLists } from './import-user-lists.js'; +import { deleteAccount } from './delete-account.js'; +import { importMuting } from './import-muting.js'; +import { importBlocking } from './import-blocking.js'; +import { importCustomEmojis } from './import-custom-emojis.js'; + +const jobs = { + deleteDriveFiles, + exportCustomEmojis, + exportNotes, + exportFollowing, + exportMute, + exportBlocking, + exportUserLists, + importFollowing, + importMuting, + importBlocking, + importUserLists, + importCustomEmojis, + deleteAccount, +} as Record | Bull.ProcessPromiseFunction>; + +export default function(dbQueue: Bull.Queue) { + for (const [k, v] of Object.entries(jobs)) { + dbQueue.process(k, v); + } +} diff --git a/packages/backend/src/queue/processors/deliver.ts b/packages/backend/src/queue/processors/deliver.ts new file mode 100644 index 0000000000..291c05766e --- /dev/null +++ b/packages/backend/src/queue/processors/deliver.ts @@ -0,0 +1,98 @@ +import { URL } from 'node:url'; +import Bull from 'bull'; +import request from '@/remote/activitypub/request.js'; +import { registerOrFetchInstanceDoc } from '@/services/register-or-fetch-instance-doc.js'; +import Logger from '@/services/logger.js'; +import { Instances } from '@/models/index.js'; +import { apRequestChart, federationChart, instanceChart } from '@/services/chart/index.js'; +import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { toPuny } from '@/misc/convert-host.js'; +import { Cache } from '@/misc/cache.js'; +import { Instance } from '@/models/entities/instance.js'; +import { DeliverJobData } from '../types.js'; +import { StatusError } from '@/misc/fetch.js'; + +const logger = new Logger('deliver'); + +let latest: string | null = null; + +const suspendedHostsCache = new Cache(1000 * 60 * 60); + +export default async (job: Bull.Job) => { + const { host } = new URL(job.data.to); + + // ブロックしてたら中断 + const meta = await fetchMeta(); + if (meta.blockedHosts.includes(toPuny(host))) { + return 'skip (blocked)'; + } + + // isSuspendedなら中断 + let suspendedHosts = suspendedHostsCache.get(null); + if (suspendedHosts == null) { + suspendedHosts = await Instances.find({ + where: { + isSuspended: true, + }, + }); + suspendedHostsCache.set(null, suspendedHosts); + } + if (suspendedHosts.map(x => x.host).includes(toPuny(host))) { + return 'skip (suspended)'; + } + + try { + if (latest !== (latest = JSON.stringify(job.data.content, null, 2))) { + logger.debug(`delivering ${latest}`); + } + + await request(job.data.user, job.data.to, job.data.content); + + // Update stats + registerOrFetchInstanceDoc(host).then(i => { + Instances.update(i.id, { + latestRequestSentAt: new Date(), + latestStatus: 200, + lastCommunicatedAt: new Date(), + isNotResponding: false, + }); + + fetchInstanceMetadata(i); + + instanceChart.requestSent(i.host, true); + apRequestChart.deliverSucc(); + federationChart.deliverd(i.host, true); + }); + + return 'Success'; + } catch (res) { + // Update stats + registerOrFetchInstanceDoc(host).then(i => { + Instances.update(i.id, { + latestRequestSentAt: new Date(), + latestStatus: res instanceof StatusError ? res.statusCode : null, + isNotResponding: true, + }); + + instanceChart.requestSent(i.host, false); + apRequestChart.deliverFail(); + federationChart.deliverd(i.host, false); + }); + + if (res instanceof StatusError) { + // 4xx + if (res.isClientError) { + // HTTPステータスコード4xxはクライアントエラーであり、それはつまり + // 何回再送しても成功することはないということなのでエラーにはしないでおく + return `${res.statusCode} ${res.statusMessage}`; + } + + // 5xx etc. + throw `${res.statusCode} ${res.statusMessage}`; + } else { + // DNS error, socket error, timeout ... + throw res; + } + } +}; diff --git a/packages/backend/src/queue/processors/ended-poll-notification.ts b/packages/backend/src/queue/processors/ended-poll-notification.ts new file mode 100644 index 0000000000..6151c96ad6 --- /dev/null +++ b/packages/backend/src/queue/processors/ended-poll-notification.ts @@ -0,0 +1,33 @@ +import Bull from 'bull'; +import { In } from 'typeorm'; +import { Notes, Polls, PollVotes } from '@/models/index.js'; +import { queueLogger } from '../logger.js'; +import { EndedPollNotificationJobData } from '@/queue/types.js'; +import { createNotification } from '@/services/create-notification.js'; + +const logger = queueLogger.createSubLogger('ended-poll-notification'); + +export async function endedPollNotification(job: Bull.Job, done: any): Promise { + const note = await Notes.findOneBy({ id: job.data.noteId }); + if (note == null || !note.hasPoll) { + done(); + return; + } + + const votes = await PollVotes.createQueryBuilder('vote') + .select('vote.userId') + .where('vote.noteId = :noteId', { noteId: note.id }) + .innerJoinAndSelect('vote.user', 'user') + .andWhere('user.host IS NULL') + .getMany(); + + const userIds = [...new Set([note.userId, ...votes.map(v => v.userId)])]; + + for (const userId of userIds) { + createNotification(userId, 'pollEnded', { + noteId: note.id, + }); + } + + done(); +} diff --git a/packages/backend/src/queue/processors/inbox.ts b/packages/backend/src/queue/processors/inbox.ts new file mode 100644 index 0000000000..198dde6050 --- /dev/null +++ b/packages/backend/src/queue/processors/inbox.ts @@ -0,0 +1,157 @@ +import { URL } from 'node:url'; +import Bull from 'bull'; +import httpSignature from '@peertube/http-signature'; +import perform from '@/remote/activitypub/perform.js'; +import Logger from '@/services/logger.js'; +import { registerOrFetchInstanceDoc } from '@/services/register-or-fetch-instance-doc.js'; +import { Instances } from '@/models/index.js'; +import { apRequestChart, federationChart, instanceChart } from '@/services/chart/index.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { toPuny, extractDbHost } from '@/misc/convert-host.js'; +import { getApId } from '@/remote/activitypub/type.js'; +import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js'; +import { InboxJobData } from '../types.js'; +import DbResolver from '@/remote/activitypub/db-resolver.js'; +import { resolvePerson } from '@/remote/activitypub/models/person.js'; +import { LdSignature } from '@/remote/activitypub/misc/ld-signature.js'; +import { StatusError } from '@/misc/fetch.js'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { UserPublickey } from '@/models/entities/user-publickey.js'; + +const logger = new Logger('inbox'); + +// ユーザーのinboxにアクティビティが届いた時の処理 +export default async (job: Bull.Job): Promise => { + const signature = job.data.signature; // HTTP-signature + const activity = job.data.activity; + + //#region Log + const info = Object.assign({}, activity) as any; + delete info['@context']; + logger.debug(JSON.stringify(info, null, 2)); + //#endregion + + const host = toPuny(new URL(signature.keyId).hostname); + + // ブロックしてたら中断 + const meta = await fetchMeta(); + if (meta.blockedHosts.includes(host)) { + return `Blocked request: ${host}`; + } + + const keyIdLower = signature.keyId.toLowerCase(); + if (keyIdLower.startsWith('acct:')) { + return `Old keyId is no longer supported. ${keyIdLower}`; + } + + const dbResolver = new DbResolver(); + + // HTTP-Signature keyIdを元にDBから取得 + let authUser: { + user: CacheableRemoteUser; + key: UserPublickey | null; + } | null = await dbResolver.getAuthUserFromKeyId(signature.keyId); + + // keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得 + if (authUser == null) { + try { + authUser = await dbResolver.getAuthUserFromApId(getApId(activity.actor)); + } catch (e) { + // 対象が4xxならスキップ + if (e instanceof StatusError) { + if (e.isClientError) { + return `skip: Ignored deleted actors on both ends ${activity.actor} - ${e.statusCode}`; + } + throw `Error in actor ${activity.actor} - ${e.statusCode || e}`; + } + } + } + + // それでもわからなければ終了 + if (authUser == null) { + return `skip: failed to resolve user`; + } + + // publicKey がなくても終了 + if (authUser.key == null) { + return `skip: failed to resolve user publicKey`; + } + + // HTTP-Signatureの検証 + const httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); + + // また、signatureのsignerは、activity.actorと一致する必要がある + if (!httpSignatureValidated || authUser.user.uri !== activity.actor) { + // 一致しなくても、でもLD-Signatureがありそうならそっちも見る + if (activity.signature) { + if (activity.signature.type !== 'RsaSignature2017') { + return `skip: unsupported LD-signature type ${activity.signature.type}`; + } + + // activity.signature.creator: https://example.oom/users/user#main-key + // みたいになっててUserを引っ張れば公開キーも入ることを期待する + if (activity.signature.creator) { + const candicate = activity.signature.creator.replace(/#.*/, ''); + await resolvePerson(candicate).catch(() => null); + } + + // keyIdからLD-Signatureのユーザーを取得 + authUser = await dbResolver.getAuthUserFromKeyId(activity.signature.creator); + if (authUser == null) { + return `skip: LD-Signatureのユーザーが取得できませんでした`; + } + + if (authUser.key == null) { + return `skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした`; + } + + // LD-Signature検証 + const ldSignature = new LdSignature(); + const verified = await ldSignature.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false); + if (!verified) { + return `skip: LD-Signatureの検証に失敗しました`; + } + + // もう一度actorチェック + if (authUser.user.uri !== activity.actor) { + return `skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`; + } + + // ブロックしてたら中断 + const ldHost = extractDbHost(authUser.user.uri); + if (meta.blockedHosts.includes(ldHost)) { + return `Blocked request: ${ldHost}`; + } + } else { + return `skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`; + } + } + + // activity.idがあればホストが署名者のホストであることを確認する + if (typeof activity.id === 'string') { + const signerHost = extractDbHost(authUser.user.uri!); + const activityIdHost = extractDbHost(activity.id); + if (signerHost !== activityIdHost) { + return `skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`; + } + } + + // Update stats + registerOrFetchInstanceDoc(authUser.user.host).then(i => { + Instances.update(i.id, { + latestRequestReceivedAt: new Date(), + lastCommunicatedAt: new Date(), + isNotResponding: false, + }); + + fetchInstanceMetadata(i); + + instanceChart.requestReceived(i.host); + apRequestChart.inbox(); + federationChart.inbox(i.host); + }); + + // アクティビティを処理 + await perform(authUser.user, activity); + return `ok`; +}; diff --git a/packages/backend/src/queue/processors/object-storage/clean-remote-files.ts b/packages/backend/src/queue/processors/object-storage/clean-remote-files.ts new file mode 100644 index 0000000000..77da162f6e --- /dev/null +++ b/packages/backend/src/queue/processors/object-storage/clean-remote-files.ts @@ -0,0 +1,50 @@ +import Bull from 'bull'; + +import { queueLogger } from '../../logger.js'; +import { deleteFileSync } from '@/services/drive/delete-file.js'; +import { DriveFiles } from '@/models/index.js'; +import { MoreThan, Not, IsNull } from 'typeorm'; + +const logger = queueLogger.createSubLogger('clean-remote-files'); + +export default async function cleanRemoteFiles(job: Bull.Job>, done: any): Promise { + logger.info(`Deleting cached remote files...`); + + let deletedCount = 0; + let cursor: any = null; + + while (true) { + const files = await DriveFiles.find({ + where: { + userHost: Not(IsNull()), + isLink: false, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 8, + order: { + id: 1, + }, + }); + + if (files.length === 0) { + job.progress(100); + break; + } + + cursor = files[files.length - 1].id; + + await Promise.all(files.map(file => deleteFileSync(file, true))); + + deletedCount += 8; + + const total = await DriveFiles.countBy({ + userHost: Not(IsNull()), + isLink: false, + }); + + job.progress(deletedCount / total); + } + + logger.succ(`All cahced remote files has been deleted.`); + done(); +} diff --git a/packages/backend/src/queue/processors/object-storage/delete-file.ts b/packages/backend/src/queue/processors/object-storage/delete-file.ts new file mode 100644 index 0000000000..c271e3ddd4 --- /dev/null +++ b/packages/backend/src/queue/processors/object-storage/delete-file.ts @@ -0,0 +1,11 @@ +import { ObjectStorageFileJobData } from '@/queue/types.js'; +import Bull from 'bull'; +import { deleteObjectStorageFile } from '@/services/drive/delete-file.js'; + +export default async (job: Bull.Job) => { + const key: string = job.data.key; + + await deleteObjectStorageFile(key); + + return 'Success'; +}; diff --git a/packages/backend/src/queue/processors/object-storage/index.ts b/packages/backend/src/queue/processors/object-storage/index.ts new file mode 100644 index 0000000000..ae6c481fea --- /dev/null +++ b/packages/backend/src/queue/processors/object-storage/index.ts @@ -0,0 +1,15 @@ +import Bull from 'bull'; +import { ObjectStorageJobData } from '@/queue/types.js'; +import deleteFile from './delete-file.js'; +import cleanRemoteFiles from './clean-remote-files.js'; + +const jobs = { + deleteFile, + cleanRemoteFiles, +} as Record | Bull.ProcessPromiseFunction>; + +export default function(q: Bull.Queue) { + for (const [k, v] of Object.entries(jobs)) { + q.process(k, 16, v); + } +} diff --git a/packages/backend/src/queue/processors/system/check-expired-mutings.ts b/packages/backend/src/queue/processors/system/check-expired-mutings.ts new file mode 100644 index 0000000000..621269e7e1 --- /dev/null +++ b/packages/backend/src/queue/processors/system/check-expired-mutings.ts @@ -0,0 +1,30 @@ +import Bull from 'bull'; +import { In } from 'typeorm'; +import { Mutings } from '@/models/index.js'; +import { queueLogger } from '../../logger.js'; +import { publishUserEvent } from '@/services/stream.js'; + +const logger = queueLogger.createSubLogger('check-expired-mutings'); + +export async function checkExpiredMutings(job: Bull.Job>, done: any): Promise { + logger.info(`Checking expired mutings...`); + + const expired = await Mutings.createQueryBuilder('muting') + .where('muting.expiresAt IS NOT NULL') + .andWhere('muting.expiresAt < :now', { now: new Date() }) + .innerJoinAndSelect('muting.mutee', 'mutee') + .getMany(); + + if (expired.length > 0) { + await Mutings.delete({ + id: In(expired.map(m => m.id)), + }); + + for (const m of expired) { + publishUserEvent(m.muterId, 'unmute', m.mutee!); + } + } + + logger.succ(`All expired mutings checked.`); + done(); +} diff --git a/packages/backend/src/queue/processors/system/clean-charts.ts b/packages/backend/src/queue/processors/system/clean-charts.ts new file mode 100644 index 0000000000..c9169d5acf --- /dev/null +++ b/packages/backend/src/queue/processors/system/clean-charts.ts @@ -0,0 +1,28 @@ +import Bull from 'bull'; + +import { queueLogger } from '../../logger.js'; +import { activeUsersChart, driveChart, federationChart, hashtagChart, instanceChart, notesChart, perUserDriveChart, perUserFollowingChart, perUserNotesChart, perUserReactionsChart, usersChart, apRequestChart } from '@/services/chart/index.js'; + +const logger = queueLogger.createSubLogger('clean-charts'); + +export async function cleanCharts(job: Bull.Job>, done: any): Promise { + logger.info(`Clean charts...`); + + await Promise.all([ + federationChart.clean(), + notesChart.clean(), + usersChart.clean(), + activeUsersChart.clean(), + instanceChart.clean(), + perUserNotesChart.clean(), + driveChart.clean(), + perUserReactionsChart.clean(), + hashtagChart.clean(), + perUserFollowingChart.clean(), + perUserDriveChart.clean(), + apRequestChart.clean(), + ]); + + logger.succ(`All charts successfully cleaned.`); + done(); +} diff --git a/packages/backend/src/queue/processors/system/clean.ts b/packages/backend/src/queue/processors/system/clean.ts new file mode 100644 index 0000000000..c4f978d7c9 --- /dev/null +++ b/packages/backend/src/queue/processors/system/clean.ts @@ -0,0 +1,18 @@ +import Bull from 'bull'; +import { LessThan } from 'typeorm'; +import { UserIps } from '@/models/index.js'; + +import { queueLogger } from '../../logger.js'; + +const logger = queueLogger.createSubLogger('clean'); + +export async function clean(job: Bull.Job>, done: any): Promise { + logger.info('Cleaning...'); + + UserIps.delete({ + createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))), + }); + + logger.succ('Cleaned.'); + done(); +} diff --git a/packages/backend/src/queue/processors/system/index.ts b/packages/backend/src/queue/processors/system/index.ts new file mode 100644 index 0000000000..9527d40b0f --- /dev/null +++ b/packages/backend/src/queue/processors/system/index.ts @@ -0,0 +1,20 @@ +import Bull from 'bull'; +import { tickCharts } from './tick-charts.js'; +import { resyncCharts } from './resync-charts.js'; +import { cleanCharts } from './clean-charts.js'; +import { checkExpiredMutings } from './check-expired-mutings.js'; +import { clean } from './clean.js'; + +const jobs = { + tickCharts, + resyncCharts, + cleanCharts, + checkExpiredMutings, + clean, +} as Record> | Bull.ProcessPromiseFunction>>; + +export default function(dbQueue: Bull.Queue>) { + for (const [k, v] of Object.entries(jobs)) { + dbQueue.process(k, v); + } +} diff --git a/packages/backend/src/queue/processors/system/resync-charts.ts b/packages/backend/src/queue/processors/system/resync-charts.ts new file mode 100644 index 0000000000..20012513af --- /dev/null +++ b/packages/backend/src/queue/processors/system/resync-charts.ts @@ -0,0 +1,21 @@ +import Bull from 'bull'; + +import { queueLogger } from '../../logger.js'; +import { driveChart, notesChart, usersChart } from '@/services/chart/index.js'; + +const logger = queueLogger.createSubLogger('resync-charts'); + +export async function resyncCharts(job: Bull.Job>, done: any): Promise { + logger.info(`Resync charts...`); + + // TODO: ユーザーごとのチャートも更新する + // TODO: インスタンスごとのチャートも更新する + await Promise.all([ + driveChart.resync(), + notesChart.resync(), + usersChart.resync(), + ]); + + logger.succ(`All charts successfully resynced.`); + done(); +} diff --git a/packages/backend/src/queue/processors/system/tick-charts.ts b/packages/backend/src/queue/processors/system/tick-charts.ts new file mode 100644 index 0000000000..13403f8f73 --- /dev/null +++ b/packages/backend/src/queue/processors/system/tick-charts.ts @@ -0,0 +1,28 @@ +import Bull from 'bull'; + +import { queueLogger } from '../../logger.js'; +import { activeUsersChart, driveChart, federationChart, hashtagChart, instanceChart, notesChart, perUserDriveChart, perUserFollowingChart, perUserNotesChart, perUserReactionsChart, usersChart, apRequestChart } from '@/services/chart/index.js'; + +const logger = queueLogger.createSubLogger('tick-charts'); + +export async function tickCharts(job: Bull.Job>, done: any): Promise { + logger.info(`Tick charts...`); + + await Promise.all([ + federationChart.tick(false), + notesChart.tick(false), + usersChart.tick(false), + activeUsersChart.tick(false), + instanceChart.tick(false), + perUserNotesChart.tick(false), + driveChart.tick(false), + perUserReactionsChart.tick(false), + hashtagChart.tick(false), + perUserFollowingChart.tick(false), + perUserDriveChart.tick(false), + apRequestChart.tick(false), + ]); + + logger.succ(`All charts successfully ticked.`); + done(); +} diff --git a/packages/backend/src/queue/processors/webhook-deliver.ts b/packages/backend/src/queue/processors/webhook-deliver.ts new file mode 100644 index 0000000000..d49206f68f --- /dev/null +++ b/packages/backend/src/queue/processors/webhook-deliver.ts @@ -0,0 +1,59 @@ +import { URL } from 'node:url'; +import Bull from 'bull'; +import Logger from '@/services/logger.js'; +import { WebhookDeliverJobData } from '../types.js'; +import { getResponse, StatusError } from '@/misc/fetch.js'; +import { Webhooks } from '@/models/index.js'; +import config from '@/config/index.js'; + +const logger = new Logger('webhook'); + +export default async (job: Bull.Job) => { + try { + logger.debug(`delivering ${job.data.webhookId}`); + + const res = await getResponse({ + url: job.data.to, + method: 'POST', + headers: { + 'User-Agent': 'Misskey-Hooks', + 'X-Misskey-Host': config.host, + 'X-Misskey-Hook-Id': job.data.webhookId, + 'X-Misskey-Hook-Secret': job.data.secret, + }, + body: JSON.stringify({ + hookId: job.data.webhookId, + userId: job.data.userId, + eventId: job.data.eventId, + createdAt: job.data.createdAt, + type: job.data.type, + body: job.data.content, + }), + }); + + Webhooks.update({ id: job.data.webhookId }, { + latestSentAt: new Date(), + latestStatus: res.status, + }); + + return 'Success'; + } catch (res) { + Webhooks.update({ id: job.data.webhookId }, { + latestSentAt: new Date(), + latestStatus: res instanceof StatusError ? res.statusCode : 1, + }); + + if (res instanceof StatusError) { + // 4xx + if (res.isClientError) { + return `${res.statusCode} ${res.statusMessage}`; + } + + // 5xx etc. + throw `${res.statusCode} ${res.statusMessage}`; + } else { + // DNS error, socket error, timeout ... + throw res; + } + } +}; diff --git a/packages/backend/src/queue/queues.ts b/packages/backend/src/queue/queues.ts new file mode 100644 index 0000000000..f3a267790c --- /dev/null +++ b/packages/backend/src/queue/queues.ts @@ -0,0 +1,21 @@ +import config from '@/config/index.js'; +import { initialize as initializeQueue } from './initialize.js'; +import { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData } from './types.js'; + +export const systemQueue = initializeQueue>('system'); +export const endedPollNotificationQueue = initializeQueue('endedPollNotification'); +export const deliverQueue = initializeQueue('deliver', config.deliverJobPerSec || 128); +export const inboxQueue = initializeQueue('inbox', config.inboxJobPerSec || 16); +export const dbQueue = initializeQueue('db'); +export const objectStorageQueue = initializeQueue('objectStorage'); +export const webhookDeliverQueue = initializeQueue('webhookDeliver', 64); + +export const queues = [ + systemQueue, + endedPollNotificationQueue, + deliverQueue, + inboxQueue, + dbQueue, + objectStorageQueue, + webhookDeliverQueue, +]; diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 432b3d364f..5ea4725561 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -1,15 +1,9 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiNote } from '@/models/Note.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiWebhook } from '@/models/Webhook.js'; -import type { IActivity } from '@/core/activitypub/type.js'; -import type httpSignature from '@peertube/http-signature'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { Note } from '@/models/entities/note'; +import { User } from '@/models/entities/user.js'; +import { Webhook } from '@/models/entities/webhook'; +import { IActivity } from '@/remote/activitypub/type.js'; +import httpSignature from '@peertube/http-signature'; export type DeliverJobData = { /** Actor */ @@ -18,8 +12,6 @@ export type DeliverJobData = { content: unknown; /** inbox URL to deliver */ to: string; - /** whether it is sharedInbox */ - isSharedInbox: boolean; }; export type InboxJobData = { @@ -27,59 +19,14 @@ export type InboxJobData = { signature: httpSignature.IParsedSignature; }; -export type RelationshipJobData = { - from: ThinUser; - to: ThinUser; - silent?: boolean; - requestId?: string; - withReplies?: boolean; -} +export type DbJobData = DbUserJobData | DbUserImportJobData | DbUserDeleteJobData; -export type DbJobData = DbJobMap[T]; - -export type DbJobMap = { - deleteDriveFiles: DbJobDataWithUser; - exportAccountData: DbJobDataWithUser; - exportCustomEmojis: DbJobDataWithUser; - exportAntennas: DBExportAntennasData; - exportNotes: DbJobDataWithUser; - exportFavorites: DbJobDataWithUser; - exportFollowing: DbExportFollowingData; - exportMuting: DbJobDataWithUser; - exportBlocking: DbJobDataWithUser; - exportUserLists: DbJobDataWithUser; - importAntennas: DBAntennaImportJobData; - importNotes: DbNoteImportJobData; - importTweetsToDb: DbNoteWithParentImportToDbJobData; - importIGToDb: DbNoteImportToDbJobData; - importFBToDb: DbNoteImportToDbJobData; - importMastoToDb: DbNoteWithParentImportToDbJobData; - importPleroToDb: DbNoteWithParentImportToDbJobData; - importKeyNotesToDb: DbNoteWithParentImportToDbJobData; - importFollowing: DbUserImportJobData; - importFollowingToDb: DbUserImportToDbJobData; - importMuting: DbUserImportJobData; - importBlocking: DbUserImportJobData; - importBlockingToDb: DbUserImportToDbJobData; - importUserLists: DbUserImportJobData; - importCustomEmojis: DbUserImportJobData; - deleteAccount: DbUserDeleteJobData; -} - -export type DbJobDataWithUser = { - user: ThinUser; -} - -export type DbExportFollowingData = { +export type DbUserJobData = { user: ThinUser; excludeMuting: boolean; excludeInactive: boolean; }; -export type DBExportAntennasData = { - user: ThinUser -} - export type DbUserDeleteJobData = { user: ThinUser; soft?: boolean; @@ -87,36 +34,7 @@ export type DbUserDeleteJobData = { export type DbUserImportJobData = { user: ThinUser; - fileId: MiDriveFile['id']; - withReplies?: boolean; -}; - -export type DbNoteImportJobData = { - user: ThinUser; - fileId: MiDriveFile['id']; - type?: string; -}; - -export type DBAntennaImportJobData = { - user: ThinUser, - antenna: Antenna -} - -export type DbUserImportToDbJobData = { - user: ThinUser; - target: string; - withReplies?: boolean; -}; - -export type DbNoteImportToDbJobData = { - user: ThinUser; - target: any; -}; - -export type DbNoteWithParentImportToDbJobData = { - user: ThinUser; - target: any; - note: MiNote['id'] | null; + fileId: DriveFile['id']; }; export type ObjectStorageJobData = ObjectStorageFileJobData | Record; @@ -126,14 +44,14 @@ export type ObjectStorageFileJobData = { }; export type EndedPollNotificationJobData = { - noteId: MiNote['id']; + noteId: Note['id']; }; export type WebhookDeliverJobData = { type: string; content: unknown; - webhookId: MiWebhook['id']; - userId: MiUser['id']; + webhookId: Webhook['id']; + userId: User['id']; to: string; secret: string; createdAt: number; @@ -141,5 +59,5 @@ export type WebhookDeliverJobData = { }; export type ThinUser = { - id: MiUser['id']; + id: User['id']; }; diff --git a/packages/backend/src/remote/activitypub/ap-request.ts b/packages/backend/src/remote/activitypub/ap-request.ts new file mode 100644 index 0000000000..8b55f22477 --- /dev/null +++ b/packages/backend/src/remote/activitypub/ap-request.ts @@ -0,0 +1,104 @@ +import * as crypto from 'node:crypto'; +import { URL } from 'node:url'; + +type Request = { + url: string; + method: string; + headers: Record; +}; + +type PrivateKey = { + privateKeyPem: string; + keyId: string; +}; + +export function createSignedPost(args: { key: PrivateKey, url: string, body: string, additionalHeaders: Record }) { + const u = new URL(args.url); + const digestHeader = `SHA-256=${crypto.createHash('sha256').update(args.body).digest('base64')}`; + + const request: Request = { + url: u.href, + method: 'POST', + headers: objectAssignWithLcKey({ + 'Date': new Date().toUTCString(), + 'Host': u.hostname, + 'Content-Type': 'application/activity+json', + 'Digest': digestHeader, + }, args.additionalHeaders), + }; + + const result = signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']); + + return { + request, + signingString: result.signingString, + signature: result.signature, + signatureHeader: result.signatureHeader, + }; +} + +export function createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record }) { + const u = new URL(args.url); + + const request: Request = { + url: u.href, + method: 'GET', + headers: objectAssignWithLcKey({ + 'Accept': 'application/activity+json, application/ld+json', + 'Date': new Date().toUTCString(), + 'Host': new URL(args.url).hostname, + }, args.additionalHeaders), + }; + + const result = signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']); + + return { + request, + signingString: result.signingString, + signature: result.signature, + signatureHeader: result.signatureHeader, + }; +} + +function signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]) { + const signingString = genSigningString(request, includeHeaders); + const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64'); + const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`; + + request.headers = objectAssignWithLcKey(request.headers, { + Signature: signatureHeader, + }); + + return { + request, + signingString, + signature, + signatureHeader, + }; +} + +function genSigningString(request: Request, includeHeaders: string[]) { + request.headers = lcObjectKey(request.headers); + + const results: string[] = []; + + for (const key of includeHeaders.map(x => x.toLowerCase())) { + if (key === '(request-target)') { + results.push(`(request-target): ${request.method.toLowerCase()} ${new URL(request.url).pathname}`); + } else { + results.push(`${key}: ${request.headers[key]}`); + } + } + + return results.join('\n'); +} + +function lcObjectKey(src: Record) { + const dst: Record = {}; + for (const key of Object.keys(src).filter(x => x !== '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key]; + return dst; +} + +function objectAssignWithLcKey(a: Record, b: Record) { + return Object.assign(lcObjectKey(a), lcObjectKey(b)); +} diff --git a/packages/backend/src/remote/activitypub/audience.ts b/packages/backend/src/remote/activitypub/audience.ts new file mode 100644 index 0000000000..846ccf9c00 --- /dev/null +++ b/packages/backend/src/remote/activitypub/audience.ts @@ -0,0 +1,92 @@ +import { ApObject, getApIds } from './type.js'; +import Resolver from './resolver.js'; +import { resolvePerson } from './models/person.js'; +import { unique, concat } from '@/prelude/array.js'; +import promiseLimit from 'promise-limit'; +import { User, CacheableRemoteUser, CacheableUser } from '@/models/entities/user.js'; + +type Visibility = 'public' | 'home' | 'followers' | 'specified'; + +type AudienceInfo = { + visibility: Visibility, + mentionedUsers: CacheableUser[], + visibleUsers: CacheableUser[], +}; + +export async function parseAudience(actor: CacheableRemoteUser, to?: ApObject, cc?: ApObject, resolver?: Resolver): Promise { + const toGroups = groupingAudience(getApIds(to), actor); + const ccGroups = groupingAudience(getApIds(cc), actor); + + const others = unique(concat([toGroups.other, ccGroups.other])); + + const limit = promiseLimit(2); + const mentionedUsers = (await Promise.all( + others.map(id => limit(() => resolvePerson(id, resolver).catch(() => null))) + )).filter((x): x is CacheableUser => x != null); + + if (toGroups.public.length > 0) { + return { + visibility: 'public', + mentionedUsers, + visibleUsers: [], + }; + } + + if (ccGroups.public.length > 0) { + return { + visibility: 'home', + mentionedUsers, + visibleUsers: [], + }; + } + + if (toGroups.followers.length > 0) { + return { + visibility: 'followers', + mentionedUsers, + visibleUsers: [], + }; + } + + return { + visibility: 'specified', + mentionedUsers, + visibleUsers: mentionedUsers, + }; +} + +function groupingAudience(ids: string[], actor: CacheableRemoteUser) { + const groups = { + public: [] as string[], + followers: [] as string[], + other: [] as string[], + }; + + for (const id of ids) { + if (isPublic(id)) { + groups.public.push(id); + } else if (isFollowers(id, actor)) { + groups.followers.push(id); + } else { + groups.other.push(id); + } + } + + groups.other = unique(groups.other); + + return groups; +} + +function isPublic(id: string) { + return [ + 'https://www.w3.org/ns/activitystreams#Public', + 'as#Public', + 'Public', + ].includes(id); +} + +function isFollowers(id: string, actor: CacheableRemoteUser) { + return ( + id === (actor.followersUri || `${actor.uri}/followers`) + ); +} diff --git a/packages/backend/src/remote/activitypub/db-resolver.ts b/packages/backend/src/remote/activitypub/db-resolver.ts new file mode 100644 index 0000000000..1a02f675ca --- /dev/null +++ b/packages/backend/src/remote/activitypub/db-resolver.ts @@ -0,0 +1,155 @@ +import escapeRegexp from 'escape-regexp'; +import config from '@/config/index.js'; +import { Note } from '@/models/entities/note.js'; +import { User, IRemoteUser, CacheableRemoteUser, CacheableUser } from '@/models/entities/user.js'; +import { UserPublickey } from '@/models/entities/user-publickey.js'; +import { MessagingMessage } from '@/models/entities/messaging-message.js'; +import { Notes, Users, UserPublickeys, MessagingMessages } from '@/models/index.js'; +import { Cache } from '@/misc/cache.js'; +import { uriPersonCache, userByIdCache } from '@/services/user-cache.js'; +import { IObject, getApId } from './type.js'; +import { resolvePerson } from './models/person.js'; + +const publicKeyCache = new Cache(Infinity); +const publicKeyByUserIdCache = new Cache(Infinity); + +export type UriParseResult = { + /** wether the URI was generated by us */ + local: true; + /** id in DB */ + id: string; + /** hint of type, e.g. "notes", "users" */ + type: string; + /** any remaining text after type and id, not including the slash after id. undefined if empty */ + rest?: string; +} | { + /** wether the URI was generated by us */ + local: false; + /** uri in DB */ + uri: string; +}; + +export function parseUri(value: string | IObject): UriParseResult { + const uri = getApId(value); + + // the host part of a URL is case insensitive, so use the 'i' flag. + const localRegex = new RegExp('^' + escapeRegexp(config.url) + '/(\\w+)/(\\w+)(?:\/(.+))?', 'i'); + const matchLocal = uri.match(localRegex); + + if (matchLocal) { + return { + local: true, + type: matchLocal[1], + id: matchLocal[2], + rest: matchLocal[3], + }; + } else { + return { + local: false, + uri, + }; + } +} + +export default class DbResolver { + constructor() { + } + + /** + * AP Note => Misskey Note in DB + */ + public async getNoteFromApId(value: string | IObject): Promise { + const parsed = parseUri(value); + + if (parsed.local) { + if (parsed.type !== 'notes') return null; + + return await Notes.findOneBy({ + id: parsed.id, + }); + } else { + return await Notes.findOneBy({ + uri: parsed.uri, + }); + } + } + + public async getMessageFromApId(value: string | IObject): Promise { + const parsed = parseUri(value); + + if (parsed.local) { + if (parsed.type !== 'notes') return null; + + return await MessagingMessages.findOneBy({ + id: parsed.id, + }); + } else { + return await MessagingMessages.findOneBy({ + uri: parsed.uri, + }); + } + } + + /** + * AP Person => Misskey User in DB + */ + public async getUserFromApId(value: string | IObject): Promise { + const parsed = parseUri(value); + + if (parsed.local) { + if (parsed.type !== 'users') return null; + + return await userByIdCache.fetchMaybe(parsed.id, () => Users.findOneBy({ + id: parsed.id, + }).then(x => x ?? undefined)) ?? null; + } else { + return await uriPersonCache.fetch(parsed.uri, () => Users.findOneBy({ + uri: parsed.uri, + })); + } + } + + /** + * AP KeyId => Misskey User and Key + */ + public async getAuthUserFromKeyId(keyId: string): Promise<{ + user: CacheableRemoteUser; + key: UserPublickey; + } | null> { + const key = await publicKeyCache.fetch(keyId, async () => { + const key = await UserPublickeys.findOneBy({ + keyId, + }); + + if (key == null) return null; + + return key; + }, key => key != null); + + if (key == null) return null; + + return { + user: await userByIdCache.fetch(key.userId, () => Users.findOneByOrFail({ id: key.userId })) as CacheableRemoteUser, + key, + }; + } + + /** + * AP Actor id => Misskey User and Key + */ + public async getAuthUserFromApId(uri: string): Promise<{ + user: CacheableRemoteUser; + key: UserPublickey | null; + } | null> { + const user = await resolvePerson(uri) as CacheableRemoteUser; + + if (user == null) return null; + + const key = await publicKeyByUserIdCache.fetch(user.id, () => UserPublickeys.findOneBy({ userId: user.id }), v => v != null); + + return { + user, + key, + }; + } +} diff --git a/packages/backend/src/remote/activitypub/deliver-manager.ts b/packages/backend/src/remote/activitypub/deliver-manager.ts new file mode 100644 index 0000000000..4c1999e4cb --- /dev/null +++ b/packages/backend/src/remote/activitypub/deliver-manager.ts @@ -0,0 +1,151 @@ +import { Users, Followings } from '@/models/index.js'; +import { ILocalUser, IRemoteUser, User } from '@/models/entities/user.js'; +import { deliver } from '@/queue/index.js'; +import { IsNull, Not } from 'typeorm'; + +//#region types +interface IRecipe { + type: string; +} + +interface IFollowersRecipe extends IRecipe { + type: 'Followers'; +} + +interface IDirectRecipe extends IRecipe { + type: 'Direct'; + to: IRemoteUser; +} + +const isFollowers = (recipe: any): recipe is IFollowersRecipe => + recipe.type === 'Followers'; + +const isDirect = (recipe: any): recipe is IDirectRecipe => + recipe.type === 'Direct'; +//#endregion + +export default class DeliverManager { + private actor: { id: User['id']; host: null; }; + private activity: any; + private recipes: IRecipe[] = []; + + /** + * Constructor + * @param actor Actor + * @param activity Activity to deliver + */ + constructor(actor: { id: User['id']; host: null; }, activity: any) { + this.actor = actor; + this.activity = activity; + } + + /** + * Add recipe for followers deliver + */ + public addFollowersRecipe() { + const deliver = { + type: 'Followers', + } as IFollowersRecipe; + + this.addRecipe(deliver); + } + + /** + * Add recipe for direct deliver + * @param to To + */ + public addDirectRecipe(to: IRemoteUser) { + const recipe = { + type: 'Direct', + to, + } as IDirectRecipe; + + this.addRecipe(recipe); + } + + /** + * Add recipe + * @param recipe Recipe + */ + public addRecipe(recipe: IRecipe) { + this.recipes.push(recipe); + } + + /** + * Execute delivers + */ + public async execute() { + if (!Users.isLocalUser(this.actor)) return; + + const inboxes = new Set(); + + /* + build inbox list + + Process follower recipes first to avoid duplication when processing + direct recipes later. + */ + if (this.recipes.some(r => isFollowers(r))) { + // followers deliver + // TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう + // ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう? + const followers = await Followings.find({ + where: { + followeeId: this.actor.id, + followerHost: Not(IsNull()), + }, + select: { + followerSharedInbox: true, + followerInbox: true, + }, + }) as { + followerSharedInbox: string | null; + followerInbox: string; + }[]; + + for (const following of followers) { + const inbox = following.followerSharedInbox || following.followerInbox; + inboxes.add(inbox); + } + } + + this.recipes.filter((recipe): recipe is IDirectRecipe => + // followers recipes have already been processed + isDirect(recipe) + // check that shared inbox has not been added yet + && !(recipe.to.sharedInbox && inboxes.has(recipe.to.sharedInbox)) + // check that they actually have an inbox + && recipe.to.inbox != null, + ) + .forEach(recipe => inboxes.add(recipe.to.inbox!)); + + // deliver + for (const inbox of inboxes) { + deliver(this.actor, this.activity, inbox); + } + } +} + +//#region Utilities +/** + * Deliver activity to followers + * @param activity Activity + * @param from Followee + */ +export async function deliverToFollowers(actor: { id: ILocalUser['id']; host: null; }, activity: any) { + const manager = new DeliverManager(actor, activity); + manager.addFollowersRecipe(); + await manager.execute(); +} + +/** + * Deliver activity to user + * @param activity Activity + * @param to Target user + */ +export async function deliverToUser(actor: { id: ILocalUser['id']; host: null; }, activity: any, to: IRemoteUser) { + const manager = new DeliverManager(actor, activity); + manager.addDirectRecipe(to); + await manager.execute(); +} +//#endregion diff --git a/packages/backend/src/remote/activitypub/kernel/accept/follow.ts b/packages/backend/src/remote/activitypub/kernel/accept/follow.ts new file mode 100644 index 0000000000..4350ef1333 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/accept/follow.ts @@ -0,0 +1,29 @@ +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import accept from '@/services/following/requests/accept.js'; +import { IFollow } from '../../type.js'; +import DbResolver from '../../db-resolver.js'; +import { relayAccepted } from '@/services/relay.js'; + +export default async (actor: CacheableRemoteUser, activity: IFollow): Promise => { + // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある + + const dbResolver = new DbResolver(); + const follower = await dbResolver.getUserFromApId(activity.actor); + + if (follower == null) { + return `skip: follower not found`; + } + + if (follower.host != null) { + return `skip: follower is not a local user`; + } + + // relay + const match = activity.id?.match(/follow-relay\/(\w+)/); + if (match) { + return await relayAccepted(match[1]); + } + + await accept(actor, follower); + return `ok`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/accept/index.ts b/packages/backend/src/remote/activitypub/kernel/accept/index.ts new file mode 100644 index 0000000000..78ef75ade3 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/accept/index.ts @@ -0,0 +1,24 @@ +import Resolver from '../../resolver.js'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import acceptFollow from './follow.js'; +import { IAccept, isFollow, getApType } from '../../type.js'; +import { apLogger } from '../../logger.js'; + +const logger = apLogger; + +export default async (actor: CacheableRemoteUser, activity: IAccept): Promise => { + const uri = activity.id || activity; + + logger.info(`Accept: ${uri}`); + + const resolver = new Resolver(); + + const object = await resolver.resolve(activity.object).catch(e => { + logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isFollow(object)) return await acceptFollow(actor, object); + + return `skip: Unknown Accept type: ${getApType(object)}`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/add/index.ts b/packages/backend/src/remote/activitypub/kernel/add/index.ts new file mode 100644 index 0000000000..c813414f93 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/add/index.ts @@ -0,0 +1,23 @@ +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { IAdd } from '../../type.js'; +import { resolveNote } from '../../models/note.js'; +import { addPinned } from '@/services/i/pin.js'; + +export default async (actor: CacheableRemoteUser, activity: IAdd): Promise => { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + if (activity.target == null) { + throw new Error('target is null'); + } + + if (activity.target === actor.featured) { + const note = await resolveNote(activity.object); + if (note == null) throw new Error('note not found'); + await addPinned(actor, note.id); + return; + } + + throw new Error(`unknown target: ${activity.target}`); +}; diff --git a/packages/backend/src/remote/activitypub/kernel/announce/index.ts b/packages/backend/src/remote/activitypub/kernel/announce/index.ts new file mode 100644 index 0000000000..ae7e507c99 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/announce/index.ts @@ -0,0 +1,19 @@ +import Resolver from '../../resolver.js'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import announceNote from './note.js'; +import { IAnnounce, getApId } from '../../type.js'; +import { apLogger } from '../../logger.js'; + +const logger = apLogger; + +export default async (actor: CacheableRemoteUser, activity: IAnnounce): Promise => { + const uri = getApId(activity); + + logger.info(`Announce: ${uri}`); + + const resolver = new Resolver(); + + const targetUri = getApId(activity.object); + + announceNote(resolver, actor, activity, targetUri); +}; diff --git a/packages/backend/src/remote/activitypub/kernel/announce/note.ts b/packages/backend/src/remote/activitypub/kernel/announce/note.ts new file mode 100644 index 0000000000..759cb4ae83 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/announce/note.ts @@ -0,0 +1,72 @@ +import Resolver from '../../resolver.js'; +import post from '@/services/note/create.js'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { IAnnounce, getApId } from '../../type.js'; +import { fetchNote, resolveNote } from '../../models/note.js'; +import { apLogger } from '../../logger.js'; +import { extractDbHost } from '@/misc/convert-host.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { getApLock } from '@/misc/app-lock.js'; +import { parseAudience } from '../../audience.js'; +import { StatusError } from '@/misc/fetch.js'; +import { Notes } from '@/models/index.js'; + +const logger = apLogger; + +/** + * アナウンスアクティビティを捌きます + */ +export default async function(resolver: Resolver, actor: CacheableRemoteUser, activity: IAnnounce, targetUri: string): Promise { + const uri = getApId(activity); + + if (actor.isSuspended) { + return; + } + + // アナウンス先をブロックしてたら中断 + const meta = await fetchMeta(); + if (meta.blockedHosts.includes(extractDbHost(uri))) return; + + const unlock = await getApLock(uri); + + try { + // 既に同じURIを持つものが登録されていないかチェック + const exist = await fetchNote(uri); + if (exist) { + return; + } + + // Announce対象をresolve + let renote; + try { + renote = await resolveNote(targetUri); + } catch (e) { + // 対象が4xxならスキップ + if (e instanceof StatusError) { + if (e.isClientError) { + logger.warn(`Ignored announce target ${targetUri} - ${e.statusCode}`); + return; + } + + logger.warn(`Error in announce target ${targetUri} - ${e.statusCode || e}`); + } + throw e; + } + + if (!await Notes.isVisibleForMe(renote, actor.id)) return 'skip: invalid actor for this activity'; + + logger.info(`Creating the (Re)Note: ${uri}`); + + const activityAudience = await parseAudience(actor, activity.to, activity.cc); + + await post(actor, { + createdAt: activity.published ? new Date(activity.published) : null, + renote, + visibility: activityAudience.visibility, + visibleUsers: activityAudience.visibleUsers, + uri, + }); + } finally { + unlock(); + } +} diff --git a/packages/backend/src/remote/activitypub/kernel/block/index.ts b/packages/backend/src/remote/activitypub/kernel/block/index.ts new file mode 100644 index 0000000000..5e230ad7b7 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/block/index.ts @@ -0,0 +1,23 @@ +import { IBlock } from '../../type.js'; +import block from '@/services/blocking/create.js'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import DbResolver from '../../db-resolver.js'; +import { Users } from '@/models/index.js'; + +export default async (actor: CacheableRemoteUser, activity: IBlock): Promise => { + // ※ activity.objectにブロック対象があり、それは存在するローカルユーザーのはず + + const dbResolver = new DbResolver(); + const blockee = await dbResolver.getUserFromApId(activity.object); + + if (blockee == null) { + return `skip: blockee not found`; + } + + if (blockee.host != null) { + return `skip: ブロックしようとしているユーザーはローカルユーザーではありません`; + } + + await block(await Users.findOneByOrFail({ id: actor.id }), await Users.findOneByOrFail({ id: blockee.id })); + return `ok`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/create/index.ts b/packages/backend/src/remote/activitypub/kernel/create/index.ts new file mode 100644 index 0000000000..c253f9f667 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/create/index.ts @@ -0,0 +1,43 @@ +import Resolver from '../../resolver.js'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import createNote from './note.js'; +import { ICreate, getApId, isPost, getApType } from '../../type.js'; +import { apLogger } from '../../logger.js'; +import { toArray, concat, unique } from '@/prelude/array.js'; + +const logger = apLogger; + +export default async (actor: CacheableRemoteUser, activity: ICreate): Promise => { + const uri = getApId(activity); + + logger.info(`Create: ${uri}`); + + // copy audiences between activity <=> object. + if (typeof activity.object === 'object') { + const to = unique(concat([toArray(activity.to), toArray(activity.object.to)])); + const cc = unique(concat([toArray(activity.cc), toArray(activity.object.cc)])); + + activity.to = to; + activity.cc = cc; + activity.object.to = to; + activity.object.cc = cc; + } + + // If there is no attributedTo, use Activity actor. + if (typeof activity.object === 'object' && !activity.object.attributedTo) { + activity.object.attributedTo = activity.actor; + } + + const resolver = new Resolver(); + + const object = await resolver.resolve(activity.object).catch(e => { + logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isPost(object)) { + createNote(resolver, actor, object, false, activity); + } else { + logger.warn(`Unknown type: ${getApType(object)}`); + } +}; diff --git a/packages/backend/src/remote/activitypub/kernel/create/note.ts b/packages/backend/src/remote/activitypub/kernel/create/note.ts new file mode 100644 index 0000000000..f8dabe06e2 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/create/note.ts @@ -0,0 +1,44 @@ +import Resolver from '../../resolver.js'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { createNote, fetchNote } from '../../models/note.js'; +import { getApId, IObject, ICreate } from '../../type.js'; +import { getApLock } from '@/misc/app-lock.js'; +import { extractDbHost } from '@/misc/convert-host.js'; +import { StatusError } from '@/misc/fetch.js'; + +/** + * 投稿作成アクティビティを捌きます + */ +export default async function(resolver: Resolver, actor: CacheableRemoteUser, note: IObject, silent = false, activity?: ICreate): Promise { + const uri = getApId(note); + + if (typeof note === 'object') { + if (actor.uri !== note.attributedTo) { + return `skip: actor.uri !== note.attributedTo`; + } + + if (typeof note.id === 'string') { + if (extractDbHost(actor.uri) !== extractDbHost(note.id)) { + return `skip: host in actor.uri !== note.id`; + } + } + } + + const unlock = await getApLock(uri); + + try { + const exist = await fetchNote(note); + if (exist) return 'skip: note exists'; + + await createNote(note, resolver, silent); + return 'ok'; + } catch (e) { + if (e instanceof StatusError && e.isClientError) { + return `skip ${e.statusCode}`; + } else { + throw e; + } + } finally { + unlock(); + } +} diff --git a/packages/backend/src/remote/activitypub/kernel/delete/actor.ts b/packages/backend/src/remote/activitypub/kernel/delete/actor.ts new file mode 100644 index 0000000000..1f94df033d --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/delete/actor.ts @@ -0,0 +1,27 @@ +import { apLogger } from '../../logger.js'; +import { createDeleteAccountJob } from '@/queue/index.js'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { Users } from '@/models/index.js'; + +const logger = apLogger; + +export async function deleteActor(actor: CacheableRemoteUser, uri: string): Promise { + logger.info(`Deleting the Actor: ${uri}`); + + if (actor.uri !== uri) { + return `skip: delete actor ${actor.uri} !== ${uri}`; + } + + const user = await Users.findOneByOrFail({ id: actor.id }); + if (user.isDeleted) { + logger.info(`skip: already deleted`); + } + + const job = await createDeleteAccountJob(actor); + + await Users.update(actor.id, { + isDeleted: true, + }); + + return `ok: queued ${job.name} ${job.id}`; +} diff --git a/packages/backend/src/remote/activitypub/kernel/delete/index.ts b/packages/backend/src/remote/activitypub/kernel/delete/index.ts new file mode 100644 index 0000000000..c7064f553b --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/delete/index.ts @@ -0,0 +1,49 @@ +import deleteNote from './note.js'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { IDelete, getApId, isTombstone, IObject, validPost, validActor } from '../../type.js'; +import { toSingle } from '@/prelude/array.js'; +import { deleteActor } from './actor.js'; + +/** + * 削除アクティビティを捌きます + */ +export default async (actor: CacheableRemoteUser, activity: IDelete): Promise => { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + // 削除対象objectのtype + let formerType: string | undefined; + + if (typeof activity.object === 'string') { + // typeが不明だけど、どうせ消えてるのでremote resolveしない + formerType = undefined; + } else { + const object = activity.object as IObject; + if (isTombstone(object)) { + formerType = toSingle(object.formerType); + } else { + formerType = toSingle(object.type); + } + } + + const uri = getApId(activity.object); + + // type不明でもactorとobjectが同じならばそれはPersonに違いない + if (!formerType && actor.uri === uri) { + formerType = 'Person'; + } + + // それでもなかったらおそらくNote + if (!formerType) { + formerType = 'Note'; + } + + if (validPost.includes(formerType)) { + return await deleteNote(actor, uri); + } else if (validActor.includes(formerType)) { + return await deleteActor(actor, uri); + } else { + return `Unknown type ${formerType}`; + } +}; diff --git a/packages/backend/src/remote/activitypub/kernel/delete/note.ts b/packages/backend/src/remote/activitypub/kernel/delete/note.ts new file mode 100644 index 0000000000..1f44c35562 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/delete/note.ts @@ -0,0 +1,41 @@ +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import deleteNode from '@/services/note/delete.js'; +import { apLogger } from '../../logger.js'; +import DbResolver from '../../db-resolver.js'; +import { getApLock } from '@/misc/app-lock.js'; +import { deleteMessage } from '@/services/messages/delete.js'; + +const logger = apLogger; + +export default async function(actor: CacheableRemoteUser, uri: string): Promise { + logger.info(`Deleting the Note: ${uri}`); + + const unlock = await getApLock(uri); + + try { + const dbResolver = new DbResolver(); + const note = await dbResolver.getNoteFromApId(uri); + + if (note == null) { + const message = await dbResolver.getMessageFromApId(uri); + if (message == null) return 'message not found'; + + if (message.userId !== actor.id) { + return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; + } + + await deleteMessage(message); + + return 'ok: message deleted'; + } + + if (note.userId !== actor.id) { + return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; + } + + await deleteNode(actor, note); + return 'ok: note deleted'; + } finally { + unlock(); + } +} diff --git a/packages/backend/src/remote/activitypub/kernel/flag/index.ts b/packages/backend/src/remote/activitypub/kernel/flag/index.ts new file mode 100644 index 0000000000..aa2f1f5362 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/flag/index.ts @@ -0,0 +1,30 @@ +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import config from '@/config/index.js'; +import { IFlag, getApIds } from '../../type.js'; +import { AbuseUserReports, Users } from '@/models/index.js'; +import { In } from 'typeorm'; +import { genId } from '@/misc/gen-id.js'; + +export default async (actor: CacheableRemoteUser, activity: IFlag): Promise => { + // objectは `(User|Note) | (User|Note)[]` だけど、全パターンDBスキーマと対応させられないので + // 対象ユーザーは一番最初のユーザー として あとはコメントとして格納する + const uris = getApIds(activity.object); + + const userIds = uris.filter(uri => uri.startsWith(config.url + '/users/')).map(uri => uri.split('/').pop()!); + const users = await Users.findBy({ + id: In(userIds), + }); + if (users.length < 1) return `skip`; + + await AbuseUserReports.insert({ + id: genId(), + createdAt: new Date(), + targetUserId: users[0].id, + targetUserHost: users[0].host, + reporterId: actor.id, + reporterHost: actor.host, + comment: `${activity.content}\n${JSON.stringify(uris, null, 2)}`, + }); + + return `ok`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/follow.ts b/packages/backend/src/remote/activitypub/kernel/follow.ts new file mode 100644 index 0000000000..a9e92fa229 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/follow.ts @@ -0,0 +1,20 @@ +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import follow from '@/services/following/create.js'; +import { IFollow } from '../type.js'; +import DbResolver from '../db-resolver.js'; + +export default async (actor: CacheableRemoteUser, activity: IFollow): Promise => { + const dbResolver = new DbResolver(); + const followee = await dbResolver.getUserFromApId(activity.object); + + if (followee == null) { + return `skip: followee not found`; + } + + if (followee.host != null) { + return `skip: フォローしようとしているユーザーはローカルユーザーではありません`; + } + + await follow(actor, followee, activity.id); + return `ok`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/index.ts b/packages/backend/src/remote/activitypub/kernel/index.ts new file mode 100644 index 0000000000..254a121605 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/index.ts @@ -0,0 +1,74 @@ +import { IObject, isCreate, isDelete, isUpdate, isRead, isFollow, isAccept, isReject, isAdd, isRemove, isAnnounce, isLike, isUndo, isBlock, isCollectionOrOrderedCollection, isCollection, isFlag } from '../type.js'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import create from './create/index.js'; +import performDeleteActivity from './delete/index.js'; +import performUpdateActivity from './update/index.js'; +import { performReadActivity } from './read.js'; +import follow from './follow.js'; +import undo from './undo/index.js'; +import like from './like.js'; +import announce from './announce/index.js'; +import accept from './accept/index.js'; +import reject from './reject/index.js'; +import add from './add/index.js'; +import remove from './remove/index.js'; +import block from './block/index.js'; +import flag from './flag/index.js'; +import { apLogger } from '../logger.js'; +import Resolver from '../resolver.js'; +import { toArray } from '@/prelude/array.js'; +import { Users } from '@/models/index.js'; + +export async function performActivity(actor: CacheableRemoteUser, activity: IObject) { + if (isCollectionOrOrderedCollection(activity)) { + const resolver = new Resolver(); + for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) { + const act = await resolver.resolve(item); + try { + await performOneActivity(actor, act); + } catch (err) { + if (err instanceof Error || typeof err === 'string') { + apLogger.error(err); + } + } + } + } else { + await performOneActivity(actor, activity); + } +} + +async function performOneActivity(actor: CacheableRemoteUser, activity: IObject): Promise { + if (actor.isSuspended) return; + + if (isCreate(activity)) { + await create(actor, activity); + } else if (isDelete(activity)) { + await performDeleteActivity(actor, activity); + } else if (isUpdate(activity)) { + await performUpdateActivity(actor, activity); + } else if (isRead(activity)) { + await performReadActivity(actor, activity); + } else if (isFollow(activity)) { + await follow(actor, activity); + } else if (isAccept(activity)) { + await accept(actor, activity); + } else if (isReject(activity)) { + await reject(actor, activity); + } else if (isAdd(activity)) { + await add(actor, activity).catch(err => apLogger.error(err)); + } else if (isRemove(activity)) { + await remove(actor, activity).catch(err => apLogger.error(err)); + } else if (isAnnounce(activity)) { + await announce(actor, activity); + } else if (isLike(activity)) { + await like(actor, activity); + } else if (isUndo(activity)) { + await undo(actor, activity); + } else if (isBlock(activity)) { + await block(actor, activity); + } else if (isFlag(activity)) { + await flag(actor, activity); + } else { + apLogger.warn(`unrecognized activity type: ${(activity as any).type}`); + } +} diff --git a/packages/backend/src/remote/activitypub/kernel/like.ts b/packages/backend/src/remote/activitypub/kernel/like.ts new file mode 100644 index 0000000000..2b65ff7383 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/like.ts @@ -0,0 +1,21 @@ +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { ILike, getApId } from '../type.js'; +import create from '@/services/note/reaction/create.js'; +import { fetchNote, extractEmojis } from '../models/note.js'; + +export default async (actor: CacheableRemoteUser, activity: ILike) => { + const targetUri = getApId(activity.object); + + const note = await fetchNote(targetUri); + if (!note) return `skip: target note not found ${targetUri}`; + + await extractEmojis(activity.tag || [], actor.host).catch(() => null); + + return await create(actor, note, activity._misskey_reaction || activity.content || activity.name).catch(e => { + if (e.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') { + return 'skip: already reacted'; + } else { + throw e; + } + }).then(() => 'ok'); +}; diff --git a/packages/backend/src/remote/activitypub/kernel/read.ts b/packages/backend/src/remote/activitypub/kernel/read.ts new file mode 100644 index 0000000000..f7b0bcecdf --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/read.ts @@ -0,0 +1,27 @@ +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { IRead, getApId } from '../type.js'; +import { isSelfHost, extractDbHost } from '@/misc/convert-host.js'; +import { MessagingMessages } from '@/models/index.js'; +import { readUserMessagingMessage } from '../../../server/api/common/read-messaging-message.js'; + +export const performReadActivity = async (actor: CacheableRemoteUser, activity: IRead): Promise => { + const id = await getApId(activity.object); + + if (!isSelfHost(extractDbHost(id))) { + return `skip: Read to foreign host (${id})`; + } + + const messageId = id.split('/').pop(); + + const message = await MessagingMessages.findOneBy({ id: messageId }); + if (message == null) { + return `skip: message not found`; + } + + if (actor.id !== message.recipientId) { + return `skip: actor is not a message recipient`; + } + + await readUserMessagingMessage(message.recipientId!, message.userId, [message.id]); + return `ok: mark as read (${message.userId} => ${message.recipientId} ${message.id})`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/reject/follow.ts b/packages/backend/src/remote/activitypub/kernel/reject/follow.ts new file mode 100644 index 0000000000..824ac69d70 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/reject/follow.ts @@ -0,0 +1,30 @@ +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { remoteReject } from '@/services/following/reject.js'; +import { IFollow } from '../../type.js'; +import DbResolver from '../../db-resolver.js'; +import { relayRejected } from '@/services/relay.js'; +import { Users } from '@/models/index.js'; + +export default async (actor: CacheableRemoteUser, activity: IFollow): Promise => { + // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある + + const dbResolver = new DbResolver(); + const follower = await dbResolver.getUserFromApId(activity.actor); + + if (follower == null) { + return `skip: follower not found`; + } + + if (!Users.isLocalUser(follower)) { + return `skip: follower is not a local user`; + } + + // relay + const match = activity.id?.match(/follow-relay\/(\w+)/); + if (match) { + return await relayRejected(match[1]); + } + + await remoteReject(actor, follower); + return `ok`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/reject/index.ts b/packages/backend/src/remote/activitypub/kernel/reject/index.ts new file mode 100644 index 0000000000..00f08842f4 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/reject/index.ts @@ -0,0 +1,24 @@ +import Resolver from '../../resolver.js'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import rejectFollow from './follow.js'; +import { IReject, isFollow, getApType } from '../../type.js'; +import { apLogger } from '../../logger.js'; + +const logger = apLogger; + +export default async (actor: CacheableRemoteUser, activity: IReject): Promise => { + const uri = activity.id || activity; + + logger.info(`Reject: ${uri}`); + + const resolver = new Resolver(); + + const object = await resolver.resolve(activity.object).catch(e => { + logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isFollow(object)) return await rejectFollow(actor, object); + + return `skip: Unknown Reject type: ${getApType(object)}`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/remove/index.ts b/packages/backend/src/remote/activitypub/kernel/remove/index.ts new file mode 100644 index 0000000000..11a994a83b --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/remove/index.ts @@ -0,0 +1,23 @@ +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { IRemove } from '../../type.js'; +import { resolveNote } from '../../models/note.js'; +import { removePinned } from '@/services/i/pin.js'; + +export default async (actor: CacheableRemoteUser, activity: IRemove): Promise => { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + if (activity.target == null) { + throw new Error('target is null'); + } + + if (activity.target === actor.featured) { + const note = await resolveNote(activity.object); + if (note == null) throw new Error('note not found'); + await removePinned(actor, note.id); + return; + } + + throw new Error(`unknown target: ${activity.target}`); +}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/accept.ts b/packages/backend/src/remote/activitypub/kernel/undo/accept.ts new file mode 100644 index 0000000000..a6e3929b0f --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/undo/accept.ts @@ -0,0 +1,27 @@ +import unfollow from '@/services/following/delete.js'; +import cancelRequest from '@/services/following/requests/cancel.js'; +import { IAccept } from '../../type.js'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { Followings } from '@/models/index.js'; +import DbResolver from '../../db-resolver.js'; + +export default async (actor: CacheableRemoteUser, activity: IAccept): Promise => { + const dbResolver = new DbResolver(); + + const follower = await dbResolver.getUserFromApId(activity.object); + if (follower == null) { + return `skip: follower not found`; + } + + const following = await Followings.findOneBy({ + followerId: follower.id, + followeeId: actor.id, + }); + + if (following) { + await unfollow(follower, actor); + return `ok: unfollowed`; + } + + return `skip: フォローされていない`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/announce.ts b/packages/backend/src/remote/activitypub/kernel/undo/announce.ts new file mode 100644 index 0000000000..417f39722f --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/undo/announce.ts @@ -0,0 +1,18 @@ +import { Notes } from '@/models/index.js'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { IAnnounce, getApId } from '../../type.js'; +import deleteNote from '@/services/note/delete.js'; + +export const undoAnnounce = async (actor: CacheableRemoteUser, activity: IAnnounce): Promise => { + const uri = getApId(activity); + + const note = await Notes.findOneBy({ + uri, + userId: actor.id, + }); + + if (!note) return 'skip: no such Announce'; + + await deleteNote(actor, note); + return 'ok: deleted'; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/block.ts b/packages/backend/src/remote/activitypub/kernel/undo/block.ts new file mode 100644 index 0000000000..4ac6698578 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/undo/block.ts @@ -0,0 +1,21 @@ +import { IBlock } from '../../type.js'; +import unblock from '@/services/blocking/delete.js'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import DbResolver from '../../db-resolver.js'; +import { Users } from '@/models/index.js'; + +export default async (actor: CacheableRemoteUser, activity: IBlock): Promise => { + const dbResolver = new DbResolver(); + const blockee = await dbResolver.getUserFromApId(activity.object); + + if (blockee == null) { + return `skip: blockee not found`; + } + + if (blockee.host != null) { + return `skip: ブロック解除しようとしているユーザーはローカルユーザーではありません`; + } + + await unblock(await Users.findOneByOrFail({ id: actor.id }), blockee); + return `ok`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/follow.ts b/packages/backend/src/remote/activitypub/kernel/undo/follow.ts new file mode 100644 index 0000000000..6a43c1444a --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/undo/follow.ts @@ -0,0 +1,41 @@ +import unfollow from '@/services/following/delete.js'; +import cancelRequest from '@/services/following/requests/cancel.js'; +import { IFollow } from '../../type.js'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { FollowRequests, Followings } from '@/models/index.js'; +import DbResolver from '../../db-resolver.js'; + +export default async (actor: CacheableRemoteUser, activity: IFollow): Promise => { + const dbResolver = new DbResolver(); + + const followee = await dbResolver.getUserFromApId(activity.object); + if (followee == null) { + return `skip: followee not found`; + } + + if (followee.host != null) { + return `skip: フォロー解除しようとしているユーザーはローカルユーザーではありません`; + } + + const req = await FollowRequests.findOneBy({ + followerId: actor.id, + followeeId: followee.id, + }); + + const following = await Followings.findOneBy({ + followerId: actor.id, + followeeId: followee.id, + }); + + if (req) { + await cancelRequest(followee, actor); + return `ok: follow request canceled`; + } + + if (following) { + await unfollow(actor, followee); + return `ok: unfollowed`; + } + + return `skip: リクエストもフォローもされていない`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/index.ts b/packages/backend/src/remote/activitypub/kernel/undo/index.ts new file mode 100644 index 0000000000..27d433eb33 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/undo/index.ts @@ -0,0 +1,36 @@ +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { IUndo, isFollow, isBlock, isLike, isAnnounce, getApType, isAccept } from '../../type.js'; +import unfollow from './follow.js'; +import unblock from './block.js'; +import undoLike from './like.js'; +import undoAccept from './accept.js'; +import { undoAnnounce } from './announce.js'; +import Resolver from '../../resolver.js'; +import { apLogger } from '../../logger.js'; + +const logger = apLogger; + +export default async (actor: CacheableRemoteUser, activity: IUndo): Promise => { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + const uri = activity.id || activity; + + logger.info(`Undo: ${uri}`); + + const resolver = new Resolver(); + + const object = await resolver.resolve(activity.object).catch(e => { + logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isFollow(object)) return await unfollow(actor, object); + if (isBlock(object)) return await unblock(actor, object); + if (isLike(object)) return await undoLike(actor, object); + if (isAnnounce(object)) return await undoAnnounce(actor, object); + if (isAccept(object)) return await undoAccept(actor, object); + + return `skip: unknown object type ${getApType(object)}`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/like.ts b/packages/backend/src/remote/activitypub/kernel/undo/like.ts new file mode 100644 index 0000000000..01aeba1fb7 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/undo/like.ts @@ -0,0 +1,21 @@ +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { ILike, getApId } from '../../type.js'; +import deleteReaction from '@/services/note/reaction/delete.js'; +import { fetchNote } from '../../models/note.js'; + +/** + * Process Undo.Like activity + */ +export default async (actor: CacheableRemoteUser, activity: ILike) => { + const targetUri = getApId(activity.object); + + const note = await fetchNote(targetUri); + if (!note) return `skip: target note not found ${targetUri}`; + + await deleteReaction(actor, note).catch(e => { + if (e.id === '60527ec9-b4cb-4a88-a6bd-32d3ad26817d') return; + throw e; + }); + + return `ok`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/update/index.ts b/packages/backend/src/remote/activitypub/kernel/update/index.ts new file mode 100644 index 0000000000..a142cb46ef --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/update/index.ts @@ -0,0 +1,34 @@ +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { getApType, IUpdate, isActor } from '../../type.js'; +import { apLogger } from '../../logger.js'; +import { updateQuestion } from '../../models/question.js'; +import Resolver from '../../resolver.js'; +import { updatePerson } from '../../models/person.js'; + +/** + * Updateアクティビティを捌きます + */ +export default async (actor: CacheableRemoteUser, activity: IUpdate): Promise => { + if ('actor' in activity && actor.uri !== activity.actor) { + return 'skip: invalid actor'; + } + + apLogger.debug('Update'); + + const resolver = new Resolver(); + + const object = await resolver.resolve(activity.object).catch(e => { + apLogger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isActor(object)) { + await updatePerson(actor.uri!, resolver, object); + return 'ok: Person updated'; + } else if (getApType(object) === 'Question') { + await updateQuestion(object, resolver).catch(e => console.log(e)); + return 'ok: Question updated'; + } else { + return `skip: Unknown type: ${getApType(object)}`; + } +}; diff --git a/packages/backend/src/remote/activitypub/logger.ts b/packages/backend/src/remote/activitypub/logger.ts new file mode 100644 index 0000000000..cab51b3bf5 --- /dev/null +++ b/packages/backend/src/remote/activitypub/logger.ts @@ -0,0 +1,3 @@ +import { remoteLogger } from '../logger.js'; + +export const apLogger = remoteLogger.createSubLogger('ap', 'magenta'); diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/remote/activitypub/misc/contexts.ts similarity index 97% rename from packages/backend/src/core/activitypub/misc/contexts.ts rename to packages/backend/src/remote/activitypub/misc/contexts.ts index 71c440e5cc..aee0d3629c 100644 --- a/packages/backend/src/core/activitypub/misc/contexts.ts +++ b/packages/backend/src/remote/activitypub/misc/contexts.ts @@ -1,10 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { JsonLd } from 'jsonld/jsonld-spec.js'; - /* eslint:disable:quotemark indent */ const id_v1 = { '@context': { @@ -93,7 +86,7 @@ const id_v1 = { 'accessControl': { '@id': 'perm:accessControl', '@type': '@id' }, 'writePermission': { '@id': 'perm:writePermission', '@type': '@id' }, }, -} satisfies JsonLd; +}; const security_v1 = { '@context': { @@ -144,7 +137,7 @@ const security_v1 = { 'signatureAlgorithm': 'sec:signingAlgorithm', 'signatureValue': 'sec:signatureValue', }, -} satisfies JsonLd; +}; const activitystreams = { '@context': { @@ -524,9 +517,9 @@ const activitystreams = { '@type': '@id', }, }, -} satisfies JsonLd; +}; -export const CONTEXTS: Record = { +export const CONTEXTS: Record = { 'https://w3id.org/identity/v1': id_v1, 'https://w3id.org/security/v1': security_v1, 'https://www.w3.org/ns/activitystreams': activitystreams, diff --git a/packages/backend/src/remote/activitypub/misc/get-note-html.ts b/packages/backend/src/remote/activitypub/misc/get-note-html.ts new file mode 100644 index 0000000000..389039ebed --- /dev/null +++ b/packages/backend/src/remote/activitypub/misc/get-note-html.ts @@ -0,0 +1,8 @@ +import * as mfm from 'mfm-js'; +import { Note } from '@/models/entities/note.js'; +import { toHtml } from '../../../mfm/to-html.js'; + +export default function(note: Note) { + if (!note.text) return ''; + return toHtml(mfm.parse(note.text), JSON.parse(note.mentionedRemoteUsers)); +} diff --git a/packages/backend/src/remote/activitypub/misc/html-to-mfm.ts b/packages/backend/src/remote/activitypub/misc/html-to-mfm.ts new file mode 100644 index 0000000000..bb1ba7925c --- /dev/null +++ b/packages/backend/src/remote/activitypub/misc/html-to-mfm.ts @@ -0,0 +1,9 @@ +import { IObject } from '../type.js'; +import { extractApHashtagObjects } from '../models/tag.js'; +import { fromHtml } from '../../../mfm/from-html.js'; + +export function htmlToMfm(html: string, tag?: IObject | IObject[]) { + const hashtagNames = extractApHashtagObjects(tag).map(x => x.name).filter((x): x is string => x != null); + + return fromHtml(html, hashtagNames); +} diff --git a/packages/backend/src/remote/activitypub/misc/ld-signature.ts b/packages/backend/src/remote/activitypub/misc/ld-signature.ts new file mode 100644 index 0000000000..362a543ece --- /dev/null +++ b/packages/backend/src/remote/activitypub/misc/ld-signature.ts @@ -0,0 +1,135 @@ +import * as crypto from 'node:crypto'; +import jsonld from 'jsonld'; +import { CONTEXTS } from './contexts.js'; +import fetch from 'node-fetch'; +import { httpAgent, httpsAgent } from '@/misc/fetch.js'; + +// RsaSignature2017 based from https://github.com/transmute-industries/RsaSignature2017 + +export class LdSignature { + public debug = false; + public preLoad = true; + public loderTimeout = 10 * 1000; + + constructor() { + } + + public async signRsaSignature2017(data: any, privateKey: string, creator: string, domain?: string, created?: Date): Promise { + const options = { + type: 'RsaSignature2017', + creator, + domain, + nonce: crypto.randomBytes(16).toString('hex'), + created: (created || new Date()).toISOString(), + } as { + type: string; + creator: string; + domain?: string; + nonce: string; + created: string; + }; + + if (!domain) { + delete options.domain; + } + + const toBeSigned = await this.createVerifyData(data, options); + + const signer = crypto.createSign('sha256'); + signer.update(toBeSigned); + signer.end(); + + const signature = signer.sign(privateKey); + + return { + ...data, + signature: { + ...options, + signatureValue: signature.toString('base64'), + }, + }; + } + + public async verifyRsaSignature2017(data: any, publicKey: string): Promise { + const toBeSigned = await this.createVerifyData(data, data.signature); + const verifier = crypto.createVerify('sha256'); + verifier.update(toBeSigned); + return verifier.verify(publicKey, data.signature.signatureValue, 'base64'); + } + + public async createVerifyData(data: any, options: any) { + const transformedOptions = { + ...options, + '@context': 'https://w3id.org/identity/v1', + }; + delete transformedOptions['type']; + delete transformedOptions['id']; + delete transformedOptions['signatureValue']; + const canonizedOptions = await this.normalize(transformedOptions); + const optionsHash = this.sha256(canonizedOptions); + const transformedData = { ...data }; + delete transformedData['signature']; + const cannonidedData = await this.normalize(transformedData); + if (this.debug) console.debug(`cannonidedData: ${cannonidedData}`); + const documentHash = this.sha256(cannonidedData); + const verifyData = `${optionsHash}${documentHash}`; + return verifyData; + } + + public async normalize(data: any) { + const customLoader = this.getLoader(); + return await jsonld.normalize(data, { + documentLoader: customLoader, + }); + } + + private getLoader() { + return async (url: string): Promise => { + if (!url.match('^https?\:\/\/')) throw `Invalid URL ${url}`; + + if (this.preLoad) { + if (url in CONTEXTS) { + if (this.debug) console.debug(`HIT: ${url}`); + return { + contextUrl: null, + document: CONTEXTS[url], + documentUrl: url, + }; + } + } + + if (this.debug) console.debug(`MISS: ${url}`); + const document = await this.fetchDocument(url); + return { + contextUrl: null, + document: document, + documentUrl: url, + }; + }; + } + + private async fetchDocument(url: string) { + const json = await fetch(url, { + headers: { + Accept: 'application/ld+json, application/json', + }, + // TODO + //timeout: this.loderTimeout, + agent: u => u.protocol === 'http:' ? httpAgent : httpsAgent, + }).then(res => { + if (!res.ok) { + throw `${res.status} ${res.statusText}`; + } else { + return res.json(); + } + }); + + return json; + } + + public sha256(data: string): string { + const hash = crypto.createHash('sha256'); + hash.update(data); + return hash.digest('hex'); + } +} diff --git a/packages/backend/src/remote/activitypub/models/icon.ts b/packages/backend/src/remote/activitypub/models/icon.ts new file mode 100644 index 0000000000..50794a937d --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/icon.ts @@ -0,0 +1,5 @@ +export type IIcon = { + type: string; + mediaType?: string; + url?: string; +}; diff --git a/packages/backend/src/remote/activitypub/models/identifier.ts b/packages/backend/src/remote/activitypub/models/identifier.ts new file mode 100644 index 0000000000..f6c3bb8c88 --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/identifier.ts @@ -0,0 +1,5 @@ +export type IIdentifier = { + type: string; + name: string; + value: string; +}; diff --git a/packages/backend/src/remote/activitypub/models/image.ts b/packages/backend/src/remote/activitypub/models/image.ts new file mode 100644 index 0000000000..102b7b1346 --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/image.ts @@ -0,0 +1,68 @@ +import { uploadFromUrl } from '@/services/drive/upload-from-url.js'; +import { CacheableRemoteUser, IRemoteUser } from '@/models/entities/user.js'; +import Resolver from '../resolver.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { apLogger } from '../logger.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { DriveFiles, Users } from '@/models/index.js'; +import { truncate } from '@/misc/truncate.js'; +import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; + +const logger = apLogger; + +/** + * Imageを作成します。 + */ +export async function createImage(actor: CacheableRemoteUser, value: any): Promise { + // 投稿者が凍結されていたらスキップ + if (actor.isSuspended) { + throw new Error('actor has been suspended'); + } + + const image = await new Resolver().resolve(value) as any; + + if (image.url == null) { + throw new Error('invalid image: url not privided'); + } + + logger.info(`Creating the Image: ${image.url}`); + + const instance = await fetchMeta(); + + let file = await uploadFromUrl({ + url: image.url, + user: actor, + uri: image.url, + sensitive: image.sensitive, + isLink: !instance.cacheRemoteFiles, + comment: truncate(image.name, DB_MAX_IMAGE_COMMENT_LENGTH) + }); + + if (file.isLink) { + // URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、 + // URLを更新する + if (file.url !== image.url) { + await DriveFiles.update({ id: file.id }, { + url: image.url, + uri: image.url, + }); + + file = await DriveFiles.findOneByOrFail({ id: file.id }); + } + } + + return file; +} + +/** + * Imageを解決します。 + * + * Misskeyに対象のImageが登録されていればそれを返し、そうでなければ + * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + */ +export async function resolveImage(actor: CacheableRemoteUser, value: any): Promise { + // TODO + + // リモートサーバーからフェッチしてきて登録 + return await createImage(actor, value); +} diff --git a/packages/backend/src/remote/activitypub/models/mention.ts b/packages/backend/src/remote/activitypub/models/mention.ts new file mode 100644 index 0000000000..7483992d2d --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/mention.ts @@ -0,0 +1,22 @@ +import promiseLimit from 'promise-limit'; +import { toArray, unique } from '@/prelude/array.js'; +import { CacheableUser, User } from '@/models/entities/user.js'; +import { IObject, isMention, IApMention } from '../type.js'; +import Resolver from '../resolver.js'; +import { resolvePerson } from './person.js'; + +export async function extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver) { + const hrefs = unique(extractApMentionObjects(tags).map(x => x.href as string)); + + const limit = promiseLimit(2); + const mentionedUsers = (await Promise.all( + hrefs.map(x => limit(() => resolvePerson(x, resolver).catch(() => null))), + )).filter((x): x is CacheableUser => x != null); + + return mentionedUsers; +} + +export function extractApMentionObjects(tags: IObject | IObject[] | null | undefined): IApMention[] { + if (tags == null) return []; + return toArray(tags).filter(isMention); +} diff --git a/packages/backend/src/remote/activitypub/models/note.ts b/packages/backend/src/remote/activitypub/models/note.ts new file mode 100644 index 0000000000..8aca589c92 --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/note.ts @@ -0,0 +1,359 @@ +import promiseLimit from 'promise-limit'; + +import config from '@/config/index.js'; +import Resolver from '../resolver.js'; +import post from '@/services/note/create.js'; +import { resolvePerson } from './person.js'; +import { resolveImage } from './image.js'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { htmlToMfm } from '../misc/html-to-mfm.js'; +import { extractApHashtags } from './tag.js'; +import { unique, toArray, toSingle } from '@/prelude/array.js'; +import { extractPollFromQuestion } from './question.js'; +import vote from '@/services/note/polls/vote.js'; +import { apLogger } from '../logger.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { deliverQuestionUpdate } from '@/services/note/polls/update.js'; +import { extractDbHost, toPuny } from '@/misc/convert-host.js'; +import { Emojis, Polls, MessagingMessages } from '@/models/index.js'; +import { Note } from '@/models/entities/note.js'; +import { IObject, getOneApId, getApId, getOneApHrefNullable, validPost, IPost, isEmoji, getApType } from '../type.js'; +import { Emoji } from '@/models/entities/emoji.js'; +import { genId } from '@/misc/gen-id.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { getApLock } from '@/misc/app-lock.js'; +import { createMessage } from '@/services/messages/create.js'; +import { parseAudience } from '../audience.js'; +import { extractApMentions } from './mention.js'; +import DbResolver from '../db-resolver.js'; +import { StatusError } from '@/misc/fetch.js'; + +const logger = apLogger; + +export function validateNote(object: any, uri: string) { + const expectHost = extractDbHost(uri); + + if (object == null) { + return new Error('invalid Note: object is null'); + } + + if (!validPost.includes(getApType(object))) { + return new Error(`invalid Note: invalid object type ${getApType(object)}`); + } + + if (object.id && extractDbHost(object.id) !== expectHost) { + return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${extractDbHost(object.id)}`); + } + + if (object.attributedTo && extractDbHost(getOneApId(object.attributedTo)) !== expectHost) { + return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${extractDbHost(object.attributedTo)}`); + } + + return null; +} + +/** + * Noteをフェッチします。 + * + * Misskeyに対象のNoteが登録されていればそれを返します。 + */ +export async function fetchNote(object: string | IObject): Promise { + const dbResolver = new DbResolver(); + return await dbResolver.getNoteFromApId(object); +} + +/** + * Noteを作成します。 + */ +export async function createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise { + if (resolver == null) resolver = new Resolver(); + + const object: any = await resolver.resolve(value); + + const entryUri = getApId(value); + const err = validateNote(object, entryUri); + if (err) { + logger.error(`${err.message}`, { + resolver: { + history: resolver.getHistory(), + }, + value: value, + object: object, + }); + throw new Error('invalid note'); + } + + const note: IPost = object; + + logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); + + logger.info(`Creating the Note: ${note.id}`); + + // 投稿者をフェッチ + const actor = await resolvePerson(getOneApId(note.attributedTo), resolver) as CacheableRemoteUser; + + // 投稿者が凍結されていたらスキップ + if (actor.isSuspended) { + throw new Error('actor has been suspended'); + } + + const noteAudience = await parseAudience(actor, note.to, note.cc, resolver); + let visibility = noteAudience.visibility; + const visibleUsers = noteAudience.visibleUsers; + + // Audience (to, cc) が指定されてなかった場合 + if (visibility === 'specified' && visibleUsers.length === 0) { + if (typeof value === 'string') { // 入力がstringならばresolverでGETが発生している + // こちらから匿名GET出来たものならばpublic + visibility = 'public'; + } + } + + let isTalk = note._misskey_talk && visibility === 'specified'; + + const apMentions = await extractApMentions(note.tag, resolver); + const apHashtags = await extractApHashtags(note.tag); + + // 添付ファイル + // TODO: attachmentは必ずしもImageではない + // TODO: attachmentは必ずしも配列ではない + // Noteがsensitiveなら添付もsensitiveにする + const limit = promiseLimit(2); + + note.attachment = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [note.attachment] : []; + const files = note.attachment + .map(attach => attach.sensitive = note.sensitive) + ? (await Promise.all(note.attachment.map(x => limit(() => resolveImage(actor, x)) as Promise))) + .filter(image => image != null) + : []; + + // リプライ + const reply: Note | null = note.inReplyTo + ? await resolveNote(note.inReplyTo, resolver).then(x => { + if (x == null) { + logger.warn(`Specified inReplyTo, but nout found`); + throw new Error('inReplyTo not found'); + } else { + return x; + } + }).catch(async e => { + // トークだったらinReplyToのエラーは無視 + const uri = getApId(note.inReplyTo); + if (uri.startsWith(config.url + '/')) { + const id = uri.split('/').pop(); + const talk = await MessagingMessages.findOneBy({ id }); + if (talk) { + isTalk = true; + return null; + } + } + + logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${e.statusCode || e}`); + throw e; + }) + : null; + + // 引用 + let quote: Note | undefined | null; + + if (note._misskey_quote || note.quoteUrl) { + const tryResolveNote = async (uri: string): Promise<{ + status: 'ok'; + res: Note | null; + } | { + status: 'permerror' | 'temperror'; + }> => { + if (typeof uri !== 'string' || !uri.match(/^https?:/)) return { status: 'permerror' }; + try { + const res = await resolveNote(uri); + if (res) { + return { + status: 'ok', + res, + }; + } else { + return { + status: 'permerror', + }; + } + } catch (e) { + return { + status: (e instanceof StatusError && e.isClientError) ? 'permerror' : 'temperror', + }; + } + }; + + const uris = unique([note._misskey_quote, note.quoteUrl].filter((x): x is string => typeof x === 'string')); + const results = await Promise.all(uris.map(uri => tryResolveNote(uri))); + + quote = results.filter((x): x is { status: 'ok', res: Note | null } => x.status === 'ok').map(x => x.res).find(x => x); + if (!quote) { + if (results.some(x => x.status === 'temperror')) { + throw 'quote resolve failed'; + } + } + } + + const cw = note.summary === '' ? null : note.summary; + + // テキストのパース + let text: string | null = null; + if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source?.content === 'string') { + text = note.source.content; + } else if (typeof note._misskey_content !== 'undefined') { + text = note._misskey_content; + } else if (typeof note.content === 'string') { + text = htmlToMfm(note.content, note.tag); + } + + // vote + if (reply && reply.hasPoll) { + const poll = await Polls.findOneByOrFail({ noteId: reply.id }); + + const tryCreateVote = async (name: string, index: number): Promise => { + if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) { + logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); + } else if (index >= 0) { + logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); + await vote(actor, reply, index); + + // リモートフォロワーにUpdate配信 + deliverQuestionUpdate(reply.id); + } + return null; + }; + + if (note.name) { + return await tryCreateVote(note.name, poll.choices.findIndex(x => x === note.name)); + } + } + + const emojis = await extractEmojis(note.tag || [], actor.host).catch(e => { + logger.info(`extractEmojis: ${e}`); + return [] as Emoji[]; + }); + + const apEmojis = emojis.map(emoji => emoji.name); + + const poll = await extractPollFromQuestion(note, resolver).catch(() => undefined); + + if (isTalk) { + for (const recipient of visibleUsers) { + await createMessage(actor, recipient, undefined, text || undefined, (files && files.length > 0) ? files[0] : null, object.id); + return null; + } + } + + return await post(actor, { + createdAt: note.published ? new Date(note.published) : null, + files, + reply, + renote: quote, + name: note.name, + cw, + text, + localOnly: false, + visibility, + visibleUsers, + apMentions, + apHashtags, + apEmojis, + poll, + uri: note.id, + url: getOneApHrefNullable(note.url), + }, silent); +} + +/** + * Noteを解決します。 + * + * Misskeyに対象のNoteが登録されていればそれを返し、そうでなければ + * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + */ +export async function resolveNote(value: string | IObject, resolver?: Resolver): Promise { + const uri = typeof value === 'string' ? value : value.id; + if (uri == null) throw new Error('missing uri'); + + // ブロックしてたら中断 + const meta = await fetchMeta(); + if (meta.blockedHosts.includes(extractDbHost(uri))) throw { statusCode: 451 }; + + const unlock = await getApLock(uri); + + try { + //#region このサーバーに既に登録されていたらそれを返す + const exist = await fetchNote(uri); + + if (exist) { + return exist; + } + //#endregion + + if (uri.startsWith(config.url)) { + throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note'); + } + + // リモートサーバーからフェッチしてきて登録 + // ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが + // 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。 + return await createNote(uri, resolver, true); + } finally { + unlock(); + } +} + +export async function extractEmojis(tags: IObject | IObject[], host: string): Promise { + host = toPuny(host); + + if (!tags) return []; + + const eomjiTags = toArray(tags).filter(isEmoji); + + return await Promise.all(eomjiTags.map(async tag => { + const name = tag.name!.replace(/^:/, '').replace(/:$/, ''); + tag.icon = toSingle(tag.icon); + + const exists = await Emojis.findOneBy({ + host, + name, + }); + + if (exists) { + if ((tag.updated != null && exists.updatedAt == null) + || (tag.id != null && exists.uri == null) + || (tag.updated != null && exists.updatedAt != null && new Date(tag.updated) > exists.updatedAt) + || (tag.icon!.url !== exists.originalUrl) + ) { + await Emojis.update({ + host, + name, + }, { + uri: tag.id, + originalUrl: tag.icon!.url, + publicUrl: tag.icon!.url, + updatedAt: new Date(), + }); + + return await Emojis.findOneBy({ + host, + name, + }) as Emoji; + } + + return exists; + } + + logger.info(`register emoji host=${host}, name=${name}`); + + return await Emojis.insert({ + id: genId(), + host, + name, + uri: tag.id, + originalUrl: tag.icon!.url, + publicUrl: tag.icon!.url, + updatedAt: new Date(), + aliases: [], + } as Partial).then(x => Emojis.findOneByOrFail(x.identifiers[0])); + })); +} diff --git a/packages/backend/src/remote/activitypub/models/person.ts b/packages/backend/src/remote/activitypub/models/person.ts new file mode 100644 index 0000000000..5ef04588e9 --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/person.ts @@ -0,0 +1,504 @@ +import { URL } from 'node:url'; +import promiseLimit from 'promise-limit'; + +import config from '@/config/index.js'; +import { registerOrFetchInstanceDoc } from '@/services/register-or-fetch-instance-doc.js'; +import { Note } from '@/models/entities/note.js'; +import { updateUsertags } from '@/services/update-hashtag.js'; +import { Users, Instances, DriveFiles, Followings, UserProfiles, UserPublickeys } from '@/models/index.js'; +import { User, IRemoteUser, CacheableUser } from '@/models/entities/user.js'; +import { Emoji } from '@/models/entities/emoji.js'; +import { UserNotePining } from '@/models/entities/user-note-pining.js'; +import { genId } from '@/misc/gen-id.js'; +import { instanceChart, usersChart } from '@/services/chart/index.js'; +import { UserPublickey } from '@/models/entities/user-publickey.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +import { toPuny } from '@/misc/convert-host.js'; +import { UserProfile } from '@/models/entities/user-profile.js'; +import { toArray } from '@/prelude/array.js'; +import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { truncate } from '@/misc/truncate.js'; +import { StatusError } from '@/misc/fetch.js'; +import { uriPersonCache } from '@/services/user-cache.js'; +import { publishInternalEvent } from '@/services/stream.js'; +import { db } from '@/db/postgre.js'; +import { apLogger } from '../logger.js'; +import { htmlToMfm } from '../misc/html-to-mfm.js'; +import { fromHtml } from '../../../mfm/from-html.js'; +import { isCollectionOrOrderedCollection, isCollection, IActor, getApId, getOneApHrefNullable, IObject, isPropertyValue, IApPropertyValue, getApType, isActor } from '../type.js'; +import Resolver from '../resolver.js'; +import { extractApHashtags } from './tag.js'; +import { resolveNote, extractEmojis } from './note.js'; +import { resolveImage } from './image.js'; + +const logger = apLogger; + +const nameLength = 128; +const summaryLength = 2048; + +/** + * Validate and convert to actor object + * @param x Fetched object + * @param uri Fetch target URI + */ +function validateActor(x: IObject, uri: string): IActor { + const expectHost = toPuny(new URL(uri).hostname); + + if (x == null) { + throw new Error('invalid Actor: object is null'); + } + + if (!isActor(x)) { + throw new Error(`invalid Actor type '${x.type}'`); + } + + if (!(typeof x.id === 'string' && x.id.length > 0)) { + throw new Error('invalid Actor: wrong id'); + } + + if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) { + throw new Error('invalid Actor: wrong inbox'); + } + + if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) { + throw new Error('invalid Actor: wrong username'); + } + + // These fields are only informational, and some AP software allows these + // fields to be very long. If they are too long, we cut them off. This way + // we can at least see these users and their activities. + if (x.name) { + if (!(typeof x.name === 'string' && x.name.length > 0)) { + throw new Error('invalid Actor: wrong name'); + } + x.name = truncate(x.name, nameLength); + } + if (x.summary) { + if (!(typeof x.summary === 'string' && x.summary.length > 0)) { + throw new Error('invalid Actor: wrong summary'); + } + x.summary = truncate(x.summary, summaryLength); + } + + const idHost = toPuny(new URL(x.id!).hostname); + if (idHost !== expectHost) { + throw new Error('invalid Actor: id has different host'); + } + + if (x.publicKey) { + if (typeof x.publicKey.id !== 'string') { + throw new Error('invalid Actor: publicKey.id is not a string'); + } + + const publicKeyIdHost = toPuny(new URL(x.publicKey.id).hostname); + if (publicKeyIdHost !== expectHost) { + throw new Error('invalid Actor: publicKey.id has different host'); + } + } + + return x; +} + +/** + * Personをフェッチします。 + * + * Misskeyに対象のPersonが登録されていればそれを返します。 + */ +export async function fetchPerson(uri: string, resolver?: Resolver): Promise { + if (typeof uri !== 'string') throw new Error('uri is not string'); + + const cached = uriPersonCache.get(uri); + if (cached) return cached; + + // URIがこのサーバーを指しているならデータベースからフェッチ + if (uri.startsWith(config.url + '/')) { + const id = uri.split('/').pop(); + const u = await Users.findOneBy({ id }); + if (u) uriPersonCache.set(uri, u); + return u; + } + + //#region このサーバーに既に登録されていたらそれを返す + const exist = await Users.findOneBy({ uri }); + + if (exist) { + uriPersonCache.set(uri, exist); + return exist; + } + //#endregion + + return null; +} + +/** + * Personを作成します。 + */ +export async function createPerson(uri: string, resolver?: Resolver): Promise { + if (typeof uri !== 'string') throw new Error('uri is not string'); + + if (uri.startsWith(config.url)) { + throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); + } + + if (resolver == null) resolver = new Resolver(); + + const object = await resolver.resolve(uri) as any; + + const person = validateActor(object, uri); + + logger.info(`Creating the Person: ${person.id}`); + + const host = toPuny(new URL(object.id).hostname); + + const { fields } = analyzeAttachments(person.attachment || []); + + const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); + + const isBot = getApType(object) === 'Service'; + + const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); + + // Create user + let user: IRemoteUser; + try { + // Start transaction + await db.transaction(async transactionalEntityManager => { + user = await transactionalEntityManager.save(new User({ + id: genId(), + avatarId: null, + bannerId: null, + createdAt: new Date(), + lastFetchedAt: new Date(), + name: truncate(person.name, nameLength), + isLocked: !!person.manuallyApprovesFollowers, + isExplorable: !!person.discoverable, + username: person.preferredUsername, + usernameLower: person.preferredUsername!.toLowerCase(), + host, + inbox: person.inbox, + sharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined), + followersUri: person.followers ? getApId(person.followers) : undefined, + featured: person.featured ? getApId(person.featured) : undefined, + uri: person.id, + tags, + isBot, + isCat: (person as any).isCat === true, + showTimelineReplies: false, + })) as IRemoteUser; + + await transactionalEntityManager.save(new UserProfile({ + userId: user.id, + description: person.summary ? htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, + url: getOneApHrefNullable(person.url), + fields, + birthday: bday ? bday[0] : null, + location: person['vcard:Address'] || null, + userHost: host, + })); + + if (person.publicKey) { + await transactionalEntityManager.save(new UserPublickey({ + userId: user.id, + keyId: person.publicKey.id, + keyPem: person.publicKey.publicKeyPem, + })); + } + }); + } catch (e) { + // duplicate key error + if (isDuplicateKeyValueError(e)) { + // /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応 + const u = await Users.findOneBy({ + uri: person.id, + }); + + if (u) { + user = u as IRemoteUser; + } else { + throw new Error('already registered'); + } + } else { + logger.error(e instanceof Error ? e : new Error(e as string)); + throw e; + } + } + + // Register host + registerOrFetchInstanceDoc(host).then(i => { + Instances.increment({ id: i.id }, 'usersCount', 1); + instanceChart.newUser(i.host); + fetchInstanceMetadata(i); + }); + + usersChart.update(user!, true); + + // ハッシュタグ更新 + updateUsertags(user!, tags); + + //#region アバターとヘッダー画像をフェッチ + const [avatar, banner] = await Promise.all([ + person.icon, + person.image, + ].map(img => + img == null + ? Promise.resolve(null) + : resolveImage(user!, img).catch(() => null), + )); + + const avatarId = avatar ? avatar.id : null; + const bannerId = banner ? banner.id : null; + + await Users.update(user!.id, { + avatarId, + bannerId, + }); + + user!.avatarId = avatarId; + user!.bannerId = bannerId; + //#endregion + + //#region カスタム絵文字取得 + const emojis = await extractEmojis(person.tag || [], host).catch(e => { + logger.info(`extractEmojis: ${e}`); + return [] as Emoji[]; + }); + + const emojiNames = emojis.map(emoji => emoji.name); + + await Users.update(user!.id, { + emojis: emojiNames, + }); + //#endregion + + await updateFeatured(user!.id, resolver).catch(err => logger.error(err)); + + return user!; +} + +/** + * Personの情報を更新します。 + * Misskeyに対象のPersonが登録されていなければ無視します。 + * @param uri URI of Person + * @param resolver Resolver + * @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します) + */ +export async function updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject): Promise { + if (typeof uri !== 'string') throw new Error('uri is not string'); + + // URIがこのサーバーを指しているならスキップ + if (uri.startsWith(config.url + '/')) { + return; + } + + //#region このサーバーに既に登録されているか + const exist = await Users.findOneBy({ uri }) as IRemoteUser; + + if (exist == null) { + return; + } + //#endregion + + if (resolver == null) resolver = new Resolver(); + + const object = hint || await resolver.resolve(uri); + + const person = validateActor(object, uri); + + logger.info(`Updating the Person: ${person.id}`); + + // アバターとヘッダー画像をフェッチ + const [avatar, banner] = await Promise.all([ + person.icon, + person.image, + ].map(img => + img == null + ? Promise.resolve(null) + : resolveImage(exist, img).catch(() => null), + )); + + // カスタム絵文字取得 + const emojis = await extractEmojis(person.tag || [], exist.host).catch(e => { + logger.info(`extractEmojis: ${e}`); + return [] as Emoji[]; + }); + + const emojiNames = emojis.map(emoji => emoji.name); + + const { fields } = analyzeAttachments(person.attachment || []); + + const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); + + const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); + + const updates = { + lastFetchedAt: new Date(), + inbox: person.inbox, + sharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined), + followersUri: person.followers ? getApId(person.followers) : undefined, + featured: person.featured, + emojis: emojiNames, + name: truncate(person.name, nameLength), + tags, + isBot: getApType(object) === 'Service', + isCat: (person as any).isCat === true, + isLocked: !!person.manuallyApprovesFollowers, + isExplorable: !!person.discoverable, + } as Partial; + + if (avatar) { + updates.avatarId = avatar.id; + } + + if (banner) { + updates.bannerId = banner.id; + } + + // Update user + await Users.update(exist.id, updates); + + if (person.publicKey) { + await UserPublickeys.update({ userId: exist.id }, { + keyId: person.publicKey.id, + keyPem: person.publicKey.publicKeyPem, + }); + } + + await UserProfiles.update({ userId: exist.id }, { + url: getOneApHrefNullable(person.url), + fields, + description: person.summary ? htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, + birthday: bday ? bday[0] : null, + location: person['vcard:Address'] || null, + }); + + publishInternalEvent('remoteUserUpdated', { id: exist.id }); + + // ハッシュタグ更新 + updateUsertags(exist, tags); + + // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする + await Followings.update({ + followerId: exist.id, + }, { + followerSharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined), + }); + + await updateFeatured(exist.id, resolver).catch(err => logger.error(err)); +} + +/** + * Personを解決します。 + * + * Misskeyに対象のPersonが登録されていればそれを返し、そうでなければ + * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + */ +export async function resolvePerson(uri: string, resolver?: Resolver): Promise { + if (typeof uri !== 'string') throw new Error('uri is not string'); + + //#region このサーバーに既に登録されていたらそれを返す + const exist = await fetchPerson(uri); + + if (exist) { + return exist; + } + //#endregion + + // リモートサーバーからフェッチしてきて登録 + if (resolver == null) resolver = new Resolver(); + return await createPerson(uri, resolver); +} + +const services: { + [x: string]: (id: string, username: string) => any + } = { + 'misskey:authentication:twitter': (userId, screenName) => ({ userId, screenName }), + 'misskey:authentication:github': (id, login) => ({ id, login }), + 'misskey:authentication:discord': (id, name) => $discord(id, name), + }; + +const $discord = (id: string, name: string) => { + if (typeof name !== 'string') { + name = 'unknown#0000'; + } + const [username, discriminator] = name.split('#'); + return { id, username, discriminator }; +}; + +function addService(target: { [x: string]: any }, source: IApPropertyValue) { + const service = services[source.name]; + + if (typeof source.value !== 'string') { + source.value = 'unknown'; + } + + const [id, username] = source.value.split('@'); + + if (service) { + target[source.name.split(':')[2]] = service(id, username); + } +} + +export function analyzeAttachments(attachments: IObject | IObject[] | undefined) { + const fields: { + name: string, + value: string + }[] = []; + const services: { [x: string]: any } = {}; + + if (Array.isArray(attachments)) { + for (const attachment of attachments.filter(isPropertyValue)) { + if (isPropertyValue(attachment.identifier)) { + addService(services, attachment.identifier); + } else { + fields.push({ + name: attachment.name, + value: fromHtml(attachment.value), + }); + } + } + } + + return { fields, services }; +} + +export async function updateFeatured(userId: User['id'], resolver?: Resolver) { + const user = await Users.findOneByOrFail({ id: userId }); + if (!Users.isRemoteUser(user)) return; + if (!user.featured) return; + + logger.info(`Updating the featured: ${user.uri}`); + + if (resolver == null) resolver = new Resolver(); + + // Resolve to (Ordered)Collection Object + const collection = await resolver.resolveCollection(user.featured); + if (!isCollectionOrOrderedCollection(collection)) throw new Error('Object is not Collection or OrderedCollection'); + + // Resolve to Object(may be Note) arrays + const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems; + const items = await Promise.all(toArray(unresolvedItems).map(x => resolver.resolve(x))); + + // Resolve and regist Notes + const limit = promiseLimit(2); + const featuredNotes = await Promise.all(items + .filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも + .slice(0, 5) + .map(item => limit(() => resolveNote(item, resolver)))); + + await db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.delete(UserNotePining, { userId: user.id }); + + // とりあえずidを別の時間で生成して順番を維持 + let td = 0; + for (const note of featuredNotes.filter(note => note != null)) { + td -= 1000; + transactionalEntityManager.insert(UserNotePining, { + id: genId(new Date(Date.now() + td)), + createdAt: new Date(), + userId: user.id, + noteId: note!.id, + }); + } + }); +} diff --git a/packages/backend/src/remote/activitypub/models/question.ts b/packages/backend/src/remote/activitypub/models/question.ts new file mode 100644 index 0000000000..57070fb1e7 --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/question.ts @@ -0,0 +1,83 @@ +import config from '@/config/index.js'; +import { Notes, Polls } from '@/models/index.js'; +import { IPoll } from '@/models/entities/poll.js'; +import Resolver from '../resolver.js'; +import { IObject, IQuestion, isQuestion } from '../type.js'; +import { apLogger } from '../logger.js'; + +export async function extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise { + if (resolver == null) resolver = new Resolver(); + + const question = await resolver.resolve(source); + + if (!isQuestion(question)) { + throw new Error('invalid type'); + } + + const multiple = !question.oneOf; + const expiresAt = question.endTime ? new Date(question.endTime) : question.closed ? new Date(question.closed) : null; + + if (multiple && !question.anyOf) { + throw new Error('invalid question'); + } + + const choices = question[multiple ? 'anyOf' : 'oneOf']! + .map((x, i) => x.name!); + + const votes = question[multiple ? 'anyOf' : 'oneOf']! + .map((x, i) => x.replies && x.replies.totalItems || x._misskey_votes || 0); + + return { + choices, + votes, + multiple, + expiresAt, + }; +} + +/** + * Update votes of Question + * @param uri URI of AP Question object + * @returns true if updated + */ +export async function updateQuestion(value: any, resolver?: Resolver) { + const uri = typeof value === 'string' ? value : value.id; + + // URIがこのサーバーを指しているならスキップ + if (uri.startsWith(config.url + '/')) throw new Error('uri points local'); + + //#region このサーバーに既に登録されているか + const note = await Notes.findOneBy({ uri }); + if (note == null) throw new Error('Question is not registed'); + + const poll = await Polls.findOneBy({ noteId: note.id }); + if (poll == null) throw new Error('Question is not registed'); + //#endregion + + // resolve new Question object + if (resolver == null) resolver = new Resolver(); + const question = await resolver.resolve(value) as IQuestion; + apLogger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`); + + if (question.type !== 'Question') throw new Error('object is not a Question'); + + const apChoices = question.oneOf || question.anyOf; + + let changed = false; + + for (const choice of poll.choices) { + const oldCount = poll.votes[poll.choices.indexOf(choice)]; + const newCount = apChoices!.filter(ap => ap.name === choice)[0].replies!.totalItems; + + if (oldCount !== newCount) { + changed = true; + poll.votes[poll.choices.indexOf(choice)] = newCount; + } + } + + await Polls.update({ noteId: note.id }, { + votes: poll.votes, + }); + + return changed; +} diff --git a/packages/backend/src/remote/activitypub/models/tag.ts b/packages/backend/src/remote/activitypub/models/tag.ts new file mode 100644 index 0000000000..964dabad04 --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/tag.ts @@ -0,0 +1,18 @@ +import { toArray } from '@/prelude/array.js'; +import { IObject, isHashtag, IApHashtag } from '../type.js'; + +export function extractApHashtags(tags: IObject | IObject[] | null | undefined) { + if (tags == null) return []; + + const hashtags = extractApHashtagObjects(tags); + + return hashtags.map(tag => { + const m = tag.name.match(/^#(.+)/); + return m ? m[1] : null; + }).filter((x): x is string => x != null); +} + +export function extractApHashtagObjects(tags: IObject | IObject[] | null | undefined): IApHashtag[] { + if (tags == null) return []; + return toArray(tags).filter(isHashtag); +} diff --git a/packages/backend/src/remote/activitypub/perform.ts b/packages/backend/src/remote/activitypub/perform.ts new file mode 100644 index 0000000000..a3c10ba945 --- /dev/null +++ b/packages/backend/src/remote/activitypub/perform.ts @@ -0,0 +1,17 @@ +import { IObject } from './type.js'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { performActivity } from './kernel/index.js'; +import { updatePerson } from './models/person.js'; + +export default async (actor: CacheableRemoteUser, activity: IObject): Promise => { + await performActivity(actor, activity); + + // ついでにリモートユーザーの情報が古かったら更新しておく + if (actor.uri) { + if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { + setImmediate(() => { + updatePerson(actor.uri!); + }); + } + } +}; diff --git a/packages/backend/src/remote/activitypub/renderer/accept.ts b/packages/backend/src/remote/activitypub/renderer/accept.ts new file mode 100644 index 0000000000..cb01f6a91b --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/accept.ts @@ -0,0 +1,8 @@ +import config from '@/config/index.js'; +import { User } from '@/models/entities/user.js'; + +export default (object: any, user: { id: User['id']; host: null }) => ({ + type: 'Accept', + actor: `${config.url}/users/${user.id}`, + object, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/add.ts b/packages/backend/src/remote/activitypub/renderer/add.ts new file mode 100644 index 0000000000..ec47884291 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/add.ts @@ -0,0 +1,9 @@ +import config from '@/config/index.js'; +import { ILocalUser } from '@/models/entities/user.js'; + +export default (user: ILocalUser, target: any, object: any) => ({ + type: 'Add', + actor: `${config.url}/users/${user.id}`, + target, + object, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/announce.ts b/packages/backend/src/remote/activitypub/renderer/announce.ts new file mode 100644 index 0000000000..2709fea51d --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/announce.ts @@ -0,0 +1,29 @@ +import config from '@/config/index.js'; +import { Note } from '@/models/entities/note.js'; + +export default (object: any, note: Note) => { + const attributedTo = `${config.url}/users/${note.userId}`; + + let to: string[] = []; + let cc: string[] = []; + + if (note.visibility === 'public') { + to = ['https://www.w3.org/ns/activitystreams#Public']; + cc = [`${attributedTo}/followers`]; + } else if (note.visibility === 'home') { + to = [`${attributedTo}/followers`]; + cc = ['https://www.w3.org/ns/activitystreams#Public']; + } else { + return null; + } + + return { + id: `${config.url}/notes/${note.id}/activity`, + actor: `${config.url}/users/${note.userId}`, + type: 'Announce', + published: note.createdAt.toISOString(), + to, + cc, + object, + }; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/block.ts b/packages/backend/src/remote/activitypub/renderer/block.ts new file mode 100644 index 0000000000..802d7280b1 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/block.ts @@ -0,0 +1,20 @@ +import config from '@/config/index.js'; +import { Blocking } from '@/models/entities/blocking.js'; + +/** + * Renders a block into its ActivityPub representation. + * + * @param block The block to be rendered. The blockee relation must be loaded. + */ +export function renderBlock(block: Blocking) { + if (block.blockee?.uri == null) { + throw new Error('renderBlock: missing blockee uri'); + } + + return { + type: 'Block', + id: `${config.url}/blocks/${block.id}`, + actor: `${config.url}/users/${block.blockerId}`, + object: block.blockee.uri, + }; +} diff --git a/packages/backend/src/remote/activitypub/renderer/create.ts b/packages/backend/src/remote/activitypub/renderer/create.ts new file mode 100644 index 0000000000..281a3cb2af --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/create.ts @@ -0,0 +1,17 @@ +import config from '@/config/index.js'; +import { Note } from '@/models/entities/note.js'; + +export default (object: any, note: Note) => { + const activity = { + id: `${config.url}/notes/${note.id}/activity`, + actor: `${config.url}/users/${note.userId}`, + type: 'Create', + published: note.createdAt.toISOString(), + object, + } as any; + + if (object.to) activity.to = object.to; + if (object.cc) activity.cc = object.cc; + + return activity; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/delete.ts b/packages/backend/src/remote/activitypub/renderer/delete.ts new file mode 100644 index 0000000000..4edd3a8807 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/delete.ts @@ -0,0 +1,9 @@ +import config from '@/config/index.js'; +import { User } from '@/models/entities/user.js'; + +export default (object: any, user: { id: User['id']; host: null }) => ({ + type: 'Delete', + actor: `${config.url}/users/${user.id}`, + object, + published: new Date().toISOString(), +}); diff --git a/packages/backend/src/remote/activitypub/renderer/document.ts b/packages/backend/src/remote/activitypub/renderer/document.ts new file mode 100644 index 0000000000..c973de4c4c --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/document.ts @@ -0,0 +1,9 @@ +import { DriveFile } from '@/models/entities/drive-file.js'; +import { DriveFiles } from '@/models/index.js'; + +export default (file: DriveFile) => ({ + type: 'Document', + mediaType: file.type, + url: DriveFiles.getPublicUrl(file), + name: file.comment, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/emoji.ts b/packages/backend/src/remote/activitypub/renderer/emoji.ts new file mode 100644 index 0000000000..0bf15eefd9 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/emoji.ts @@ -0,0 +1,14 @@ +import config from '@/config/index.js'; +import { Emoji } from '@/models/entities/emoji.js'; + +export default (emoji: Emoji) => ({ + id: `${config.url}/emojis/${emoji.name}`, + type: 'Emoji', + name: `:${emoji.name}:`, + updated: emoji.updatedAt != null ? emoji.updatedAt.toISOString() : new Date().toISOString, + icon: { + type: 'Image', + mediaType: emoji.type || 'image/png', + url: emoji.publicUrl || emoji.originalUrl, // || emoji.originalUrl してるのは後方互換性のため + }, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/flag.ts b/packages/backend/src/remote/activitypub/renderer/flag.ts new file mode 100644 index 0000000000..58eadddbaa --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/flag.ts @@ -0,0 +1,15 @@ +import config from '@/config/index.js'; +import { IObject, IActivity } from '@/remote/activitypub/type.js'; +import { ILocalUser, IRemoteUser } from '@/models/entities/user.js'; +import { getInstanceActor } from '@/services/instance-actor.js'; + +// to anonymise reporters, the reporting actor must be a system user +// object has to be a uri or array of uris +export const renderFlag = (user: ILocalUser, object: [string], content: string) => { + return { + type: 'Flag', + actor: `${config.url}/users/${user.id}`, + content, + object, + }; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/follow-relay.ts b/packages/backend/src/remote/activitypub/renderer/follow-relay.ts new file mode 100644 index 0000000000..2c9678090f --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/follow-relay.ts @@ -0,0 +1,14 @@ +import config from '@/config/index.js'; +import { Relay } from '@/models/entities/relay.js'; +import { ILocalUser } from '@/models/entities/user.js'; + +export function renderFollowRelay(relay: Relay, relayActor: ILocalUser) { + const follow = { + id: `${config.url}/activities/follow-relay/${relay.id}`, + type: 'Follow', + actor: `${config.url}/users/${relayActor.id}`, + object: 'https://www.w3.org/ns/activitystreams#Public', + }; + + return follow; +} diff --git a/packages/backend/src/remote/activitypub/renderer/follow-user.ts b/packages/backend/src/remote/activitypub/renderer/follow-user.ts new file mode 100644 index 0000000000..9a8a16d749 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/follow-user.ts @@ -0,0 +1,12 @@ +import config from '@/config/index.js'; +import { Users } from '@/models/index.js'; +import { User } from '@/models/entities/user.js'; + +/** + * Convert (local|remote)(Follower|Followee)ID to URL + * @param id Follower|Followee ID + */ +export default async function renderFollowUser(id: User['id']): Promise { + const user = await Users.findOneByOrFail({ id: id }); + return Users.isLocalUser(user) ? `${config.url}/users/${user.id}` : user.uri; +} diff --git a/packages/backend/src/remote/activitypub/renderer/follow.ts b/packages/backend/src/remote/activitypub/renderer/follow.ts new file mode 100644 index 0000000000..00fac18ad5 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/follow.ts @@ -0,0 +1,14 @@ +import config from '@/config/index.js'; +import { User } from '@/models/entities/user.js'; +import { Users } from '@/models/index.js'; + +export default (follower: { id: User['id']; host: User['host']; uri: User['host'] }, followee: { id: User['id']; host: User['host']; uri: User['host'] }, requestId?: string) => { + const follow = { + id: requestId ?? `${config.url}/follows/${follower.id}/${followee.id}`, + type: 'Follow', + actor: Users.isLocalUser(follower) ? `${config.url}/users/${follower.id}` : follower.uri, + object: Users.isLocalUser(followee) ? `${config.url}/users/${followee.id}` : followee.uri, + } as any; + + return follow; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/hashtag.ts b/packages/backend/src/remote/activitypub/renderer/hashtag.ts new file mode 100644 index 0000000000..a7b441e006 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/hashtag.ts @@ -0,0 +1,7 @@ +import config from '@/config/index.js'; + +export default (tag: string) => ({ + type: 'Hashtag', + href: `${config.url}/tags/${encodeURIComponent(tag)}`, + name: `#${tag}`, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/image.ts b/packages/backend/src/remote/activitypub/renderer/image.ts new file mode 100644 index 0000000000..c7d5a31a27 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/image.ts @@ -0,0 +1,9 @@ +import { DriveFile } from '@/models/entities/drive-file.js'; +import { DriveFiles } from '@/models/index.js'; + +export default (file: DriveFile) => ({ + type: 'Image', + url: DriveFiles.getPublicUrl(file), + sensitive: file.isSensitive, + name: file.comment, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/index.ts b/packages/backend/src/remote/activitypub/renderer/index.ts new file mode 100644 index 0000000000..f100b77ce5 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/index.ts @@ -0,0 +1,59 @@ +import config from '@/config/index.js'; +import { v4 as uuid } from 'uuid'; +import { IActivity } from '../type.js'; +import { LdSignature } from '../misc/ld-signature.js'; +import { getUserKeypair } from '@/misc/keypair-store.js'; +import { User } from '@/models/entities/user.js'; + +export const renderActivity = (x: any): IActivity | null => { + if (x == null) return null; + + if (typeof x === 'object' && x.id == null) { + x.id = `${config.url}/${uuid()}`; + } + + return Object.assign({ + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + { + // as non-standards + manuallyApprovesFollowers: 'as:manuallyApprovesFollowers', + sensitive: 'as:sensitive', + Hashtag: 'as:Hashtag', + quoteUrl: 'as:quoteUrl', + // Mastodon + toot: 'http://joinmastodon.org/ns#', + Emoji: 'toot:Emoji', + featured: 'toot:featured', + discoverable: 'toot:discoverable', + // schema + schema: 'http://schema.org#', + PropertyValue: 'schema:PropertyValue', + value: 'schema:value', + // Misskey + misskey: 'https://misskey-hub.net/ns#', + '_misskey_content': 'misskey:_misskey_content', + '_misskey_quote': 'misskey:_misskey_quote', + '_misskey_reaction': 'misskey:_misskey_reaction', + '_misskey_votes': 'misskey:_misskey_votes', + '_misskey_talk': 'misskey:_misskey_talk', + 'isCat': 'misskey:isCat', + // vcard + vcard: 'http://www.w3.org/2006/vcard/ns#', + }, + ], + }, x); +}; + +export const attachLdSignature = async (activity: any, user: { id: User['id']; host: null; }): Promise => { + if (activity == null) return null; + + const keypair = await getUserKeypair(user.id); + + const ldSignature = new LdSignature(); + ldSignature.debug = false; + activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${config.url}/users/${user.id}#main-key`); + + return activity; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/key.ts b/packages/backend/src/remote/activitypub/renderer/key.ts new file mode 100644 index 0000000000..c4f3d464f8 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/key.ts @@ -0,0 +1,14 @@ +import config from '@/config/index.js'; +import { ILocalUser } from '@/models/entities/user.js'; +import { UserKeypair } from '@/models/entities/user-keypair.js'; +import { createPublicKey } from 'node:crypto'; + +export default (user: ILocalUser, key: UserKeypair, postfix?: string) => ({ + id: `${config.url}/users/${user.id}${postfix || '/publickey'}`, + type: 'Key', + owner: `${config.url}/users/${user.id}`, + publicKeyPem: createPublicKey(key.publicKey).export({ + type: 'spki', + format: 'pem', + }), +}); diff --git a/packages/backend/src/remote/activitypub/renderer/like.ts b/packages/backend/src/remote/activitypub/renderer/like.ts new file mode 100644 index 0000000000..00fb72e8a3 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/like.ts @@ -0,0 +1,31 @@ +import config from '@/config/index.js'; +import { NoteReaction } from '@/models/entities/note-reaction.js'; +import { Note } from '@/models/entities/note.js'; +import { Emojis } from '@/models/index.js'; +import { IsNull } from 'typeorm'; +import renderEmoji from './emoji.js'; + +export const renderLike = async (noteReaction: NoteReaction, note: Note) => { + const reaction = noteReaction.reaction; + + const object = { + type: 'Like', + id: `${config.url}/likes/${noteReaction.id}`, + actor: `${config.url}/users/${noteReaction.userId}`, + object: note.uri ? note.uri : `${config.url}/notes/${noteReaction.noteId}`, + content: reaction, + _misskey_reaction: reaction, + } as any; + + if (reaction.startsWith(':')) { + const name = reaction.replace(/:/g, ''); + const emoji = await Emojis.findOneBy({ + name, + host: IsNull(), + }); + + if (emoji) object.tag = [ renderEmoji(emoji) ]; + } + + return object; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/mention.ts b/packages/backend/src/remote/activitypub/renderer/mention.ts new file mode 100644 index 0000000000..c7e62e8840 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/mention.ts @@ -0,0 +1,9 @@ +import config from '@/config/index.js'; +import { User, ILocalUser } from '@/models/entities/user.js'; +import { Users } from '@/models/index.js'; + +export default (mention: User) => ({ + type: 'Mention', + href: Users.isRemoteUser(mention) ? mention.uri : `${config.url}/users/${(mention as ILocalUser).id}`, + name: Users.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as ILocalUser).username}`, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/note.ts b/packages/backend/src/remote/activitypub/renderer/note.ts new file mode 100644 index 0000000000..b3bafaa3ab --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/note.ts @@ -0,0 +1,169 @@ +import { In, IsNull } from 'typeorm'; +import config from '@/config/index.js'; +import { Note, IMentionedRemoteUsers } from '@/models/entities/note.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { DriveFiles, Notes, Users, Emojis, Polls } from '@/models/index.js'; +import { Emoji } from '@/models/entities/emoji.js'; +import { Poll } from '@/models/entities/poll.js'; +import toHtml from '../misc/get-note-html.js'; +import renderEmoji from './emoji.js'; +import renderMention from './mention.js'; +import renderHashtag from './hashtag.js'; +import renderDocument from './document.js'; + +export default async function renderNote(note: Note, dive = true, isTalk = false): Promise> { + const getPromisedFiles = async (ids: string[]) => { + if (!ids || ids.length === 0) return []; + const items = await DriveFiles.findBy({ id: In(ids) }); + return ids.map(id => items.find(item => item.id === id)).filter(item => item != null) as DriveFile[]; + }; + + let inReplyTo; + let inReplyToNote: Note | null; + + if (note.replyId) { + inReplyToNote = await Notes.findOneBy({ id: note.replyId }); + + if (inReplyToNote != null) { + const inReplyToUser = await Users.findOneBy({ id: inReplyToNote.userId }); + + if (inReplyToUser != null) { + if (inReplyToNote.uri) { + inReplyTo = inReplyToNote.uri; + } else { + if (dive) { + inReplyTo = await renderNote(inReplyToNote, false); + } else { + inReplyTo = `${config.url}/notes/${inReplyToNote.id}`; + } + } + } + } + } else { + inReplyTo = null; + } + + let quote; + + if (note.renoteId) { + const renote = await Notes.findOneBy({ id: note.renoteId }); + + if (renote) { + quote = renote.uri ? renote.uri : `${config.url}/notes/${renote.id}`; + } + } + + const attributedTo = `${config.url}/users/${note.userId}`; + + const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); + + let to: string[] = []; + let cc: string[] = []; + + if (note.visibility === 'public') { + to = ['https://www.w3.org/ns/activitystreams#Public']; + cc = [`${attributedTo}/followers`].concat(mentions); + } else if (note.visibility === 'home') { + to = [`${attributedTo}/followers`]; + cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions); + } else if (note.visibility === 'followers') { + to = [`${attributedTo}/followers`]; + cc = mentions; + } else { + to = mentions; + } + + const mentionedUsers = note.mentions.length > 0 ? await Users.findBy({ + id: In(note.mentions), + }) : []; + + const hashtagTags = (note.tags || []).map(tag => renderHashtag(tag)); + const mentionTags = mentionedUsers.map(u => renderMention(u)); + + const files = await getPromisedFiles(note.fileIds); + + const text = note.text ?? ''; + let poll: Poll | null = null; + + if (note.hasPoll) { + poll = await Polls.findOneBy({ noteId: note.id }); + } + + let apText = text; + + if (quote) { + apText += `\n\nRE: ${quote}`; + } + + const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; + + const content = toHtml(Object.assign({}, note, { + text: apText, + })); + + const emojis = await getEmojis(note.emojis); + const apemojis = emojis.map(emoji => renderEmoji(emoji)); + + const tag = [ + ...hashtagTags, + ...mentionTags, + ...apemojis, + ]; + + const asPoll = poll ? { + type: 'Question', + content: toHtml(Object.assign({}, note, { + text: text, + })), + [poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt, + [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ + type: 'Note', + name: text, + replies: { + type: 'Collection', + totalItems: poll!.votes[i], + }, + })), + } : {}; + + const asTalk = isTalk ? { + _misskey_talk: true, + } : {}; + + return { + id: `${config.url}/notes/${note.id}`, + type: 'Note', + attributedTo, + summary, + content, + _misskey_content: text, + source: { + content: text, + mediaType: "text/x.misskeymarkdown", + }, + _misskey_quote: quote, + quoteUrl: quote, + published: note.createdAt.toISOString(), + to, + cc, + inReplyTo, + attachment: files.map(renderDocument), + sensitive: note.cw != null || files.some(file => file.isSensitive), + tag, + ...asPoll, + ...asTalk, + }; +} + +export async function getEmojis(names: string[]): Promise { + if (names == null || names.length === 0) return []; + + const emojis = await Promise.all( + names.map(name => Emojis.findOneBy({ + name, + host: IsNull(), + })), + ); + + return emojis.filter(emoji => emoji != null) as Emoji[]; +} diff --git a/packages/backend/src/remote/activitypub/renderer/ordered-collection-page.ts b/packages/backend/src/remote/activitypub/renderer/ordered-collection-page.ts new file mode 100644 index 0000000000..c5e25f577b --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/ordered-collection-page.ts @@ -0,0 +1,23 @@ +/** + * Render OrderedCollectionPage + * @param id URL of self + * @param totalItems Number of total items + * @param orderedItems Items + * @param partOf URL of base + * @param prev URL of prev page (optional) + * @param next URL of next page (optional) + */ +export default function(id: string, totalItems: any, orderedItems: any, partOf: string, prev?: string, next?: string) { + const page = { + id, + partOf, + type: 'OrderedCollectionPage', + totalItems, + orderedItems, + } as any; + + if (prev) page.prev = prev; + if (next) page.next = next; + + return page; +} diff --git a/packages/backend/src/remote/activitypub/renderer/ordered-collection.ts b/packages/backend/src/remote/activitypub/renderer/ordered-collection.ts new file mode 100644 index 0000000000..ff9a77be3d --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/ordered-collection.ts @@ -0,0 +1,28 @@ +/** + * Render OrderedCollection + * @param id URL of self + * @param totalItems Total number of items + * @param first URL of first page (optional) + * @param last URL of last page (optional) + * @param orderedItems attached objects (optional) + */ +export default function(id: string | null, totalItems: any, first?: string, last?: string, orderedItems?: Record[]): { + id: string | null; + type: 'OrderedCollection'; + totalItems: any; + first?: string; + last?: string; + orderedItems?: Record[]; +} { + const page: any = { + id, + type: 'OrderedCollection', + totalItems, + }; + + if (first) page.first = first; + if (last) page.last = last; + if (orderedItems) page.orderedItems = orderedItems; + + return page; +} diff --git a/packages/backend/src/remote/activitypub/renderer/person.ts b/packages/backend/src/remote/activitypub/renderer/person.ts new file mode 100644 index 0000000000..cd2fd74d47 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/person.ts @@ -0,0 +1,89 @@ +import { URL } from 'node:url'; +import * as mfm from 'mfm-js'; +import renderImage from './image.js'; +import renderKey from './key.js'; +import config from '@/config/index.js'; +import { ILocalUser } from '@/models/entities/user.js'; +import { toHtml } from '../../../mfm/to-html.js'; +import { getEmojis } from './note.js'; +import renderEmoji from './emoji.js'; +import { IIdentifier } from '../models/identifier.js'; +import renderHashtag from './hashtag.js'; +import { DriveFiles, UserProfiles } from '@/models/index.js'; +import { getUserKeypair } from '@/misc/keypair-store.js'; + +export async function renderPerson(user: ILocalUser) { + const id = `${config.url}/users/${user.id}`; + const isSystem = !!user.username.match(/\./); + + const [avatar, banner, profile] = await Promise.all([ + user.avatarId ? DriveFiles.findOneBy({ id: user.avatarId }) : Promise.resolve(undefined), + user.bannerId ? DriveFiles.findOneBy({ id: user.bannerId }) : Promise.resolve(undefined), + UserProfiles.findOneByOrFail({ userId: user.id }), + ]); + + const attachment: { + type: 'PropertyValue', + name: string, + value: string, + identifier?: IIdentifier + }[] = []; + + if (profile.fields) { + for (const field of profile.fields) { + attachment.push({ + type: 'PropertyValue', + name: field.name, + value: (field.value != null && field.value.match(/^https?:/)) + ? `
${new URL(field.value).href}` + : field.value, + }); + } + } + + const emojis = await getEmojis(user.emojis); + const apemojis = emojis.map(emoji => renderEmoji(emoji)); + + const hashtagTags = (user.tags || []).map(tag => renderHashtag(tag)); + + const tag = [ + ...apemojis, + ...hashtagTags, + ]; + + const keypair = await getUserKeypair(user.id); + + const person = { + type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person', + id, + inbox: `${id}/inbox`, + outbox: `${id}/outbox`, + followers: `${id}/followers`, + following: `${id}/following`, + featured: `${id}/collections/featured`, + sharedInbox: `${config.url}/inbox`, + endpoints: { sharedInbox: `${config.url}/inbox` }, + url: `${config.url}/@${user.username}`, + preferredUsername: user.username, + name: user.name, + summary: profile.description ? toHtml(mfm.parse(profile.description)) : null, + icon: avatar ? renderImage(avatar) : null, + image: banner ? renderImage(banner) : null, + tag, + manuallyApprovesFollowers: user.isLocked, + discoverable: !!user.isExplorable, + publicKey: renderKey(user, keypair, `#main-key`), + isCat: user.isCat, + attachment: attachment.length ? attachment : undefined, + } as any; + + if (profile?.birthday) { + person['vcard:bday'] = profile.birthday; + } + + if (profile?.location) { + person['vcard:Address'] = profile.location; + } + + return person; +} diff --git a/packages/backend/src/remote/activitypub/renderer/question.ts b/packages/backend/src/remote/activitypub/renderer/question.ts new file mode 100644 index 0000000000..d4d1b590af --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/question.ts @@ -0,0 +1,23 @@ +import config from '@/config/index.js'; +import { User } from '@/models/entities/user.js'; +import { Note } from '@/models/entities/note.js'; +import { Poll } from '@/models/entities/poll.js'; + +export default async function renderQuestion(user: { id: User['id'] }, note: Note, poll: Poll) { + const question = { + type: 'Question', + id: `${config.url}/questions/${note.id}`, + actor: `${config.url}/users/${user.id}`, + content: note.text || '', + [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ + name: text, + _misskey_votes: poll.votes[i], + replies: { + type: 'Collection', + totalItems: poll.votes[i], + }, + })), + }; + + return question; +} diff --git a/packages/backend/src/remote/activitypub/renderer/read.ts b/packages/backend/src/remote/activitypub/renderer/read.ts new file mode 100644 index 0000000000..a30e649f64 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/read.ts @@ -0,0 +1,9 @@ +import config from '@/config/index.js'; +import { User } from '@/models/entities/user.js'; +import { MessagingMessage } from '@/models/entities/messaging-message.js'; + +export const renderReadActivity = (user: { id: User['id'] }, message: MessagingMessage) => ({ + type: 'Read', + actor: `${config.url}/users/${user.id}`, + object: message.uri, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/reject.ts b/packages/backend/src/remote/activitypub/renderer/reject.ts new file mode 100644 index 0000000000..ab4cc1646a --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/reject.ts @@ -0,0 +1,8 @@ +import config from '@/config/index.js'; +import { User } from '@/models/entities/user.js'; + +export default (object: any, user: { id: User['id'] }) => ({ + type: 'Reject', + actor: `${config.url}/users/${user.id}`, + object, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/remove.ts b/packages/backend/src/remote/activitypub/renderer/remove.ts new file mode 100644 index 0000000000..1be3edc5d5 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/remove.ts @@ -0,0 +1,9 @@ +import config from '@/config/index.js'; +import { User } from '@/models/entities/user.js'; + +export default (user: { id: User['id'] }, target: any, object: any) => ({ + type: 'Remove', + actor: `${config.url}/users/${user.id}`, + target, + object, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/tombstone.ts b/packages/backend/src/remote/activitypub/renderer/tombstone.ts new file mode 100644 index 0000000000..313ca74e9d --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/tombstone.ts @@ -0,0 +1,4 @@ +export default (id: string) => ({ + id, + type: 'Tombstone', +}); diff --git a/packages/backend/src/remote/activitypub/renderer/undo.ts b/packages/backend/src/remote/activitypub/renderer/undo.ts new file mode 100644 index 0000000000..46631df9ea --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/undo.ts @@ -0,0 +1,15 @@ +import config from '@/config/index.js'; +import { ILocalUser, User } from '@/models/entities/user.js'; + +export default (object: any, user: { id: User['id'] }) => { + if (object == null) return null; + const id = typeof object.id === 'string' && object.id.startsWith(config.url) ? `${object.id}/undo` : undefined; + + return { + type: 'Undo', + ...(id ? { id } : {}), + actor: `${config.url}/users/${user.id}`, + object, + published: new Date().toISOString(), + }; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/update.ts b/packages/backend/src/remote/activitypub/renderer/update.ts new file mode 100644 index 0000000000..cf880f03fc --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/update.ts @@ -0,0 +1,15 @@ +import config from '@/config/index.js'; +import { User } from '@/models/entities/user.js'; + +export default (object: any, user: { id: User['id'] }) => { + const activity = { + id: `${config.url}/users/${user.id}#updates/${new Date().getTime()}`, + actor: `${config.url}/users/${user.id}`, + type: 'Update', + to: [ 'https://www.w3.org/ns/activitystreams#Public' ], + object, + published: new Date().toISOString(), + } as any; + + return activity; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/vote.ts b/packages/backend/src/remote/activitypub/renderer/vote.ts new file mode 100644 index 0000000000..b6eb8e095d --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/vote.ts @@ -0,0 +1,23 @@ +import config from '@/config/index.js'; +import { Note } from '@/models/entities/note.js'; +import { IRemoteUser, User } from '@/models/entities/user.js'; +import { PollVote } from '@/models/entities/poll-vote.js'; +import { Poll } from '@/models/entities/poll.js'; + +export default async function renderVote(user: { id: User['id'] }, vote: PollVote, note: Note, poll: Poll, pollOwner: IRemoteUser): Promise { + return { + id: `${config.url}/users/${user.id}#votes/${vote.id}/activity`, + actor: `${config.url}/users/${user.id}`, + type: 'Create', + to: [pollOwner.uri], + published: new Date().toISOString(), + object: { + id: `${config.url}/users/${user.id}#votes/${vote.id}`, + type: 'Note', + attributedTo: `${config.url}/users/${user.id}`, + to: [pollOwner.uri], + inReplyTo: note.uri, + name: poll.choices[vote.choice], + }, + }; +} diff --git a/packages/backend/src/remote/activitypub/request.ts b/packages/backend/src/remote/activitypub/request.ts new file mode 100644 index 0000000000..5cbfd8c259 --- /dev/null +++ b/packages/backend/src/remote/activitypub/request.ts @@ -0,0 +1,58 @@ +import config from '@/config/index.js'; +import { getUserKeypair } from '@/misc/keypair-store.js'; +import { User } from '@/models/entities/user.js'; +import { getResponse } from '../../misc/fetch.js'; +import { createSignedPost, createSignedGet } from './ap-request.js'; + +export default async (user: { id: User['id'] }, url: string, object: any) => { + const body = JSON.stringify(object); + + const keypair = await getUserKeypair(user.id); + + const req = createSignedPost({ + key: { + privateKeyPem: keypair.privateKey, + keyId: `${config.url}/users/${user.id}#main-key`, + }, + url, + body, + additionalHeaders: { + 'User-Agent': config.userAgent, + }, + }); + + await getResponse({ + url, + method: req.request.method, + headers: req.request.headers, + body, + }); +}; + +/** + * Get AP object with http-signature + * @param user http-signature user + * @param url URL to fetch + */ +export async function signedGet(url: string, user: { id: User['id'] }) { + const keypair = await getUserKeypair(user.id); + + const req = createSignedGet({ + key: { + privateKeyPem: keypair.privateKey, + keyId: `${config.url}/users/${user.id}#main-key`, + }, + url, + additionalHeaders: { + 'User-Agent': config.userAgent, + }, + }); + + const res = await getResponse({ + url, + method: req.request.method, + headers: req.request.headers, + }); + + return await res.json(); +} diff --git a/packages/backend/src/remote/activitypub/resolver.ts b/packages/backend/src/remote/activitypub/resolver.ts new file mode 100644 index 0000000000..6514c0660f --- /dev/null +++ b/packages/backend/src/remote/activitypub/resolver.ts @@ -0,0 +1,139 @@ +import config from '@/config/index.js'; +import { getJson } from '@/misc/fetch.js'; +import { ILocalUser } from '@/models/entities/user.js'; +import { getInstanceActor } from '@/services/instance-actor.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { extractDbHost, isSelfHost } from '@/misc/convert-host.js'; +import { FollowRequests, Notes, NoteReactions, Polls, Users } from '@/models/index.js'; +import renderNote from '@/remote/activitypub/renderer/note.js'; +import { renderLike } from '@/remote/activitypub/renderer/like.js'; +import { renderPerson } from '@/remote/activitypub/renderer/person.js'; +import renderQuestion from '@/remote/activitypub/renderer/question.js'; +import renderCreate from '@/remote/activitypub/renderer/create.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderFollow from '@/remote/activitypub/renderer/follow.js'; +import { parseUri } from './db-resolver.js'; +import { IObject, isCollectionOrOrderedCollection, ICollection, IOrderedCollection } from './type.js'; +import { signedGet } from './request.js'; + +export default class Resolver { + private history: Set; + private user?: ILocalUser; + private recursionLimit?: number; + + constructor(recursionLimit = 100) { + this.history = new Set(); + this.recursionLimit = recursionLimit; + } + + public getHistory(): string[] { + return Array.from(this.history); + } + + public async resolveCollection(value: string | IObject): Promise { + const collection = typeof value === 'string' + ? await this.resolve(value) + : value; + + if (isCollectionOrOrderedCollection(collection)) { + return collection; + } else { + throw new Error(`unrecognized collection type: ${collection.type}`); + } + } + + public async resolve(value: string | IObject): Promise { + if (value == null) { + throw new Error('resolvee is null (or undefined)'); + } + + if (typeof value !== 'string') { + return value; + } + + if (value.includes('#')) { + // URLs with fragment parts cannot be resolved correctly because + // the fragment part does not get transmitted over HTTP(S). + // Avoid strange behaviour by not trying to resolve these at all. + throw new Error(`cannot resolve URL with fragment: ${value}`); + } + + if (this.history.has(value)) { + throw new Error('cannot resolve already resolved one'); + } + + if (this.recursionLimit && this.history.size > this.recursionLimit) { + throw new Error('hit recursion limit'); + } + + this.history.add(value); + + const host = extractDbHost(value); + if (isSelfHost(host)) { + return await this.resolveLocal(value); + } + + const meta = await fetchMeta(); + if (meta.blockedHosts.includes(host)) { + throw new Error('Instance is blocked'); + } + + if (config.signToActivityPubGet && !this.user) { + this.user = await getInstanceActor(); + } + + const object = (this.user + ? await signedGet(value, this.user) + : await getJson(value, 'application/activity+json, application/ld+json')) as IObject; + + if (object == null || ( + Array.isArray(object['@context']) ? + !(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') : + object['@context'] !== 'https://www.w3.org/ns/activitystreams' + )) { + throw new Error('invalid response'); + } + + return object; + } + + private resolveLocal(url: string): Promise { + const parsed = parseUri(url); + if (!parsed.local) throw new Error('resolveLocal: not local'); + + switch (parsed.type) { + case 'notes': + return Notes.findOneByOrFail({ id: parsed.id }) + .then(note => { + if (parsed.rest === 'activity') { + // this refers to the create activity and not the note itself + return renderActivity(renderCreate(renderNote(note))); + } else { + return renderNote(note); + } + }); + case 'users': + return Users.findOneByOrFail({ id: parsed.id }) + .then(user => renderPerson(user as ILocalUser)); + case 'questions': + // Polls are indexed by the note they are attached to. + return Promise.all([ + Notes.findOneByOrFail({ id: parsed.id }), + Polls.findOneByOrFail({ noteId: parsed.id }), + ]) + .then(([note, poll]) => renderQuestion({ id: note.userId }, note, poll)); + case 'likes': + return NoteReactions.findOneByOrFail({ id: parsed.id }).then(reaction => renderActivity(renderLike(reaction, { uri: null }))); + case 'follows': + // rest should be + if (parsed.rest == null || !/^\w+$/.test(parsed.rest)) throw new Error('resolveLocal: invalid follow URI'); + + return Promise.all( + [parsed.id, parsed.rest].map(id => Users.findOneByOrFail({ id })), + ) + .then(([follower, followee]) => renderActivity(renderFollow(follower, followee, url))); + default: + throw new Error(`resolveLocal: type ${type} unhandled`); + } + } +} diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/remote/activitypub/type.ts similarity index 87% rename from packages/backend/src/core/activitypub/type.ts rename to packages/backend/src/remote/activitypub/type.ts index ce3317ef72..de7eb0ed83 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/remote/activitypub/type.ts @@ -1,31 +1,25 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export type Obj = { [x: string]: any }; +export type obj = { [x: string]: any }; export type ApObject = IObject | string | (IObject | string)[]; export interface IObject { - '@context'?: string | string[] | Obj | Obj[]; + '@context': string | string[] | obj | obj[]; type: string | string[]; id?: string; - name?: string | null; - summary?: string | null; - _misskey_summary?: string; + summary?: string; published?: string; cc?: ApObject; to?: ApObject; - attributedTo?: ApObject; + attributedTo: ApObject; attachment?: any[]; inReplyTo?: any; replies?: ICollection; - content?: string | null; + content?: string; + name?: string; startTime?: Date; endTime?: Date; icon?: any; image?: any; - url?: ApObject | string; + url?: ApObject; href?: string; tag?: IObject | IObject[]; sensitive?: boolean; @@ -117,22 +111,18 @@ export interface IPost extends IObject { mediaType: string; }; _misskey_quote?: string; - _misskey_content?: string; quoteUrl?: string; - quoteUri?: string; - updated?: string; + _misskey_talk: boolean; } export interface IQuestion extends IObject { type: 'Note' | 'Question'; - actor: string; source?: { content: string; mediaType: string; }; _misskey_quote?: string; quoteUrl?: string; - quoteUri?: string; oneOf?: IQuestionChoice[]; anyOf?: IQuestionChoice[]; endTime?: Date; @@ -166,8 +156,6 @@ export interface IActor extends IObject { name?: string; preferredUsername?: string; manuallyApprovesFollowers?: boolean; - movedTo?: string; - alsoKnownAs?: string[]; discoverable?: boolean; inbox: string; sharedInbox?: string; // 後方互換性のため @@ -184,9 +172,6 @@ export interface IActor extends IObject { }; 'vcard:bday'?: string; 'vcard:Address'?: string; - noindex?: boolean; - listenbrainz?: string; - backgroundUrl?: string; } export const isCollection = (object: IObject): object is ICollection => @@ -206,15 +191,14 @@ export interface IApPropertyValue extends IObject { } export const isPropertyValue = (object: IObject): object is IApPropertyValue => + object && getApType(object) === 'PropertyValue' && typeof object.name === 'string' && - 'value' in object && - typeof object.value === 'string'; + typeof (object as any).value === 'string'; export interface IApMention extends IObject { type: 'Mention'; href: string; - name: string; } export const isMention = (object: IObject): object is IApMention => @@ -232,30 +216,12 @@ export const isHashtag = (object: IObject): object is IApHashtag => export interface IApEmoji extends IObject { type: 'Emoji'; - name: string; - updated: string; + updated: Date; } export const isEmoji = (object: IObject): object is IApEmoji => getApType(object) === 'Emoji' && !Array.isArray(object.icon) && object.icon.url != null; -export interface IKey extends IObject { - type: 'Key'; - owner: string; - publicKeyPem: string | Buffer; -} - -export interface IApDocument extends IObject { - type: 'Document'; - name: string | null; - mediaType: string; -} - -export interface IApImage extends IObject { - type: 'Image'; - name: string | null; -} - export interface ICreate extends IActivity { type: 'Create'; } @@ -313,11 +279,6 @@ export interface IFlag extends IActivity { type: 'Flag'; } -export interface IMove extends IActivity { - type: 'Move'; - target: IObject | string; -} - export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create'; export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete'; export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update'; @@ -332,4 +293,3 @@ export const isLike = (object: IObject): object is ILike => getApType(object) == export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce'; export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; -export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move'; diff --git a/packages/backend/src/remote/logger.ts b/packages/backend/src/remote/logger.ts new file mode 100644 index 0000000000..4921f53bd8 --- /dev/null +++ b/packages/backend/src/remote/logger.ts @@ -0,0 +1,3 @@ +import Logger from '@/services/logger.js'; + +export const remoteLogger = new Logger('remote', 'cyan'); diff --git a/packages/backend/src/remote/resolve-user.ts b/packages/backend/src/remote/resolve-user.ts new file mode 100644 index 0000000000..6fc6f2c4d3 --- /dev/null +++ b/packages/backend/src/remote/resolve-user.ts @@ -0,0 +1,111 @@ +import { URL } from 'node:url'; +import webFinger from './webfinger.js'; +import config from '@/config/index.js'; +import { createPerson, updatePerson } from './activitypub/models/person.js'; +import { remoteLogger } from './logger.js'; +import chalk from 'chalk'; +import { User, IRemoteUser } from '@/models/entities/user.js'; +import { Users } from '@/models/index.js'; +import { toPuny } from '@/misc/convert-host.js'; +import { IsNull } from 'typeorm'; + +const logger = remoteLogger.createSubLogger('resolve-user'); + +export async function resolveUser(username: string, host: string | null): Promise { + const usernameLower = username.toLowerCase(); + + if (host == null) { + logger.info(`return local user: ${usernameLower}`); + return await Users.findOneBy({ usernameLower, host: IsNull() }).then(u => { + if (u == null) { + throw new Error('user not found'); + } else { + return u; + } + }); + } + + host = toPuny(host); + + if (config.host === host) { + logger.info(`return local user: ${usernameLower}`); + return await Users.findOneBy({ usernameLower, host: IsNull() }).then(u => { + if (u == null) { + throw new Error('user not found'); + } else { + return u; + } + }); + } + + const user = await Users.findOneBy({ usernameLower, host }) as IRemoteUser | null; + + const acctLower = `${usernameLower}@${host}`; + + if (user == null) { + const self = await resolveSelf(acctLower); + + logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`); + return await createPerson(self.href); + } + + // ユーザー情報が古い場合は、WebFilgerからやりなおして返す + if (user.lastFetchedAt == null || Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { + // 繋がらないインスタンスに何回も試行するのを防ぐ, 後続の同様処理の連続試行を防ぐ ため 試行前にも更新する + await Users.update(user.id, { + lastFetchedAt: new Date(), + }); + + logger.info(`try resync: ${acctLower}`); + const self = await resolveSelf(acctLower); + + if (user.uri !== self.href) { + // if uri mismatch, Fix (user@host <=> AP's Person id(IRemoteUser.uri)) mapping. + logger.info(`uri missmatch: ${acctLower}`); + logger.info(`recovery missmatch uri for (username=${username}, host=${host}) from ${user.uri} to ${self.href}`); + + // validate uri + const uri = new URL(self.href); + if (uri.hostname !== host) { + throw new Error(`Invalid uri`); + } + + await Users.update({ + usernameLower, + host: host, + }, { + uri: self.href, + }); + } else { + logger.info(`uri is fine: ${acctLower}`); + } + + await updatePerson(self.href); + + logger.info(`return resynced remote user: ${acctLower}`); + return await Users.findOneBy({ uri: self.href }).then(u => { + if (u == null) { + throw new Error('user not found'); + } else { + return u; + } + }); + } + + logger.info(`return existing remote user: ${acctLower}`); + return user; +} + +async function resolveSelf(acctLower: string) { + logger.info(`WebFinger for ${chalk.yellow(acctLower)}`); + const finger = await webFinger(acctLower).catch(e => { + logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ e.statusCode || e.message }`); + throw new Error(`Failed to WebFinger for ${acctLower}: ${ e.statusCode || e.message }`); + }); + const self = finger.links.find(link => link.rel != null && link.rel.toLowerCase() === 'self'); + if (!self) { + logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`); + throw new Error('self link not found'); + } + return self; +} diff --git a/packages/backend/src/remote/webfinger.ts b/packages/backend/src/remote/webfinger.ts new file mode 100644 index 0000000000..337df34c2d --- /dev/null +++ b/packages/backend/src/remote/webfinger.ts @@ -0,0 +1,34 @@ +import { URL } from 'node:url'; +import { getJson } from '@/misc/fetch.js'; +import { query as urlQuery } from '@/prelude/url.js'; + +type ILink = { + href: string; + rel?: string; +}; + +type IWebFinger = { + links: ILink[]; + subject: string; +}; + +export default async function(query: string): Promise { + const url = genUrl(query); + + return await getJson(url, 'application/jrd+json, application/json') as IWebFinger; +} + +function genUrl(query: string) { + if (query.match(/^https?:\/\//)) { + const u = new URL(query); + return `${u.protocol}//${u.hostname}/.well-known/webfinger?` + urlQuery({ resource: query }); + } + + const m = query.match(/^([^@]+)@(.*)/); + if (m) { + const hostname = m[2]; + return `https://${hostname}/.well-known/webfinger?` + urlQuery({ resource: `acct:${query}` }); + } + + throw new Error(`Invalid query (${query})`); +} diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts deleted file mode 100644 index 722a17e956..0000000000 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ /dev/null @@ -1,935 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as crypto from 'node:crypto'; -import { IncomingMessage } from 'node:http'; -import { format as formatURL } from 'node:url'; -import { Inject, Injectable } from '@nestjs/common'; -import fastifyAccepts from '@fastify/accepts'; -import httpSignature from '@peertube/http-signature'; -import { Brackets, In, IsNull, LessThan, Not } from 'typeorm'; -import accepts from 'accepts'; -import vary from 'vary'; -import secureJson from 'secure-json-parse'; -import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository } from '@/models/_.js'; -import * as url from '@/misc/prelude/url.js'; -import type { Config } from '@/config.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; -import { QueueService } from '@/core/QueueService.js'; -import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; -import { MetaService } from '@/core/MetaService.js'; -import { UserKeypairService } from '@/core/UserKeypairService.js'; -import { InstanceActorService } from '@/core/InstanceActorService.js'; -import type { MiUserPublickey } from '@/models/UserPublickey.js'; -import type { MiFollowing } from '@/models/Following.js'; -import { countIf } from '@/misc/prelude/array.js'; -import type { MiNote } from '@/models/Note.js'; -import { QueryService } from '@/core/QueryService.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { bindThis } from '@/decorators.js'; -import { IActivity } from '@/core/activitypub/type.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; -import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; -import type { FindOptionsWhere } from 'typeorm'; -import type Logger from '@/logger.js'; -import { LoggerService } from '@/core/LoggerService.js'; - -const ACTIVITY_JSON = 'application/activity+json; charset=utf-8'; -const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; - -@Injectable() -export class ActivityPubServerService { - private logger: Logger; - private authlogger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.noteReactionsRepository) - private noteReactionsRepository: NoteReactionsRepository, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - @Inject(DI.userNotePiningsRepository) - private userNotePiningsRepository: UserNotePiningsRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - @Inject(DI.followRequestsRepository) - private followRequestsRepository: FollowRequestsRepository, - - private metaService: MetaService, - private utilityService: UtilityService, - private userEntityService: UserEntityService, - private instanceActorService: InstanceActorService, - private apRendererService: ApRendererService, - private apDbResolverService: ApDbResolverService, - private queueService: QueueService, - private userKeypairService: UserKeypairService, - private queryService: QueryService, - private loggerService: LoggerService, - ) { - //this.createServer = this.createServer.bind(this); - this.logger = this.loggerService.getLogger('apserv', 'pink'); - this.authlogger = this.logger.createSubLogger('sigcheck'); - } - - @bindThis - private setResponseType(request: FastifyRequest, reply: FastifyReply): void { - const accept = request.accepts().type([ACTIVITY_JSON, LD_JSON]); - if (accept === LD_JSON) { - reply.type(LD_JSON); - } else { - reply.type(ACTIVITY_JSON); - } - } - - /** - * Pack Create or Announce Activity - * @param note Note - */ - @bindThis - private async packActivity(note: MiNote): Promise { - if (isPureRenote(note)) { - const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); - return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note); - } - - return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note); - } - - @bindThis - private async shouldRefuseGetRequest(request: FastifyRequest, reply: FastifyReply, userId: string | undefined = undefined): Promise { - if (!this.config.checkActivityPubGetSignature) return false; - - /* this code is inspired from the `inbox` function below, and - `queue/processors/InboxProcessorService` - - those pieces of code also check `digest`, and various bits from the - request body, but that only makes sense for requests with a body: - here we're validating GET requests - - this is also inspired by FireFish's `checkFetch` - */ - - /* tell any caching proxy that they should not cache these - responses: we wouldn't want the proxy to return a 403 to - someone presenting a valid signature, or return a cached - response body to someone we've blocked! - */ - reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); - - /* we always allow requests about our instance actor, because when - a remote instance needs to check our signature on a request we - sent, it will need to fetch information about the user that - signed it (which is our instance actor), and if we try to check - their signature on *that* request, we'll fetch *their* instance - actor... leading to an infinite recursion */ - if (userId) { - const instanceActor = await this.instanceActorService.getInstanceActor(); - - if (userId === instanceActor.id || userId === instanceActor.username) { - this.authlogger.debug(`${request.id} ${request.url} request to instance.actor, letting through`); - return false; - } - } - - let signature; - - try { - signature = httpSignature.parseRequest(request.raw, { 'headers': [] }); - } catch (e) { - // not signed, or malformed signature: refuse - this.authlogger.warn(`${request.id} ${request.url} not signed, or malformed signature: refuse`); - reply.code(401); - return true; - } - - if (signature.params.headers.indexOf('host') === -1 - || request.headers.host !== this.config.host) { - // no destination host, or not us: refuse - this.authlogger.warn(`${request.id} ${request.url} no destination host, or not us: refuse`); - reply.code(401); - return true; - } - - const keyId = new URL(signature.keyId); - const keyHost = this.utilityService.toPuny(keyId.hostname); - - const meta = await this.metaService.fetch(); - if (this.utilityService.isBlockedHost(meta.blockedHosts, keyHost)) { - /* blocked instance: refuse (we don't care if the signature is - good, if they even pretend to be from a blocked instance, - they're out) */ - this.authlogger.warn(`${request.id} ${request.url} instance ${keyHost} is blocked: refuse`); - reply.code(401); - return true; - } else if (meta.allowlistMode && !this.utilityService.isAllowedHost(meta.allowedHosts, keyHost)) { - /* allowlist mode enabled and instance not on allowlist: refuse */ - this.authLogger.warn(`${request.id} ${request.url} instance ${keyHost} is not on allowlist: refuse`); - reply.code(401); - return true; - } - - // do we know the signer already? - let authUser: { - user: MiRemoteUser; - key: MiUserPublickey | null; - } | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId); - - if (authUser == null) { - /* keyId is often in the shape `${user.uri}#${keyname}`, try - fetching information about the remote user */ - const candidate = formatURL(keyId, { fragment: false }); - this.authlogger.info(`${request.id} ${request.url} we don't know the user for keyId ${keyId}, trying to fetch via ${candidate}`); - authUser = await this.apDbResolverService.getAuthUserFromApId(candidate); - } - - if (authUser?.key == null) { - // we can't figure out who the signer is, or we can't get their key: refuse - this.authlogger.warn(`${request.id} ${request.url} we can't figure out who the signer is, or we can't get their key: refuse`); - reply.code(401); - return true; - } - - let httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); - - if (!httpSignatureValidated) { - this.authlogger.info(`${request.id} ${request.url} failed to validate signature, re-fetching the key for ${authUser.user.uri}`); - // maybe they changed their key? refetch it - authUser.key = await this.apDbResolverService.refetchPublicKeyForApId(authUser.user); - - if (authUser.key != null) { - httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); - } else { - this.authlogger.warn(`${request.id} ${request.url} failed to re-fetch key for ${authUser.user}`); - } - } - - if (!httpSignatureValidated) { - // bad signature: refuse - this.authlogger.info(`${request.id} ${request.url} failed to validate signature: refuse`); - reply.code(401); - return true; - } - - // all good, don't refuse - return false; - } - - @bindThis - private inbox(request: FastifyRequest, reply: FastifyReply) { - let signature; - - try { - signature = httpSignature.parseRequest(request.raw, { 'headers': [] }); - } catch (e) { - reply.code(401); - return; - } - - if (signature.params.headers.indexOf('host') === -1 - || request.headers.host !== this.config.host) { - // Host not specified or not match. - reply.code(401); - return; - } - - if (signature.params.headers.indexOf('digest') === -1) { - // Digest not found. - reply.code(401); - } else { - const digest = request.headers.digest; - - if (typeof digest !== 'string') { - // Huh? - reply.code(401); - return; - } - - const re = /^([a-zA-Z0-9\-]+)=(.+)$/; - const match = digest.match(re); - - if (match == null) { - // Invalid digest - reply.code(401); - return; - } - - const algo = match[1].toUpperCase(); - const digestValue = match[2]; - - if (algo !== 'SHA-256') { - // Unsupported digest algorithm - reply.code(401); - return; - } - - if (request.rawBody == null) { - // Bad request - reply.code(400); - return; - } - - const hash = crypto.createHash('sha256').update(request.rawBody).digest('base64'); - - if (hash !== digestValue) { - // Invalid digest - reply.code(401); - return; - } - } - - this.queueService.inbox(request.body as IActivity, signature); - - reply.code(202); - } - - @bindThis - private async followers( - request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>, - reply: FastifyReply, - ) { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; - - const userId = request.params.user; - - const cursor = request.query.cursor; - if (cursor != null && typeof cursor !== 'string') { - reply.code(400); - return; - } - - const page = request.query.page === 'true'; - - const user = await this.usersRepository.findOneBy({ - id: userId, - host: IsNull(), - }); - - if (user == null) { - reply.code(404); - return; - } - - //#region Check ff visibility - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - - if (profile.followersVisibility === 'private') { - reply.code(403); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30'); - return; - } else if (profile.followersVisibility === 'followers') { - reply.code(403); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30'); - return; - } - //#endregion - - const limit = 10; - const partOf = `${this.config.url}/users/${userId}/followers`; - - if (page) { - const query = { - followeeId: user.id, - } as FindOptionsWhere; - - // カーソルが指定されている場合 - if (cursor) { - query.id = LessThan(cursor); - } - - // Get followers - const followings = await this.followingsRepository.find({ - where: query, - take: limit + 1, - order: { id: -1 }, - }); - - // 「次のページ」があるかどうか - const inStock = followings.length === limit + 1; - if (inStock) followings.pop(); - - const renderedFollowers = await Promise.all(followings.map(following => this.apRendererService.renderFollowUser(following.followerId))); - const rendered = this.apRendererService.renderOrderedCollectionPage( - `${partOf}?${url.query({ - page: 'true', - cursor, - })}`, - user.followersCount, renderedFollowers, partOf, - undefined, - inStock ? `${partOf}?${url.query({ - page: 'true', - cursor: followings.at(-1)!.id, - })}` : undefined, - ); - - this.setResponseType(request, reply); - return (this.apRendererService.addContext(rendered)); - } else { - // index page - const rendered = this.apRendererService.renderOrderedCollection( - partOf, - user.followersCount, - `${partOf}?page=true`, - ); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(rendered)); - } - } - - @bindThis - private async following( - request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>, - reply: FastifyReply, - ) { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; - - const userId = request.params.user; - - const cursor = request.query.cursor; - if (cursor != null && typeof cursor !== 'string') { - reply.code(400); - return; - } - - const page = request.query.page === 'true'; - - const user = await this.usersRepository.findOneBy({ - id: userId, - host: IsNull(), - }); - - if (user == null) { - reply.code(404); - return; - } - - //#region Check ff visibility - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - - if (profile.followingVisibility === 'private') { - reply.code(403); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30'); - return; - } else if (profile.followingVisibility === 'followers') { - reply.code(403); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30'); - return; - } - //#endregion - - const limit = 10; - const partOf = `${this.config.url}/users/${userId}/following`; - - if (page) { - const query = { - followerId: user.id, - } as FindOptionsWhere; - - // カーソルが指定されている場合 - if (cursor) { - query.id = LessThan(cursor); - } - - // Get followings - const followings = await this.followingsRepository.find({ - where: query, - take: limit + 1, - order: { id: -1 }, - }); - - // 「次のページ」があるかどうか - const inStock = followings.length === limit + 1; - if (inStock) followings.pop(); - - const renderedFollowees = await Promise.all(followings.map(following => this.apRendererService.renderFollowUser(following.followeeId))); - const rendered = this.apRendererService.renderOrderedCollectionPage( - `${partOf}?${url.query({ - page: 'true', - cursor, - })}`, - user.followingCount, renderedFollowees, partOf, - undefined, - inStock ? `${partOf}?${url.query({ - page: 'true', - cursor: followings.at(-1)!.id, - })}` : undefined, - ); - - this.setResponseType(request, reply); - return (this.apRendererService.addContext(rendered)); - } else { - // index page - const rendered = this.apRendererService.renderOrderedCollection( - partOf, - user.followingCount, - `${partOf}?page=true`, - ); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(rendered)); - } - } - - @bindThis - private async featured(request: FastifyRequest<{ Params: { user: string; }; }>, reply: FastifyReply) { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; - - const userId = request.params.user; - - const user = await this.usersRepository.findOneBy({ - id: userId, - host: IsNull(), - }); - - if (user == null) { - reply.code(404); - return; - } - - const pinings = await this.userNotePiningsRepository.find({ - where: { userId: user.id }, - order: { id: 'DESC' }, - }); - - const pinnedNotes = (await Promise.all(pinings.map(pining => - this.notesRepository.findOneByOrFail({ id: pining.noteId })))) - .filter(note => !note.localOnly && ['public', 'home'].includes(note.visibility)); - - const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note))); - - const rendered = this.apRendererService.renderOrderedCollection( - `${this.config.url}/users/${userId}/collections/featured`, - renderedNotes.length, - undefined, - undefined, - renderedNotes, - ); - - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(rendered)); - } - - @bindThis - private async outbox( - request: FastifyRequest<{ - Params: { user: string; }; - Querystring: { since_id?: string; until_id?: string; page?: string; }; - }>, - reply: FastifyReply, - ) { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; - - const userId = request.params.user; - - const sinceId = request.query.since_id; - if (sinceId != null && typeof sinceId !== 'string') { - reply.code(400); - return; - } - - const untilId = request.query.until_id; - if (untilId != null && typeof untilId !== 'string') { - reply.code(400); - return; - } - - const page = request.query.page === 'true'; - - if (countIf(x => x != null, [sinceId, untilId]) > 1) { - reply.code(400); - return; - } - - const user = await this.usersRepository.findOneBy({ - id: userId, - host: IsNull(), - }); - - if (user == null) { - reply.code(404); - return; - } - - const limit = 20; - const partOf = `${this.config.url}/users/${userId}/outbox`; - - if (page) { - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId) - .andWhere('note.userId = :userId', { userId: user.id }) - .andWhere(new Brackets(qb => { - qb - .where('note.visibility = \'public\'') - .orWhere('note.visibility = \'home\''); - })) - .andWhere('note.localOnly = FALSE'); - - const notes = await query.limit(limit).getMany(); - - if (sinceId) notes.reverse(); - - const activities = await Promise.all(notes.map(note => this.packActivity(note))); - const rendered = this.apRendererService.renderOrderedCollectionPage( - `${partOf}?${url.query({ - page: 'true', - since_id: sinceId, - until_id: untilId, - })}`, - user.notesCount, activities, partOf, - notes.length ? `${partOf}?${url.query({ - page: 'true', - since_id: notes[0].id, - })}` : undefined, - notes.length ? `${partOf}?${url.query({ - page: 'true', - until_id: notes.at(-1)!.id, - })}` : undefined, - ); - - this.setResponseType(request, reply); - return (this.apRendererService.addContext(rendered)); - } else { - // index page - const rendered = this.apRendererService.renderOrderedCollection( - partOf, - user.notesCount, - `${partOf}?page=true`, - `${partOf}?page=true&since_id=000000000000000000000000`, - ); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(rendered)); - } - } - - @bindThis - private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null) { - if (user == null) { - reply.code(404); - return; - } - - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(await this.apRendererService.renderPerson(user as MiLocalUser))); - } - - @bindThis - public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { - fastify.addConstraintStrategy({ - name: 'apOrHtml', - storage() { - const store = {} as any; - return { - get(key: string) { - return store[key] ?? null; - }, - set(key: string, value: any) { - store[key] = value; - }, - }; - }, - deriveConstraint(request: IncomingMessage) { - const accepted = accepts(request).type(['html', ACTIVITY_JSON, LD_JSON]); - const isAp = typeof accepted === 'string' && !accepted.match(/html/); - return isAp ? 'ap' : 'html'; - }, - }); - - const almostDefaultJsonParser: FastifyBodyParser = function (request, rawBody, done) { - if (rawBody.length === 0) { - const err = new Error('Body cannot be empty!') as any; - err.statusCode = 400; - return done(err); - } - - try { - const json = secureJson.parse(rawBody.toString('utf8'), null, { - protoAction: 'ignore', - constructorAction: 'ignore', - }); - done(null, json); - } catch (err: any) { - err.statusCode = 400; - return done(err); - } - }; - - fastify.register(fastifyAccepts); - fastify.addContentTypeParser('application/activity+json', { parseAs: 'buffer' }, almostDefaultJsonParser); - fastify.addContentTypeParser('application/ld+json', { parseAs: 'buffer' }, almostDefaultJsonParser); - - fastify.addHook('onRequest', (request, reply, done) => { - reply.header('Access-Control-Allow-Headers', 'Accept'); - reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); - reply.header('Access-Control-Allow-Origin', '*'); - reply.header('Access-Control-Expose-Headers', 'Vary'); - done(); - }); - - //#region Routing - // inbox (limit: 64kb) - fastify.post('/inbox', { config: { rawBody: true }, bodyLimit: 1024 * 64 }, async (request, reply) => await this.inbox(request, reply)); - fastify.post('/users/:user/inbox', { config: { rawBody: true }, bodyLimit: 1024 * 64 }, async (request, reply) => await this.inbox(request, reply)); - - // note - fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply)) return; - - vary(reply.raw, 'Accept'); - - const note = await this.notesRepository.findOneBy({ - id: request.params.note, - visibility: In(['public', 'home']), - localOnly: false, - }); - - if (note == null) { - reply.code(404); - return; - } - - // リモートだったらリダイレクト - if (note.userHost != null) { - if (note.uri == null || this.utilityService.isSelfHost(note.userHost)) { - reply.code(500); - return; - } - reply.redirect(note.uri); - return; - } - - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return this.apRendererService.addContext(await this.apRendererService.renderNote(note, false)); - }); - - // note activity - fastify.get<{ Params: { note: string; } }>('/notes/:note/activity', async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply)) return; - - vary(reply.raw, 'Accept'); - - const note = await this.notesRepository.findOneBy({ - id: request.params.note, - userHost: IsNull(), - visibility: In(['public', 'home']), - localOnly: false, - }); - - if (note == null) { - reply.code(404); - return; - } - - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(await this.packActivity(note))); - }); - - // outbox - fastify.get<{ - Params: { user: string; }; - Querystring: { since_id?: string; until_id?: string; page?: string; }; - }>('/users/:user/outbox', async (request, reply) => await this.outbox(request, reply)); - - // followers - fastify.get<{ - Params: { user: string; }; - Querystring: { cursor?: string; page?: string; }; - }>('/users/:user/followers', async (request, reply) => await this.followers(request, reply)); - - // following - fastify.get<{ - Params: { user: string; }; - Querystring: { cursor?: string; page?: string; }; - }>('/users/:user/following', async (request, reply) => await this.following(request, reply)); - - // featured - fastify.get<{ Params: { user: string; }; }>('/users/:user/collections/featured', async (request, reply) => await this.featured(request, reply)); - - // publickey - fastify.get<{ Params: { user: string; } }>('/users/:user/publickey', async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; - - const userId = request.params.user; - - const user = await this.usersRepository.findOneBy({ - id: userId, - host: IsNull(), - }); - - if (user == null) { - reply.code(404); - return; - } - - const keypair = await this.userKeypairService.getUserKeypair(user.id); - - if (this.userEntityService.isLocalUser(user)) { - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(this.apRendererService.renderKey(user, keypair))); - } else { - reply.code(400); - return; - } - }); - - fastify.get<{ Params: { user: string; } }>('/users/:user', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; - - const userId = request.params.user; - - const user = await this.usersRepository.findOneBy({ - id: userId, - host: IsNull(), - isSuspended: false, - }); - - return await this.userInfo(request, reply, user); - }); - - fastify.get<{ Params: { user: string; } }>('/@:user', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; - - const user = await this.usersRepository.findOneBy({ - usernameLower: request.params.user.toLowerCase(), - host: IsNull(), - isSuspended: false, - }); - - return await this.userInfo(request, reply, user); - }); - //#endregion - - // emoji - fastify.get<{ Params: { emoji: string; } }>('/emojis/:emoji', async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply)) return; - - const emoji = await this.emojisRepository.findOneBy({ - host: IsNull(), - name: request.params.emoji, - }); - - if (emoji == null || emoji.localOnly) { - reply.code(404); - return; - } - - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(await this.apRendererService.renderEmoji(emoji))); - }); - - // like - fastify.get<{ Params: { like: string; } }>('/likes/:like', async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply)) return; - - const reaction = await this.noteReactionsRepository.findOneBy({ id: request.params.like }); - - if (reaction == null) { - reply.code(404); - return; - } - - const note = await this.notesRepository.findOneBy({ id: reaction.noteId }); - - if (note == null) { - reply.code(404); - return; - } - - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(await this.apRendererService.renderLike(reaction, note))); - }); - - // follow - fastify.get<{ Params: { follower: string; followee: string; } }>('/follows/:follower/:followee', async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply)) return; - - // This may be used before the follow is completed, so we do not - // check if the following exists. - - const [follower, followee] = await Promise.all([ - this.usersRepository.findOneBy({ - id: request.params.follower, - host: IsNull(), - }), - this.usersRepository.findOneBy({ - id: request.params.followee, - host: Not(IsNull()), - }), - ]) as [MiLocalUser | MiRemoteUser | null, MiLocalUser | MiRemoteUser | null]; - - if (follower == null || followee == null) { - reply.code(404); - return; - } - - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee))); - }); - - // follow - fastify.get<{ Params: { followRequestId: string ; } }>('/follows/:followRequestId', async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply)) return; - - // This may be used before the follow is completed, so we do not - // check if the following exists and only check if the follow request exists. - - const followRequest = await this.followRequestsRepository.findOneBy({ - id: request.params.followRequestId, - }); - - if (followRequest == null) { - reply.code(404); - return; - } - - const [follower, followee] = await Promise.all([ - this.usersRepository.findOneBy({ - id: followRequest.followerId, - host: IsNull(), - }), - this.usersRepository.findOneBy({ - id: followRequest.followeeId, - host: Not(IsNull()), - }), - ]) as [MiLocalUser | MiRemoteUser | null, MiLocalUser | MiRemoteUser | null]; - - if (follower == null || followee == null) { - reply.code(404); - return; - } - - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee))); - }); - - done(); - } -} diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts deleted file mode 100644 index e82ef64dc4..0000000000 --- a/packages/backend/src/server/FileServerService.ts +++ /dev/null @@ -1,469 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; -import { Inject, Injectable } from '@nestjs/common'; -import rename from 'rename'; -import sharp from 'sharp'; -import { sharpBmp } from 'sharp-read-bmp'; -import type { Config } from '@/config.js'; -import type { MiDriveFile, DriveFilesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; -import { StatusError } from '@/misc/status-error.js'; -import type Logger from '@/logger.js'; -import { DownloadService } from '@/core/DownloadService.js'; -import { IImageStreamable, ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js'; -import { VideoProcessingService } from '@/core/VideoProcessingService.js'; -import { InternalStorageService } from '@/core/InternalStorageService.js'; -import { contentDisposition } from '@/misc/content-disposition.js'; -import { FileInfoService } from '@/core/FileInfoService.js'; -import { LoggerService } from '@/core/LoggerService.js'; -import { bindThis } from '@/decorators.js'; -import { isMimeImage } from '@/misc/is-mime-image.js'; -import { correctFilename } from '@/misc/correct-filename.js'; -import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; - -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); - -const assets = `${_dirname}/../../server/file/assets/`; - -@Injectable() -export class FileServerService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private fileInfoService: FileInfoService, - private downloadService: DownloadService, - private imageProcessingService: ImageProcessingService, - private videoProcessingService: VideoProcessingService, - private internalStorageService: InternalStorageService, - private loggerService: LoggerService, - ) { - this.logger = this.loggerService.getLogger('server', 'gray', false); - - //this.createServer = this.createServer.bind(this); - } - - @bindThis - public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { - fastify.addHook('onRequest', (request, reply, done) => { - reply.header('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); - reply.header('Access-Control-Allow-Origin', '*'); - done(); - }); - - fastify.get('/files/app-default.jpg', (request, reply) => { - const file = fs.createReadStream(`${_dirname}/assets/dummy.png`); - reply.header('Content-Type', 'image/jpeg'); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - return reply.send(file); - }); - - fastify.get<{ Params: { key: string; } }>('/files/:key', async (request, reply) => { - return await this.sendDriveFile(request, reply) - .catch(err => this.errorHandler(request, reply, err)); - }); - fastify.get<{ Params: { key: string; } }>('/files/:key/*', async (request, reply) => { - return await this.sendDriveFile(request, reply) - .catch(err => this.errorHandler(request, reply, err)); - }); - - fastify.get<{ - Params: { url: string; }; - Querystring: { url?: string; }; - }>('/proxy/:url*', async (request, reply) => { - return await this.proxyHandler(request, reply) - .catch(err => this.errorHandler(request, reply, err)); - }); - - done(); - } - - @bindThis - private async errorHandler(request: FastifyRequest<{ Params?: { [x: string]: any }; Querystring?: { [x: string]: any }; }>, reply: FastifyReply, err?: any) { - this.logger.error(`${err}`); - - reply.header('Cache-Control', 'max-age=300'); - - if (request.query && 'fallback' in request.query) { - return reply.sendFile('/dummy.png', assets); - } - - if (err instanceof StatusError && (err.statusCode === 302 || err.isClientError)) { - reply.code(err.statusCode); - return; - } - - reply.code(500); - return; - } - - @bindThis - private async sendDriveFile(request: FastifyRequest<{ Params: { key: string; } }>, reply: FastifyReply) { - const key = request.params.key; - const file = await this.getFileFromKey(key).then(); - - if (file === '404') { - reply.code(404); - reply.header('Cache-Control', 'max-age=86400'); - return reply.sendFile('/dummy.png', assets); - } - - if (file === '204') { - reply.code(204); - reply.header('Cache-Control', 'max-age=86400'); - return; - } - - try { - if (file.state === 'remote') { - let image: IImageStreamable | null = null; - - if (file.fileRole === 'thumbnail') { - if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) { - reply.header('Cache-Control', 'max-age=31536000, immutable'); - - const url = new URL(`${this.config.mediaProxy}/static.webp`); - url.searchParams.set('url', file.url); - url.searchParams.set('static', '1'); - - file.cleanup(); - return await reply.redirect(301, url.toString()); - } else if (file.mime.startsWith('video/')) { - const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url); - if (externalThumbnail) { - file.cleanup(); - return await reply.redirect(301, externalThumbnail); - } - - image = await this.videoProcessingService.generateVideoThumbnail(file.path); - } - } - - if (file.fileRole === 'webpublic') { - if (['image/svg+xml'].includes(file.mime)) { - reply.header('Cache-Control', 'max-age=31536000, immutable'); - - const url = new URL(`${this.config.mediaProxy}/svg.webp`); - url.searchParams.set('url', file.url); - - file.cleanup(); - return await reply.redirect(301, url.toString()); - } - } - - if (!image) { - image = { - data: fs.createReadStream(file.path), - ext: file.ext, - type: file.mime, - }; - } - - if ('pipe' in image.data && typeof image.data.pipe === 'function') { - // image.dataがstreamなら、stream終了後にcleanup - image.data.on('end', file.cleanup); - image.data.on('close', file.cleanup); - } else { - // image.dataがstreamでないなら直ちにcleanup - file.cleanup(); - } - - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); - reply.header('Content-Disposition', - contentDisposition( - 'inline', - correctFilename(file.filename, image.ext), - ), - ); - return image.data; - } - - if (file.fileRole !== 'original') { - const filename = rename(file.filename, { - suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', - extname: file.ext ? `.${file.ext}` : '.unknown', - }).toString(); - - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream'); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', filename)); - return fs.createReadStream(file.path); - } else { - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream'); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', file.filename)); - return fs.createReadStream(file.path); - } - } catch (e) { - if ('cleanup' in file) file.cleanup(); - throw e; - } - } - - @bindThis - private async proxyHandler(request: FastifyRequest<{ Params: { url: string; }; Querystring: { url?: string; }; }>, reply: FastifyReply) { - const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url; - - if (typeof url !== 'string') { - reply.code(400); - return; - } - - // アバタークロップなど、どうしてもオリジンである必要がある場合 - const mustOrigin = 'origin' in request.query; - - if (this.config.externalMediaProxyEnabled && !mustOrigin) { - // 外部のメディアプロキシが有効なら、そちらにリダイレクト - - reply.header('Cache-Control', 'public, max-age=259200'); // 3 days - - const url = new URL(`${this.config.mediaProxy}/${request.params.url || ''}`); - - for (const [key, value] of Object.entries(request.query)) { - url.searchParams.append(key, value); - } - - return await reply.redirect( - 301, - url.toString(), - ); - } - - // Create temp file - const file = await this.getStreamAndTypeFromUrl(url); - if (file === '404') { - reply.code(404); - reply.header('Cache-Control', 'max-age=86400'); - return reply.sendFile('/dummy.png', assets); - } - - if (file === '204') { - reply.code(204); - reply.header('Cache-Control', 'max-age=86400'); - return; - } - - try { - const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp'); - const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp'); - - if ( - 'emoji' in request.query || - 'avatar' in request.query || - 'static' in request.query || - 'preview' in request.query || - 'badge' in request.query - ) { - if (!isConvertibleImage) { - // 画像でないなら404でお茶を濁す - throw new StatusError('Unexpected mime', 404); - } - } - - let image: IImageStreamable | null = null; - if ('emoji' in request.query || 'avatar' in request.query) { - if (!isAnimationConvertibleImage && !('static' in request.query)) { - image = { - data: fs.createReadStream(file.path), - ext: file.ext, - type: file.mime, - }; - } else { - const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) })) - .resize({ - height: 'emoji' in request.query ? 128 : 320, - withoutEnlargement: true, - }) - .webp(webpDefault); - - image = { - data, - ext: 'webp', - type: 'image/webp', - }; - } - } else if ('static' in request.query) { - image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422); - } else if ('preview' in request.query) { - image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200); - } else if ('badge' in request.query) { - const mask = (await sharpBmp(file.path, file.mime)) - .resize(96, 96, { - fit: 'contain', - position: 'centre', - withoutEnlargement: false, - }) - .greyscale() - .normalise() - .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast - .flatten({ background: '#000' }) - .toColorspace('b-w'); - - const stats = await mask.clone().stats(); - - if (stats.entropy < 0.1) { - // エントロピーがあまりない場合は404にする - throw new StatusError('Skip to provide badge', 404); - } - - const data = sharp({ - create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, - }) - .pipelineColorspace('b-w') - .boolean(await mask.png().toBuffer(), 'eor'); - - image = { - data: await data.png().toBuffer(), - ext: 'png', - type: 'image/png', - }; - } else if (file.mime === 'image/svg+xml') { - image = this.imageProcessingService.convertToWebpStream(file.path, 2048, 2048); - } else if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) { - throw new StatusError('Rejected type', 403, 'Rejected type'); - } - - if (!image) { - image = { - data: fs.createReadStream(file.path), - ext: file.ext, - type: file.mime, - }; - } - - if ('cleanup' in file) { - if ('pipe' in image.data && typeof image.data.pipe === 'function') { - // image.dataがstreamなら、stream終了後にcleanup - image.data.on('end', file.cleanup); - image.data.on('close', file.cleanup); - } else { - // image.dataがstreamでないなら直ちにcleanup - file.cleanup(); - } - } - - reply.header('Content-Type', image.type); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', - contentDisposition( - 'inline', - correctFilename(file.filename, image.ext), - ), - ); - return image.data; - } catch (e) { - if ('cleanup' in file) file.cleanup(); - throw e; - } - } - - @bindThis - private async getStreamAndTypeFromUrl(url: string): Promise< - { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: MiDriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; } - | '404' - | '204' - > { - if (url.startsWith(`${this.config.url}/files/`)) { - const key = url.replace(`${this.config.url}/files/`, '').split('/').shift(); - if (!key) throw new StatusError('Invalid File Key', 400, 'Invalid File Key'); - - return await this.getFileFromKey(key); - } - - return await this.downloadAndDetectTypeFromUrl(url); - } - - @bindThis - private async downloadAndDetectTypeFromUrl(url: string): Promise< - { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } - > { - const [path, cleanup] = await createTemp(); - try { - const { filename } = await this.downloadService.downloadUrl(url, path); - - const { mime, ext } = await this.fileInfoService.detectType(path); - - return { - state: 'remote', - mime, ext, - path, cleanup, - filename, - }; - } catch (e) { - cleanup(); - throw e; - } - } - - @bindThis - private async getFileFromKey(key: string): Promise< - { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; } - | '404' - | '204' - > { - // Fetch drive file - const file = await this.driveFilesRepository.createQueryBuilder('file') - .where('file.accessKey = :accessKey', { accessKey: key }) - .orWhere('file.thumbnailAccessKey = :thumbnailAccessKey', { thumbnailAccessKey: key }) - .orWhere('file.webpublicAccessKey = :webpublicAccessKey', { webpublicAccessKey: key }) - .getOne(); - - if (file == null) return '404'; - - const isThumbnail = file.thumbnailAccessKey === key; - const isWebpublic = file.webpublicAccessKey === key; - - if (!file.storedInternal) { - if (!(file.isLink && file.uri)) return '204'; - const result = await this.downloadAndDetectTypeFromUrl(file.uri); - return { - ...result, - url: file.uri, - fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original', - file, - filename: file.name, - }; - } - - const path = this.internalStorageService.resolvePath(key); - - if (isThumbnail || isWebpublic) { - const { mime, ext } = await this.fileInfoService.detectType(path); - return { - state: 'stored_internal', - fileRole: isThumbnail ? 'thumbnail' : 'webpublic', - file, - filename: file.name, - mime, ext, - path, - }; - } - - return { - state: 'stored_internal', - fileRole: 'original', - file, - filename: file.name, - // 古いファイルは修正前のmimeを持っているのでできるだけ修正してあげる - mime: this.fileInfoService.fixMime(file.type), - ext: null, - path, - }; - } -} diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts deleted file mode 100644 index 31479269b9..0000000000 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ /dev/null @@ -1,170 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { MetaService } from '@/core/MetaService.js'; -import { MemorySingleCache } from '@/misc/cache.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { bindThis } from '@/decorators.js'; -import NotesChart from '@/core/chart/charts/notes.js'; -import UsersChart from '@/core/chart/charts/users.js'; -import { DEFAULT_POLICIES } from '@/core/RoleService.js'; -import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; - -const nodeinfo2_1path = '/nodeinfo/2.1'; -const nodeinfo2_0path = '/nodeinfo/2.0'; -const nodeinfo_homepage = 'https://misskey-hub.net'; - -@Injectable() -export class NodeinfoServerService { - constructor( - @Inject(DI.config) - private config: Config, - - private userEntityService: UserEntityService, - private metaService: MetaService, - private notesChart: NotesChart, - private usersChart: UsersChart, - ) { - //this.createServer = this.createServer.bind(this); - } - - @bindThis - public getLinks() { - return [{ - rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', - href: this.config.url + nodeinfo2_1path - }, { - rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', - href: this.config.url + nodeinfo2_0path, - }]; - } - - @bindThis - public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { - const nodeinfo2 = async (version: number) => { - const now = Date.now(); - - const notesChart = await this.notesChart.getChart('hour', 1, null); - const localPosts = notesChart.local.total[0]; - - const usersChart = await this.usersChart.getChart('hour', 1, null); - const total = usersChart.local.total[0]; - - const [ - meta, - //activeHalfyear, - //activeMonth, - ] = await Promise.all([ - this.metaService.fetch(true), - // 重い - //this.usersRepository.count({ where: { host: IsNull(), lastActiveDate: MoreThan(new Date(now - 15552000000)) } }), - //this.usersRepository.count({ where: { host: IsNull(), lastActiveDate: MoreThan(new Date(now - 2592000000)) } }), - ]); - - const activeHalfyear = null; - const activeMonth = null; - - const proxyAccount = meta.proxyAccountId ? await this.userEntityService.pack(meta.proxyAccountId).catch(() => null) : null; - - const basePolicies = { ...DEFAULT_POLICIES, ...meta.policies }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const document: any = { - software: { - name: 'sharkey', - version: this.config.version, - homepage: nodeinfo_homepage, - repository: meta.repositoryUrl, - }, - protocols: ['activitypub'], - services: { - inbound: [] as string[], - outbound: ['atom1.0', 'rss2.0'], - }, - openRegistrations: !meta.disableRegistration, - usage: { - users: { total, activeHalfyear, activeMonth }, - localPosts, - localComments: 0, - }, - metadata: { - nodeName: meta.name, - nodeDescription: meta.description, - nodeAdmins: [{ - name: meta.maintainerName, - email: meta.maintainerEmail, - }], - // deprecated - maintainer: { - name: meta.maintainerName, - email: meta.maintainerEmail, - }, - langs: meta.langs, - tosUrl: meta.termsOfServiceUrl, - privacyPolicyUrl: meta.privacyPolicyUrl, - impressumUrl: meta.impressumUrl, - repositoryUrl: meta.repositoryUrl, - feedbackUrl: meta.feedbackUrl, - disableRegistration: meta.disableRegistration, - disableLocalTimeline: !basePolicies.ltlAvailable, - disableGlobalTimeline: !basePolicies.gtlAvailable, - disableBubbleTimeline: !basePolicies.btlAvailable, - emailRequiredForSignup: meta.emailRequiredForSignup, - enableHcaptcha: meta.enableHcaptcha, - enableRecaptcha: meta.enableRecaptcha, - maxNoteTextLength: this.config.maxNoteLength, - enableEmail: meta.enableEmail, - enableServiceWorker: meta.enableServiceWorker, - proxyAccountName: proxyAccount ? proxyAccount.username : null, - themeColor: meta.themeColor ?? '#86b300', - }, - }; - if (version >= 21) { - document.software.repository = meta.repositoryUrl; - document.software.homepage = meta.repositoryUrl; - } - return document; - }; - - const cache = new MemorySingleCache>>(1000 * 60 * 10); - - fastify.get(nodeinfo2_1path, async (request, reply) => { - const base = await cache.fetch(() => nodeinfo2(21)); - - reply - .type( - 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"', - ) - .header('Cache-Control', 'public, max-age=600') - .header('Access-Control-Allow-Headers', 'Accept') - .header('Access-Control-Allow-Methods', 'GET, OPTIONS') - .header('Access-Control-Allow-Origin', '*') - .header('Access-Control-Expose-Headers', 'Vary'); - return { version: '2.1', ...base }; - }); - - fastify.get(nodeinfo2_0path, async (request, reply) => { - const base = await cache.fetch(() => nodeinfo2(20)); - - delete (base as any).software.repository; - - reply - .type( - 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"', - ) - .header('Cache-Control', 'public, max-age=600') - .header('Access-Control-Allow-Headers', 'Accept') - .header('Access-Control-Allow-Methods', 'GET, OPTIONS') - .header('Access-Control-Allow-Origin', '*') - .header('Access-Control-Expose-Headers', 'Vary'); - return { version: '2.0', ...base }; - }); - - done(); - } -} diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts deleted file mode 100644 index 52070b5157..0000000000 --- a/packages/backend/src/server/ServerModule.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Module } from '@nestjs/common'; -import { EndpointsModule } from '@/server/api/EndpointsModule.js'; -import { CoreModule } from '@/core/CoreModule.js'; -import { ApiCallService } from './api/ApiCallService.js'; -import { FileServerService } from './FileServerService.js'; -import { NodeinfoServerService } from './NodeinfoServerService.js'; -import { ServerService } from './ServerService.js'; -import { WellKnownServerService } from './WellKnownServerService.js'; -import { GetterService } from './api/GetterService.js'; -import { ChannelsService } from './api/stream/ChannelsService.js'; -import { ActivityPubServerService } from './ActivityPubServerService.js'; -import { ApiLoggerService } from './api/ApiLoggerService.js'; -import { ApiServerService } from './api/ApiServerService.js'; -import { AuthenticateService } from './api/AuthenticateService.js'; -import { RateLimiterService } from './api/RateLimiterService.js'; -import { SigninApiService } from './api/SigninApiService.js'; -import { SigninService } from './api/SigninService.js'; -import { SignupApiService } from './api/SignupApiService.js'; -import { StreamingApiServerService } from './api/StreamingApiServerService.js'; -import { ClientServerService } from './web/ClientServerService.js'; -import { MastoConverters } from './api/mastodon/converters.js'; -import { FeedService } from './web/FeedService.js'; -import { UrlPreviewService } from './web/UrlPreviewService.js'; -import { MainChannelService } from './api/stream/channels/main.js'; -import { AdminChannelService } from './api/stream/channels/admin.js'; -import { AntennaChannelService } from './api/stream/channels/antenna.js'; -import { ChannelChannelService } from './api/stream/channels/channel.js'; -import { DriveChannelService } from './api/stream/channels/drive.js'; -import { GlobalTimelineChannelService } from './api/stream/channels/global-timeline.js'; -import { BubbleTimelineChannelService } from './api/stream/channels/bubble-timeline.js'; -import { HashtagChannelService } from './api/stream/channels/hashtag.js'; -import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js'; -import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js'; -import { LocalTimelineChannelService } from './api/stream/channels/local-timeline.js'; -import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; -import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; -import { UserListChannelService } from './api/stream/channels/user-list.js'; -import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; -import { MastodonApiServerService } from './api/mastodon/MastodonApiServerService.js'; -import { ClientLoggerService } from './web/ClientLoggerService.js'; -import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; -import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; - -@Module({ - imports: [ - EndpointsModule, - CoreModule, - ], - providers: [ - ClientServerService, - ClientLoggerService, - FeedService, - UrlPreviewService, - ActivityPubServerService, - FileServerService, - NodeinfoServerService, - ServerService, - WellKnownServerService, - GetterService, - ChannelsService, - ApiCallService, - ApiLoggerService, - ApiServerService, - AuthenticateService, - RateLimiterService, - SigninApiService, - SigninService, - SignupApiService, - StreamingApiServerService, - MainChannelService, - AdminChannelService, - AntennaChannelService, - ChannelChannelService, - DriveChannelService, - GlobalTimelineChannelService, - BubbleTimelineChannelService, - HashtagChannelService, - RoleTimelineChannelService, - HomeTimelineChannelService, - HybridTimelineChannelService, - LocalTimelineChannelService, - QueueStatsChannelService, - ServerStatsChannelService, - UserListChannelService, - OpenApiServerService, - MastodonApiServerService, - OAuth2ProviderService, - MastoConverters, - ], - exports: [ - ServerService, - ], -}) -export class ServerModule {} diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts deleted file mode 100644 index 3b43b931ae..0000000000 --- a/packages/backend/src/server/ServerService.ts +++ /dev/null @@ -1,272 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import cluster from 'node:cluster'; -import * as fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import Fastify, { FastifyInstance } from 'fastify'; -import fastifyStatic from '@fastify/static'; -import fastifyRawBody from 'fastify-raw-body'; -import { IsNull } from 'typeorm'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import type { Config } from '@/config.js'; -import type { EmojisRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import type Logger from '@/logger.js'; -import * as Acct from '@/misc/acct.js'; -import { genIdenticon } from '@/misc/gen-identicon.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { LoggerService } from '@/core/LoggerService.js'; -import { bindThis } from '@/decorators.js'; -import { MetaService } from '@/core/MetaService.js'; -import { ActivityPubServerService } from './ActivityPubServerService.js'; -import { NodeinfoServerService } from './NodeinfoServerService.js'; -import { ApiServerService } from './api/ApiServerService.js'; -import { StreamingApiServerService } from './api/StreamingApiServerService.js'; -import { WellKnownServerService } from './WellKnownServerService.js'; -import { FileServerService } from './FileServerService.js'; -import { ClientServerService } from './web/ClientServerService.js'; -import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; -import { MastodonApiServerService } from './api/mastodon/MastodonApiServerService.js'; -import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; - -const _dirname = fileURLToPath(new URL('.', import.meta.url)); - -@Injectable() -export class ServerService implements OnApplicationShutdown { - private logger: Logger; - #fastify: FastifyInstance; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private metaService: MetaService, - private userEntityService: UserEntityService, - private apiServerService: ApiServerService, - private openApiServerService: OpenApiServerService, - private mastodonApiServerService: MastodonApiServerService, - private streamingApiServerService: StreamingApiServerService, - private activityPubServerService: ActivityPubServerService, - private wellKnownServerService: WellKnownServerService, - private nodeinfoServerService: NodeinfoServerService, - private fileServerService: FileServerService, - private clientServerService: ClientServerService, - private globalEventService: GlobalEventService, - private loggerService: LoggerService, - private oauth2ProviderService: OAuth2ProviderService, - ) { - this.logger = this.loggerService.getLogger('server', 'gray', false); - } - - @bindThis - public async launch(): Promise { - const fastify = Fastify({ - trustProxy: true, - logger: false, - }); - this.#fastify = fastify; - - // HSTS - // 6months (15552000sec) - if (this.config.url.startsWith('https') && !this.config.disableHsts) { - fastify.addHook('onRequest', (request, reply, done) => { - reply.header('strict-transport-security', 'max-age=15552000; preload'); - done(); - }); - } - - // Register raw-body parser for ActivityPub HTTP signature validation. - await fastify.register(fastifyRawBody, { - global: false, - encoding: null, - runFirst: true, - }); - - // Register non-serving static server so that the child services can use reply.sendFile. - // `root` here is just a placeholder and each call must use its own `rootPath`. - fastify.register(fastifyStatic, { - root: _dirname, - serve: false, - }); - - fastify.register(this.apiServerService.createServer, { prefix: '/api' }); - fastify.register(this.openApiServerService.createServer); - fastify.register(this.mastodonApiServerService.createServer, { prefix: '/api' }); - fastify.register(this.fileServerService.createServer); - fastify.register(this.activityPubServerService.createServer); - fastify.register(this.nodeinfoServerService.createServer); - fastify.register(this.wellKnownServerService.createServer); - fastify.register(this.oauth2ProviderService.createServer, { prefix: '/oauth' }); - - fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => { - const path = request.params.path; - - reply.header('Cache-Control', 'public, max-age=86400'); - - if (!path.match(/^[a-zA-Z0-9\-_@\.]+?\.webp$/)) { - reply.code(404); - return; - } - - const name = path.split('@')[0].replace(/\.webp$/i, ''); - const host = path.split('@')[1]?.replace(/\.webp$/i, ''); - - const emoji = await this.emojisRepository.findOneBy({ - // `@.` is the spec of ReactionService.decodeReaction - host: (host == null || host === '.') ? IsNull() : host, - name: name, - }); - - reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); - - if (emoji == null) { - if ('fallback' in request.query) { - return await reply.redirect('/static-assets/emoji-unknown.png'); - } else { - reply.code(404); - return; - } - } - - let url: URL; - if ('badge' in request.query) { - url = new URL(`${this.config.mediaProxy}/emoji.png`); - // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl); - url.searchParams.set('badge', '1'); - } else { - url = new URL(`${this.config.mediaProxy}/emoji.webp`); - // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl); - url.searchParams.set('emoji', '1'); - if ('static' in request.query) url.searchParams.set('static', '1'); - } - - return await reply.redirect( - 301, - url.toString(), - ); - }); - - fastify.get<{ Params: { acct: string } }>('/avatar/@:acct', async (request, reply) => { - const { username, host } = Acct.parse(request.params.acct); - const user = await this.usersRepository.findOne({ - where: { - usernameLower: username.toLowerCase(), - host: (host == null) || (host === this.config.host) ? IsNull() : host, - isSuspended: false, - }, - }); - - reply.header('Cache-Control', 'public, max-age=86400'); - - if (user) { - reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user)); - } else { - reply.redirect('/static-assets/user-unknown.png'); - } - }); - - fastify.get<{ Params: { x: string } }>('/identicon/:x', async (request, reply) => { - reply.header('Content-Type', 'image/png'); - reply.header('Cache-Control', 'public, max-age=86400'); - - if ((await this.metaService.fetch()).enableIdenticonGeneration) { - const [temp, cleanup] = await createTemp(); - await genIdenticon(request.params.x, fs.createWriteStream(temp)); - return fs.createReadStream(temp).on('close', () => cleanup()); - } else { - return reply.redirect('/static-assets/avatar.png'); - } - }); - - fastify.get<{ Params: { code: string } }>('/verify-email/:code', async (request, reply) => { - const profile = await this.userProfilesRepository.findOneBy({ - emailVerifyCode: request.params.code, - }); - - if (profile != null) { - await this.userProfilesRepository.update({ userId: profile.userId }, { - emailVerified: true, - emailVerifyCode: null, - }); - - this.globalEventService.publishMainStream(profile.userId, 'meUpdated', await this.userEntityService.pack(profile.userId, { id: profile.userId }, { - detail: true, - includeSecrets: true, - })); - - reply.code(200).send('Verification succeeded! メールアドレスの認証に成功しました。'); - return; - } else { - reply.code(404).send('Verification failed. Please try again. メールアドレスの認証に失敗しました。もう一度お試しください'); - return; - } - }); - - fastify.register(this.clientServerService.createServer); - - this.streamingApiServerService.attach(fastify.server); - - fastify.server.on('error', err => { - switch ((err as any).code) { - case 'EACCES': - this.logger.error(`You do not have permission to listen on port ${this.config.port}.`); - break; - case 'EADDRINUSE': - this.logger.error(`Port ${this.config.port} is already in use by another process.`); - break; - default: - this.logger.error(err); - break; - } - - if (cluster.isWorker) { - process.send!('listenFailed'); - } else { - // disableClustering - process.exit(1); - } - }); - - if (this.config.socket) { - if (fs.existsSync(this.config.socket)) { - fs.unlinkSync(this.config.socket); - } - fastify.listen({ path: this.config.socket }, (err, address) => { - if (this.config.chmodSocket) { - fs.chmodSync(this.config.socket!, this.config.chmodSocket); - } - }); - } else { - fastify.listen({ port: this.config.port, host: '0.0.0.0' }); - } - - await fastify.ready(); - } - - @bindThis - public async dispose(): Promise { - await this.streamingApiServerService.detach(); - await this.#fastify.close(); - } - - @bindThis - async onApplicationShutdown(signal: string): Promise { - await this.dispose(); - } -} diff --git a/packages/backend/src/server/WellKnownServerService.ts b/packages/backend/src/server/WellKnownServerService.ts deleted file mode 100644 index c3eaf53a14..0000000000 --- a/packages/backend/src/server/WellKnownServerService.ts +++ /dev/null @@ -1,179 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull } from 'typeorm'; -import vary from 'vary'; -import fastifyAccepts from '@fastify/accepts'; -import { DI } from '@/di-symbols.js'; -import type { UsersRepository } from '@/models/_.js'; -import type { Config } from '@/config.js'; -import { escapeAttribute, escapeValue } from '@/misc/prelude/xml.js'; -import type { MiUser } from '@/models/User.js'; -import * as Acct from '@/misc/acct.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { bindThis } from '@/decorators.js'; -import { NodeinfoServerService } from './NodeinfoServerService.js'; -import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; -import type { FindOptionsWhere } from 'typeorm'; -import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; - -@Injectable() -export class WellKnownServerService { - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private nodeinfoServerService: NodeinfoServerService, - private userEntityService: UserEntityService, - private oauth2ProviderService: OAuth2ProviderService, - ) { - //this.createServer = this.createServer.bind(this); - } - - @bindThis - public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { - const XRD = (...x: { element: string, value?: string, attributes?: Record }[]) => - `${x.map(({ element, value, attributes }) => - `<${ - Object.entries(typeof attributes === 'object' && attributes || {}).reduce((a, [k, v]) => `${a} ${k}="${escapeAttribute(v)}"`, element) - }${ - typeof value === 'string' ? `>${escapeValue(value)}`).reduce((a, c) => a + c, '')}`; - - const allPath = '/.well-known/*'; - const webFingerPath = '/.well-known/webfinger'; - const jrd = 'application/jrd+json'; - const xrd = 'application/xrd+xml'; - - fastify.register(fastifyAccepts); - - fastify.addHook('onRequest', (request, reply, done) => { - reply.header('Access-Control-Allow-Headers', 'Accept'); - reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); - reply.header('Access-Control-Allow-Origin', '*'); - reply.header('Access-Control-Expose-Headers', 'Vary'); - done(); - }); - - fastify.options(allPath, async (request, reply) => { - reply.code(204); - }); - - fastify.get('/.well-known/host-meta', async (request, reply) => { - reply.header('Content-Type', xrd); - return XRD({ element: 'Link', attributes: { - rel: 'lrdd', - type: xrd, - template: `${this.config.url}${webFingerPath}?resource={uri}`, - } }); - }); - - fastify.get('/.well-known/host-meta.json', async (request, reply) => { - reply.header('Content-Type', 'application/json'); - return { - links: [{ - rel: 'lrdd', - type: jrd, - template: `${this.config.url}${webFingerPath}?resource={uri}`, - }], - }; - }); - - fastify.get('/.well-known/nodeinfo', async (request, reply) => { - return { links: this.nodeinfoServerService.getLinks() }; - }); - - fastify.get('/.well-known/oauth-authorization-server', async () => { - return this.oauth2ProviderService.generateRFC8414(); - }); - - /* TODO -fastify.get('/.well-known/change-password', async (request, reply) => { -}); -*/ - - fastify.get<{ Querystring: { resource: string } }>(webFingerPath, async (request, reply) => { - const fromId = (id: MiUser['id']): FindOptionsWhere => ({ - id, - host: IsNull(), - isSuspended: false, - }); - - const generateQuery = (resource: string): FindOptionsWhere | number => - resource.startsWith(`${this.config.url.toLowerCase()}/users/`) ? - fromId(resource.split('/').pop()!) : - fromAcct(Acct.parse( - resource.startsWith(`${this.config.url.toLowerCase()}/@`) ? resource.split('/').pop()! : - resource.startsWith('acct:') ? resource.slice('acct:'.length) : - resource)); - - const fromAcct = (acct: Acct.Acct): FindOptionsWhere | number => - !acct.host || acct.host === this.config.host.toLowerCase() ? { - usernameLower: acct.username, - host: IsNull(), - isSuspended: false, - } : 422; - - if (typeof request.query.resource !== 'string') { - reply.code(400); - return; - } - - const query = generateQuery(request.query.resource.toLowerCase()); - - if (typeof query === 'number') { - reply.code(query); - return; - } - - const user = await this.usersRepository.findOneBy(query); - - if (user == null) { - reply.code(404); - return; - } - - const subject = `acct:${user.username}@${this.config.host}`; - const self = { - rel: 'self', - type: 'application/activity+json', - href: this.userEntityService.genLocalUserUri(user.id), - }; - const profilePage = { - rel: 'http://webfinger.net/rel/profile-page', - type: 'text/html', - href: `${this.config.url}/@${user.username}`, - }; - const subscribe = { - rel: 'http://ostatus.org/schema/1.0/subscribe', - template: `${this.config.url}/authorize-follow?acct={uri}`, - }; - - vary(reply.raw, 'Accept'); - reply.header('Cache-Control', 'public, max-age=180'); - - if (request.accepts().type([jrd, xrd]) === xrd) { - reply.type(xrd); - return XRD( - { element: 'Subject', value: subject }, - { element: 'Link', attributes: self }, - { element: 'Link', attributes: profilePage }, - { element: 'Link', attributes: subscribe }); - } else { - reply.type(jrd); - return { - subject, - links: [self, profilePage, subscribe], - }; - } - }); - - done(); - } -} diff --git a/packages/backend/src/server/activitypub.ts b/packages/backend/src/server/activitypub.ts new file mode 100644 index 0000000000..cd5f917c40 --- /dev/null +++ b/packages/backend/src/server/activitypub.ts @@ -0,0 +1,254 @@ +import Router from '@koa/router'; +import json from 'koa-json-body'; +import httpSignature from '@peertube/http-signature'; + +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderNote from '@/remote/activitypub/renderer/note.js'; +import renderKey from '@/remote/activitypub/renderer/key.js'; +import { renderPerson } from '@/remote/activitypub/renderer/person.js'; +import renderEmoji from '@/remote/activitypub/renderer/emoji.js'; +import Outbox, { packActivity } from './activitypub/outbox.js'; +import Followers from './activitypub/followers.js'; +import Following from './activitypub/following.js'; +import Featured from './activitypub/featured.js'; +import { inbox as processInbox } from '@/queue/index.js'; +import { isSelfHost } from '@/misc/convert-host.js'; +import { Notes, Users, Emojis, NoteReactions } from '@/models/index.js'; +import { ILocalUser, User } from '@/models/entities/user.js'; +import { In, IsNull, Not } from 'typeorm'; +import { renderLike } from '@/remote/activitypub/renderer/like.js'; +import { getUserKeypair } from '@/misc/keypair-store.js'; +import renderFollow from '@/remote/activitypub/renderer/follow.js'; + +// Init router +const router = new Router(); + +//#region Routing + +function inbox(ctx: Router.RouterContext) { + let signature; + + try { + signature = httpSignature.parseRequest(ctx.req, { 'headers': [] }); + } catch (e) { + ctx.status = 401; + return; + } + + processInbox(ctx.request.body, signature); + + ctx.status = 202; +} + +const ACTIVITY_JSON = 'application/activity+json; charset=utf-8'; +const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; + +function isActivityPubReq(ctx: Router.RouterContext) { + ctx.response.vary('Accept'); + const accepted = ctx.accepts('html', ACTIVITY_JSON, LD_JSON); + return typeof accepted === 'string' && !accepted.match(/html/); +} + +export function setResponseType(ctx: Router.RouterContext) { + const accept = ctx.accepts(ACTIVITY_JSON, LD_JSON); + if (accept === LD_JSON) { + ctx.response.type = LD_JSON; + } else { + ctx.response.type = ACTIVITY_JSON; + } +} + +// inbox +router.post('/inbox', json(), inbox); +router.post('/users/:user/inbox', json(), inbox); + +// note +router.get('/notes/:note', async (ctx, next) => { + if (!isActivityPubReq(ctx)) return await next(); + + const note = await Notes.findOneBy({ + id: ctx.params.note, + visibility: In(['public' as const, 'home' as const]), + localOnly: false, + }); + + if (note == null) { + ctx.status = 404; + return; + } + + // リモートだったらリダイレクト + if (note.userHost != null) { + if (note.uri == null || isSelfHost(note.userHost)) { + ctx.status = 500; + return; + } + ctx.redirect(note.uri); + return; + } + + ctx.body = renderActivity(await renderNote(note, false)); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); +}); + +// note activity +router.get('/notes/:note/activity', async ctx => { + const note = await Notes.findOneBy({ + id: ctx.params.note, + userHost: IsNull(), + visibility: In(['public' as const, 'home' as const]), + localOnly: false, + }); + + if (note == null) { + ctx.status = 404; + return; + } + + ctx.body = renderActivity(await packActivity(note)); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); +}); + +// outbox +router.get('/users/:user/outbox', Outbox); + +// followers +router.get('/users/:user/followers', Followers); + +// following +router.get('/users/:user/following', Following); + +// featured +router.get('/users/:user/collections/featured', Featured); + +// publickey +router.get('/users/:user/publickey', async ctx => { + const userId = ctx.params.user; + + const user = await Users.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + ctx.status = 404; + return; + } + + const keypair = await getUserKeypair(user.id); + + if (Users.isLocalUser(user)) { + ctx.body = renderActivity(renderKey(user, keypair)); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); + } else { + ctx.status = 400; + } +}); + +// user +async function userInfo(ctx: Router.RouterContext, user: User | null) { + if (user == null) { + ctx.status = 404; + return; + } + + ctx.body = renderActivity(await renderPerson(user as ILocalUser)); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); +} + +router.get('/users/:user', async (ctx, next) => { + if (!isActivityPubReq(ctx)) return await next(); + + const userId = ctx.params.user; + + const user = await Users.findOneBy({ + id: userId, + host: IsNull(), + isSuspended: false, + }); + + await userInfo(ctx, user); +}); + +router.get('/@:user', async (ctx, next) => { + if (!isActivityPubReq(ctx)) return await next(); + + const user = await Users.findOneBy({ + usernameLower: ctx.params.user.toLowerCase(), + host: IsNull(), + isSuspended: false, + }); + + await userInfo(ctx, user); +}); +//#endregion + +// emoji +router.get('/emojis/:emoji', async ctx => { + const emoji = await Emojis.findOneBy({ + host: IsNull(), + name: ctx.params.emoji, + }); + + if (emoji == null) { + ctx.status = 404; + return; + } + + ctx.body = renderActivity(await renderEmoji(emoji)); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); +}); + +// like +router.get('/likes/:like', async ctx => { + const reaction = await NoteReactions.findOneBy({ id: ctx.params.like }); + + if (reaction == null) { + ctx.status = 404; + return; + } + + const note = await Notes.findOneBy({ id: reaction.noteId }); + + if (note == null) { + ctx.status = 404; + return; + } + + ctx.body = renderActivity(await renderLike(reaction, note)); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); +}); + +// follow +router.get('/follows/:follower/:followee', async ctx => { + // This may be used before the follow is completed, so we do not + // check if the following exists. + + const [follower, followee] = await Promise.all([ + Users.findOneBy({ + id: ctx.params.follower, + host: IsNull(), + }), + Users.findOneBy({ + id: ctx.params.followee, + host: Not(IsNull()), + }), + ]); + + if (follower == null || followee == null) { + ctx.status = 404; + return; + } + + ctx.body = renderActivity(renderFollow(follower, followee)); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); +}); + +export default router; diff --git a/packages/backend/src/server/activitypub/featured.ts b/packages/backend/src/server/activitypub/featured.ts new file mode 100644 index 0000000000..c03fd1049f --- /dev/null +++ b/packages/backend/src/server/activitypub/featured.ts @@ -0,0 +1,41 @@ +import Router from '@koa/router'; +import config from '@/config/index.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection.js'; +import { setResponseType } from '../activitypub.js'; +import renderNote from '@/remote/activitypub/renderer/note.js'; +import { Users, Notes, UserNotePinings } from '@/models/index.js'; +import { IsNull } from 'typeorm'; + +export default async (ctx: Router.RouterContext) => { + const userId = ctx.params.user; + + const user = await Users.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + ctx.status = 404; + return; + } + + const pinings = await UserNotePinings.find({ + where: { userId: user.id }, + order: { id: 'DESC' }, + }); + + const pinnedNotes = await Promise.all(pinings.map(pining => + Notes.findOneByOrFail({ id: pining.noteId }))); + + const renderedNotes = await Promise.all(pinnedNotes.map(note => renderNote(note))); + + const rendered = renderOrderedCollection( + `${config.url}/users/${userId}/collections/featured`, + renderedNotes.length, undefined, undefined, renderedNotes, + ); + + ctx.body = renderActivity(rendered); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); +}; diff --git a/packages/backend/src/server/activitypub/followers.ts b/packages/backend/src/server/activitypub/followers.ts new file mode 100644 index 0000000000..beb48713a6 --- /dev/null +++ b/packages/backend/src/server/activitypub/followers.ts @@ -0,0 +1,95 @@ +import Router from '@koa/router'; +import { FindOptionsWhere, IsNull, LessThan } from 'typeorm'; +import config from '@/config/index.js'; +import * as url from '@/prelude/url.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection.js'; +import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-collection-page.js'; +import renderFollowUser from '@/remote/activitypub/renderer/follow-user.js'; +import { Users, Followings, UserProfiles } from '@/models/index.js'; +import { Following } from '@/models/entities/following.js'; +import { setResponseType } from '../activitypub.js'; + +export default async (ctx: Router.RouterContext) => { + const userId = ctx.params.user; + + const cursor = ctx.request.query.cursor; + if (cursor != null && typeof cursor !== 'string') { + ctx.status = 400; + return; + } + + const page = ctx.request.query.page === 'true'; + + const user = await Users.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + ctx.status = 404; + return; + } + + //#region Check ff visibility + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + if (profile.ffVisibility === 'private') { + ctx.status = 403; + ctx.set('Cache-Control', 'public, max-age=30'); + return; + } else if (profile.ffVisibility === 'followers') { + ctx.status = 403; + ctx.set('Cache-Control', 'public, max-age=30'); + return; + } + //#endregion + + const limit = 10; + const partOf = `${config.url}/users/${userId}/followers`; + + if (page) { + const query = { + followeeId: user.id, + } as FindOptionsWhere; + + // カーソルが指定されている場合 + if (cursor) { + query.id = LessThan(cursor); + } + + // Get followers + const followings = await Followings.find({ + where: query, + take: limit + 1, + order: { id: -1 }, + }); + + // 「次のページ」があるかどうか + const inStock = followings.length === limit + 1; + if (inStock) followings.pop(); + + const renderedFollowers = await Promise.all(followings.map(following => renderFollowUser(following.followerId))); + const rendered = renderOrderedCollectionPage( + `${partOf}?${url.query({ + page: 'true', + cursor, + })}`, + user.followersCount, renderedFollowers, partOf, + undefined, + inStock ? `${partOf}?${url.query({ + page: 'true', + cursor: followings[followings.length - 1].id, + })}` : undefined, + ); + + ctx.body = renderActivity(rendered); + setResponseType(ctx); + } else { + // index page + const rendered = renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`); + ctx.body = renderActivity(rendered); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); + } +}; diff --git a/packages/backend/src/server/activitypub/following.ts b/packages/backend/src/server/activitypub/following.ts new file mode 100644 index 0000000000..3a25a6316c --- /dev/null +++ b/packages/backend/src/server/activitypub/following.ts @@ -0,0 +1,95 @@ +import Router from '@koa/router'; +import { LessThan, IsNull, FindOptionsWhere } from 'typeorm'; +import config from '@/config/index.js'; +import * as url from '@/prelude/url.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection.js'; +import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-collection-page.js'; +import renderFollowUser from '@/remote/activitypub/renderer/follow-user.js'; +import { Users, Followings, UserProfiles } from '@/models/index.js'; +import { Following } from '@/models/entities/following.js'; +import { setResponseType } from '../activitypub.js'; + +export default async (ctx: Router.RouterContext) => { + const userId = ctx.params.user; + + const cursor = ctx.request.query.cursor; + if (cursor != null && typeof cursor !== 'string') { + ctx.status = 400; + return; + } + + const page = ctx.request.query.page === 'true'; + + const user = await Users.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + ctx.status = 404; + return; + } + + //#region Check ff visibility + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + if (profile.ffVisibility === 'private') { + ctx.status = 403; + ctx.set('Cache-Control', 'public, max-age=30'); + return; + } else if (profile.ffVisibility === 'followers') { + ctx.status = 403; + ctx.set('Cache-Control', 'public, max-age=30'); + return; + } + //#endregion + + const limit = 10; + const partOf = `${config.url}/users/${userId}/following`; + + if (page) { + const query = { + followerId: user.id, + } as FindOptionsWhere; + + // カーソルが指定されている場合 + if (cursor) { + query.id = LessThan(cursor); + } + + // Get followings + const followings = await Followings.find({ + where: query, + take: limit + 1, + order: { id: -1 }, + }); + + // 「次のページ」があるかどうか + const inStock = followings.length === limit + 1; + if (inStock) followings.pop(); + + const renderedFollowees = await Promise.all(followings.map(following => renderFollowUser(following.followeeId))); + const rendered = renderOrderedCollectionPage( + `${partOf}?${url.query({ + page: 'true', + cursor, + })}`, + user.followingCount, renderedFollowees, partOf, + undefined, + inStock ? `${partOf}?${url.query({ + page: 'true', + cursor: followings[followings.length - 1].id, + })}` : undefined, + ); + + ctx.body = renderActivity(rendered); + setResponseType(ctx); + } else { + // index page + const rendered = renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`); + ctx.body = renderActivity(rendered); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); + } +}; diff --git a/packages/backend/src/server/activitypub/outbox.ts b/packages/backend/src/server/activitypub/outbox.ts new file mode 100644 index 0000000000..7a2586998a --- /dev/null +++ b/packages/backend/src/server/activitypub/outbox.ts @@ -0,0 +1,108 @@ +import Router from '@koa/router'; +import { Brackets, IsNull } from 'typeorm'; +import config from '@/config/index.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection.js'; +import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-collection-page.js'; +import renderNote from '@/remote/activitypub/renderer/note.js'; +import renderCreate from '@/remote/activitypub/renderer/create.js'; +import renderAnnounce from '@/remote/activitypub/renderer/announce.js'; +import { countIf } from '@/prelude/array.js'; +import * as url from '@/prelude/url.js'; +import { Users, Notes } from '@/models/index.js'; +import { Note } from '@/models/entities/note.js'; +import { makePaginationQuery } from '../api/common/make-pagination-query.js'; +import { setResponseType } from '../activitypub.js'; + +export default async (ctx: Router.RouterContext) => { + const userId = ctx.params.user; + + const sinceId = ctx.request.query.since_id; + if (sinceId != null && typeof sinceId !== 'string') { + ctx.status = 400; + return; + } + + const untilId = ctx.request.query.until_id; + if (untilId != null && typeof untilId !== 'string') { + ctx.status = 400; + return; + } + + const page = ctx.request.query.page === 'true'; + + if (countIf(x => x != null, [sinceId, untilId]) > 1) { + ctx.status = 400; + return; + } + + const user = await Users.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + ctx.status = 404; + return; + } + + const limit = 20; + const partOf = `${config.url}/users/${userId}/outbox`; + + if (page) { + const query = makePaginationQuery(Notes.createQueryBuilder('note'), sinceId, untilId) + .andWhere('note.userId = :userId', { userId: user.id }) + .andWhere(new Brackets(qb => { qb + .where('note.visibility = \'public\'') + .orWhere('note.visibility = \'home\''); + })) + .andWhere('note.localOnly = FALSE'); + + const notes = await query.take(limit).getMany(); + + if (sinceId) notes.reverse(); + + const activities = await Promise.all(notes.map(note => packActivity(note))); + const rendered = renderOrderedCollectionPage( + `${partOf}?${url.query({ + page: 'true', + since_id: sinceId, + until_id: untilId, + })}`, + user.notesCount, activities, partOf, + notes.length ? `${partOf}?${url.query({ + page: 'true', + since_id: notes[0].id, + })}` : undefined, + notes.length ? `${partOf}?${url.query({ + page: 'true', + until_id: notes[notes.length - 1].id, + })}` : undefined, + ); + + ctx.body = renderActivity(rendered); + setResponseType(ctx); + } else { + // index page + const rendered = renderOrderedCollection(partOf, user.notesCount, + `${partOf}?page=true`, + `${partOf}?page=true&since_id=000000000000000000000000`, + ); + ctx.body = renderActivity(rendered); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); + } +}; + +/** + * Pack Create or Announce Activity + * @param note Note + */ +export async function packActivity(note: Note): Promise { + if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { + const renote = await Notes.findOneByOrFail({ id: note.renoteId }); + return renderAnnounce(renote.uri ? renote.uri : `${config.url}/notes/${renote.id}`, note); + } + + return renderCreate(await renderNote(note, false), note); +} diff --git a/packages/backend/src/server/api/2fa.ts b/packages/backend/src/server/api/2fa.ts new file mode 100644 index 0000000000..96b9316e47 --- /dev/null +++ b/packages/backend/src/server/api/2fa.ts @@ -0,0 +1,422 @@ +import * as crypto from 'node:crypto'; +import * as jsrsasign from 'jsrsasign'; +import config from '@/config/index.js'; + +const ECC_PRELUDE = Buffer.from([0x04]); +const NULL_BYTE = Buffer.from([0]); +const PEM_PRELUDE = Buffer.from( + '3059301306072a8648ce3d020106082a8648ce3d030107034200', + 'hex', +); + +// Android Safetynet attestations are signed with this cert: +const GSR2 = `-----BEGIN CERTIFICATE----- +MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G +A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp +Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1 +MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG +A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL +v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8 +eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq +tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd +C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa +zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB +mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH +V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n +bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG +3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs +J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO +291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS +ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd +AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7 +TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg== +-----END CERTIFICATE-----\n`; + +function base64URLDecode(source: string) { + return Buffer.from(source.replace(/\-/g, '+').replace(/_/g, '/'), 'base64'); +} + +function getCertSubject(certificate: string) { + const subjectCert = new jsrsasign.X509(); + subjectCert.readCertPEM(certificate); + + const subjectString = subjectCert.getSubjectString(); + const subjectFields = subjectString.slice(1).split('/'); + + const fields = {} as Record; + for (const field of subjectFields) { + const eqIndex = field.indexOf('='); + fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1); + } + + return fields; +} + +function verifyCertificateChain(certificates: string[]) { + let valid = true; + + for (let i = 0; i < certificates.length; i++) { + const Cert = certificates[i]; + const certificate = new jsrsasign.X509(); + certificate.readCertPEM(Cert); + + const CACert = i + 1 >= certificates.length ? Cert : certificates[i + 1]; + + const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex!, 0, [0]); + const algorithm = certificate.getSignatureAlgorithmField(); + const signatureHex = certificate.getSignatureValueHex(); + + // Verify against CA + const Signature = new jsrsasign.KJUR.crypto.Signature({ alg: algorithm }); + Signature.init(CACert); + Signature.updateHex(certStruct); + valid = valid && !!Signature.verify(signatureHex); // true if CA signed the certificate + } + + return valid; +} + +function PEMString(pemBuffer: Buffer, type = 'CERTIFICATE') { + if (pemBuffer.length === 65 && pemBuffer[0] === 0x04) { + pemBuffer = Buffer.concat([PEM_PRELUDE, pemBuffer], 91); + type = 'PUBLIC KEY'; + } + const cert = pemBuffer.toString('base64'); + + const keyParts = []; + const max = Math.ceil(cert.length / 64); + let start = 0; + for (let i = 0; i < max; i++) { + keyParts.push(cert.substring(start, start + 64)); + start += 64; + } + + return ( + `-----BEGIN ${type}-----\n` + + keyParts.join('\n') + + `\n-----END ${type}-----\n` + ); +} + +export function hash(data: Buffer) { + return crypto + .createHash('sha256') + .update(data) + .digest(); +} + +export function verifyLogin({ + publicKey, + authenticatorData, + clientDataJSON, + clientData, + signature, + challenge, +}: { + publicKey: Buffer, + authenticatorData: Buffer, + clientDataJSON: Buffer, + clientData: any, + signature: Buffer, + challenge: string +}) { + if (clientData.type !== 'webauthn.get') { + throw new Error('type is not webauthn.get'); + } + + if (hash(clientData.challenge).toString('hex') !== challenge) { + throw new Error('challenge mismatch'); + } + if (clientData.origin !== config.scheme + '://' + config.host) { + throw new Error('origin mismatch'); + } + + const verificationData = Buffer.concat( + [authenticatorData, hash(clientDataJSON)], + 32 + authenticatorData.length, + ); + + return crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(publicKey), signature); +} + +export const procedures = { + none: { + verify({ publicKey }: { publicKey: Map }) { + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyU2F = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + + return { + publicKey: publicKeyU2F, + valid: true, + }; + }, + }, + 'android-key': { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId, + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map; + rpIdHash: Buffer, + credentialId: Buffer, + }) { + if (attStmt.alg !== -7) { + throw new Error('alg mismatch'); + } + + const verificationData = Buffer.concat([ + authenticatorData, + clientDataHash, + ]); + + const attCert: Buffer = attStmt.x5c[0]; + + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyData = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + + if (!attCert.equals(publicKeyData)) { + throw new Error('public key mismatch'); + } + + const isValid = crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(attCert), attStmt.sig); + + // TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON) + + return { + valid: isValid, + publicKey: publicKeyData, + }; + }, + }, + // what a stupid attestation + 'android-safetynet': { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId, + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map; + rpIdHash: Buffer, + credentialId: Buffer, + }) { + const verificationData = hash( + Buffer.concat([authenticatorData, clientDataHash]), + ); + + const jwsParts = attStmt.response.toString('utf-8').split('.'); + + const header = JSON.parse(base64URLDecode(jwsParts[0]).toString('utf-8')); + const response = JSON.parse( + base64URLDecode(jwsParts[1]).toString('utf-8'), + ); + const signature = jwsParts[2]; + + if (!verificationData.equals(Buffer.from(response.nonce, 'base64'))) { + throw new Error('invalid nonce'); + } + + const certificateChain = header.x5c + .map((key: any) => PEMString(key)) + .concat([GSR2]); + + if (getCertSubject(certificateChain[0]).CN !== 'attest.android.com') { + throw new Error('invalid common name'); + } + + if (!verifyCertificateChain(certificateChain)) { + throw new Error('Invalid certificate chain!'); + } + + const signatureBase = Buffer.from( + jwsParts[0] + '.' + jwsParts[1], + 'utf-8', + ); + + const valid = crypto + .createVerify('sha256') + .update(signatureBase) + .verify(certificateChain[0], base64URLDecode(signature)); + + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyData = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + return { + valid, + publicKey: publicKeyData, + }; + }, + }, + packed: { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId, + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map; + rpIdHash: Buffer, + credentialId: Buffer, + }) { + const verificationData = Buffer.concat([ + authenticatorData, + clientDataHash, + ]); + + if (attStmt.x5c) { + const attCert = attStmt.x5c[0]; + + const validSignature = crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(attCert), attStmt.sig); + + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyData = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + + return { + valid: validSignature, + publicKey: publicKeyData, + }; + } else if (attStmt.ecdaaKeyId) { + // https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-ecdaa-algorithm-v2.0-id-20180227.html#ecdaa-verify-operation + throw new Error('ECDAA-Verify is not supported'); + } else { + if (attStmt.alg !== -7) throw new Error('alg mismatch'); + + throw new Error('self attestation is not supported'); + } + }, + }, + + 'fido-u2f': { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId, + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map, + rpIdHash: Buffer, + credentialId: Buffer + }) { + const x5c: Buffer[] = attStmt.x5c; + if (x5c.length !== 1) { + throw new Error('x5c length does not match expectation'); + } + + const attCert = x5c[0]; + + // TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve + + const negTwo: Buffer = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree: Buffer = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyU2F = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + + const verificationData = Buffer.concat([ + NULL_BYTE, + rpIdHash, + clientDataHash, + credentialId, + publicKeyU2F, + ]); + + const validSignature = crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(attCert), attStmt.sig); + + return { + valid: validSignature, + publicKey: publicKeyU2F, + }; + }, + }, +}; diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts deleted file mode 100644 index 9247879dc0..0000000000 --- a/packages/backend/src/server/api/ApiCallService.ts +++ /dev/null @@ -1,407 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { randomUUID } from 'node:crypto'; -import * as fs from 'node:fs'; -import * as stream from 'node:stream/promises'; -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import { getIpHash } from '@/misc/get-ip-hash.js'; -import type { MiLocalUser, MiUser } from '@/models/User.js'; -import type { MiAccessToken } from '@/models/AccessToken.js'; -import type Logger from '@/logger.js'; -import type { UserIpsRepository } from '@/models/_.js'; -import { MetaService } from '@/core/MetaService.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { bindThis } from '@/decorators.js'; -import { RoleService } from '@/core/RoleService.js'; -import { ApiError } from './error.js'; -import { RateLimiterService } from './RateLimiterService.js'; -import { ApiLoggerService } from './ApiLoggerService.js'; -import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; -import type { FastifyRequest, FastifyReply } from 'fastify'; -import type { OnApplicationShutdown } from '@nestjs/common'; -import type { IEndpointMeta, IEndpoint } from './endpoints.js'; -import type { Config } from '@/config.js'; - -const accessDenied = { - message: 'Access denied.', - code: 'ACCESS_DENIED', - id: '56f35758-7dd5-468b-8439-5d6fb8ec9b8e', -}; - -@Injectable() -export class ApiCallService implements OnApplicationShutdown { - private logger: Logger; - private userIpHistories: Map>; - private userIpHistoriesClearIntervalId: NodeJS.Timeout; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.userIpsRepository) - private userIpsRepository: UserIpsRepository, - - private metaService: MetaService, - private authenticateService: AuthenticateService, - private rateLimiterService: RateLimiterService, - private roleService: RoleService, - private apiLoggerService: ApiLoggerService, - ) { - this.logger = this.apiLoggerService.logger; - this.userIpHistories = new Map>(); - - this.userIpHistoriesClearIntervalId = setInterval(() => { - this.userIpHistories.clear(); - }, 1000 * 60 * 60); - } - - #sendApiError(reply: FastifyReply, err: ApiError): void { - 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 (!statusCode) { - statusCode = 500; - } - this.send(reply, statusCode, err); - } - - #sendAuthenticationError(reply: FastifyReply, err: unknown): void { - if (err instanceof AuthenticationError) { - const message = 'Authentication failed. Please ensure your token is correct.'; - reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_token", error_description="${message}"`); - this.send(reply, 401, new ApiError({ - message: 'Authentication failed. Please ensure your token is correct.', - code: 'AUTHENTICATION_FAILED', - id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', - })); - } else { - this.send(reply, 500, new ApiError()); - } - } - - @bindThis - public handleRequest( - endpoint: IEndpoint & { exec: any }, - request: FastifyRequest<{ Body: Record | undefined, Querystring: Record }>, - reply: FastifyReply, - ): void { - const body = request.method === 'GET' - ? request.query - : request.body; - - // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive) - const token = request.headers.authorization?.startsWith('Bearer ') - ? request.headers.authorization.slice(7) - : body?.['i']; - if (token != null && typeof token !== 'string') { - reply.code(400); - return; - } - this.authenticateService.authenticate(token).then(([user, app]) => { - this.call(endpoint, user, app, body, null, request).then((res) => { - if (request.method === 'GET' && endpoint.meta.cacheSec && !token && !user) { - reply.header('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`); - } - this.send(reply, res); - }).catch((err: ApiError) => { - this.#sendApiError(reply, err); - }); - - if (user) { - this.logIp(request, user); - } - }).catch(err => { - this.#sendAuthenticationError(reply, err); - }); - } - - @bindThis - public async handleMultipartRequest( - endpoint: IEndpoint & { exec: any }, - request: FastifyRequest<{ Body: Record, Querystring: Record }>, - reply: FastifyReply, - ): Promise { - const multipartData = await request.file().catch(() => { - /* Fastify throws if the remote didn't send multipart data. Return 400 below. */ - }); - if (multipartData == null) { - reply.code(400); - reply.send(); - return; - } - - const [path] = await createTemp(); - await stream.pipeline(multipartData.file, fs.createWriteStream(path)); - - const fields = {} as Record; - for (const [k, v] of Object.entries(multipartData.fields)) { - fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined; - } - - // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive) - const token = request.headers.authorization?.startsWith('Bearer ') - ? request.headers.authorization.slice(7) - : fields['i']; - if (token != null && typeof token !== 'string') { - reply.code(400); - return; - } - this.authenticateService.authenticate(token).then(([user, app]) => { - this.call(endpoint, user, app, fields, { - name: multipartData.filename, - path: path, - }, request).then((res) => { - this.send(reply, res); - }).catch((err: ApiError) => { - this.#sendApiError(reply, err); - }); - - if (user) { - this.logIp(request, user); - } - }).catch(err => { - this.#sendAuthenticationError(reply, err); - }); - } - - @bindThis - private send(reply: FastifyReply, x?: any, y?: ApiError) { - if (x == null) { - reply.code(204); - reply.send(); - } else if (typeof x === 'number' && y) { - reply.code(x); - reply.send({ - error: { - message: y!.message, - code: y!.code, - id: y!.id, - kind: y!.kind, - ...(y!.info ? { info: y!.info } : {}), - }, - }); - } else { - // 文字列を返す場合は、JSON.stringify通さないとJSONと認識されない - reply.send(typeof x === 'string' ? JSON.stringify(x) : x); - } - } - - @bindThis - private async logIp(request: FastifyRequest, user: MiLocalUser) { - const meta = await this.metaService.fetch(); - if (!meta.enableIpLogging) return; - const ip = request.ip; - const ips = this.userIpHistories.get(user.id); - if (ips == null || !ips.has(ip)) { - if (ips == null) { - this.userIpHistories.set(user.id, new Set([ip])); - } else { - ips.add(ip); - } - - try { - this.userIpsRepository.createQueryBuilder().insert().values({ - createdAt: new Date(), - userId: user.id, - ip: ip, - }).orIgnore(true).execute(); - } catch { - } - } - } - - @bindThis - private async call( - ep: IEndpoint & { exec: any }, - user: MiLocalUser | null | undefined, - token: MiAccessToken | null | undefined, - data: any, - file: { - name: string; - path: string; - } | null, - request: FastifyRequest<{ Body: Record | undefined, Querystring: Record }>, - ) { - const isSecure = user != null && token == null; - - if (ep.meta.secure && !isSecure) { - throw new ApiError(accessDenied); - } - - if (ep.meta.limit) { - // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. - let limitActor: string; - if (user) { - limitActor = user.id; - } else { - limitActor = getIpHash(request.ip); - } - - const limit = Object.assign({}, ep.meta.limit); - - if (limit.key == null) { - (limit as any).key = ep.name; - } - - // TODO: 毎リクエスト計算するのもあれだしキャッシュしたい - const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1; - - if (factor > 0) { - // Rate limit - await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable }, limitActor, factor).catch(err => { - throw new ApiError({ - message: 'Rate limit exceeded. Please try again later.', - code: 'RATE_LIMIT_EXCEEDED', - id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', - httpStatusCode: 429, - }); - }); - } - } - - const requireCredential = (ep.meta.requireCredential === 'always' || - (ep.meta.requireCredential === 'conditional' && this.config.secureApiMode)); - if (requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin) { - if (user == null) { - throw new ApiError({ - message: 'Credential required.', - code: 'CREDENTIAL_REQUIRED', - id: '1384574d-a912-4b81-8601-c7b1c4085df1', - httpStatusCode: 401, - }); - } else if (user!.isSuspended) { - throw new ApiError({ - message: 'Your account has been suspended.', - code: 'YOUR_ACCOUNT_SUSPENDED', - kind: 'permission', - id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370', - }); - } - } - - if (ep.meta.prohibitMoved) { - if (user?.movedToUri) { - throw new ApiError({ - message: 'You have moved your account.', - code: 'YOUR_ACCOUNT_MOVED', - kind: 'permission', - id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31', - }); - } - } - - if ((ep.meta.requireModerator || ep.meta.requireAdmin) && !user!.isRoot) { - const myRoles = await this.roleService.getUserRoles(user!.id); - if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) { - throw new ApiError({ - message: 'You are not assigned to a moderator role.', - code: 'ROLE_PERMISSION_DENIED', - kind: 'permission', - id: 'd33d5333-db36-423d-a8f9-1a2b9549da41', - }); - } - if (ep.meta.requireAdmin && !myRoles.some(r => r.isAdministrator)) { - throw new ApiError({ - message: 'You are not assigned to an administrator role.', - code: 'ROLE_PERMISSION_DENIED', - kind: 'permission', - id: 'c3d38592-54c0-429d-be96-5636b0431a61', - }); - } - } - - if (ep.meta.requireRolePolicy != null && !user!.isRoot) { - const myRoles = await this.roleService.getUserRoles(user!.id); - const policies = await this.roleService.getUserPolicies(user!.id); - if (!policies[ep.meta.requireRolePolicy] && !myRoles.some(r => r.isAdministrator)) { - throw new ApiError({ - message: 'You are not assigned to a required role.', - code: 'ROLE_PERMISSION_DENIED', - kind: 'permission', - id: '7f86f06f-7e15-4057-8561-f4b6d4ac755a', - }); - } - } - - if (token && ((ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) - || (!ep.meta.kind && (requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin)))) { - throw new ApiError({ - message: 'Your app does not have the necessary permissions to use this endpoint.', - code: 'PERMISSION_DENIED', - kind: 'permission', - id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', - }); - } - - // Cast non JSON input - if ((ep.meta.requireFile || request.method === 'GET') && ep.params.properties) { - for (const k of Object.keys(ep.params.properties)) { - const param = ep.params.properties![k]; - if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { - try { - data[k] = JSON.parse(data[k]); - } catch (e) { - throw new ApiError({ - message: 'Invalid param.', - code: 'INVALID_PARAM', - id: '0b5f1631-7c1a-41a6-b399-cce335f34d85', - }, { - param: k, - reason: `cannot cast to ${param.type}`, - }); - } - } - } - } - - // API invoking - return await ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => { - if (err instanceof ApiError || err instanceof AuthenticationError) { - throw err; - } else { - const errId = randomUUID(); - this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, { - ep: ep.name, - ps: data, - e: { - message: err.message, - code: err.name, - stack: err.stack, - id: errId, - }, - }); - console.error(err, errId); - throw new ApiError(null, { - e: { - message: err.message, - code: err.name, - id: errId, - }, - }); - } - }); - } - - @bindThis - public dispose(): void { - clearInterval(this.userIpHistoriesClearIntervalId); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/server/api/ApiLoggerService.ts b/packages/backend/src/server/api/ApiLoggerService.ts deleted file mode 100644 index 2339366a5d..0000000000 --- a/packages/backend/src/server/api/ApiLoggerService.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import type Logger from '@/logger.js'; -import { LoggerService } from '@/core/LoggerService.js'; - -@Injectable() -export class ApiLoggerService { - public logger: Logger; - - constructor( - private loggerService: LoggerService, - ) { - this.logger = this.loggerService.getLogger('api'); - } -} diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts deleted file mode 100644 index 1758c03aca..0000000000 --- a/packages/backend/src/server/api/ApiServerService.ts +++ /dev/null @@ -1,187 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import cors from '@fastify/cors'; -import multipart from '@fastify/multipart'; -import fastifyCookie from '@fastify/cookie'; -import { ModuleRef } from '@nestjs/core'; -import type { Config } from '@/config.js'; -import type { InstancesRepository, AccessTokensRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { bindThis } from '@/decorators.js'; -import endpoints from './endpoints.js'; -import { ApiCallService } from './ApiCallService.js'; -import { SignupApiService } from './SignupApiService.js'; -import { SigninApiService } from './SigninApiService.js'; -import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; - -@Injectable() -export class ApiServerService { - constructor( - private moduleRef: ModuleRef, - - @Inject(DI.config) - private config: Config, - - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, - - @Inject(DI.accessTokensRepository) - private accessTokensRepository: AccessTokensRepository, - - private userEntityService: UserEntityService, - private apiCallService: ApiCallService, - private signupApiService: SignupApiService, - private signinApiService: SigninApiService, - ) { - //this.createServer = this.createServer.bind(this); - } - - @bindThis - public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { - fastify.register(cors, { - origin: '*', - }); - - fastify.register(multipart, { - limits: { - fileSize: this.config.maxFileSize ?? 262144000, - files: 1, - }, - }); - - fastify.register(fastifyCookie, {}); - - // Prevent cache - fastify.addHook('onRequest', (request, reply, done) => { - reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); - done(); - }); - - for (const endpoint of endpoints) { - const ep = { - name: endpoint.name, - meta: endpoint.meta, - params: endpoint.params, - exec: this.moduleRef.get('ep:' + endpoint.name, { strict: false }).exec, - }; - - if (endpoint.meta.requireFile) { - fastify.all<{ - Params: { endpoint: string; }, - Body: Record, - Querystring: Record, - }>('/' + endpoint.name, async (request, reply) => { - if (request.method === 'GET' && !endpoint.meta.allowGet) { - reply.code(405); - reply.send(); - return; - } - - // Await so that any error can automatically be translated to HTTP 500 - await this.apiCallService.handleMultipartRequest(ep, request, reply); - return reply; - }); - } else { - fastify.all<{ - Params: { endpoint: string; }, - Body: Record, - Querystring: Record, - }>('/' + endpoint.name, { bodyLimit: 1024 * 1024 }, async (request, reply) => { - if (request.method === 'GET' && !endpoint.meta.allowGet) { - reply.code(405); - reply.send(); - return; - } - - // Await so that any error can automatically be translated to HTTP 500 - await this.apiCallService.handleRequest(ep, request, reply); - return reply; - }); - } - } - - fastify.post<{ - Body: { - username: string; - password: string; - host?: string; - invitationCode?: string; - emailAddress?: string; - 'hcaptcha-response'?: string; - 'g-recaptcha-response'?: string; - 'turnstile-response'?: string; - } - }>('/signup', (request, reply) => this.signupApiService.signup(request, reply)); - - fastify.post<{ - Body: { - username: string; - password: string; - token?: string; - signature?: string; - authenticatorData?: string; - clientDataJSON?: string; - credentialId?: string; - challengeId?: string; - }; - }>('/signin', (request, reply) => this.signinApiService.signin(request, reply)); - - fastify.post<{ Body: { code: string; } }>('/signup-pending', (request, reply) => this.signupApiService.signupPending(request, reply)); - - fastify.get('/v1/instance/peers', async (request, reply) => { - const instances = await this.instancesRepository.find({ - select: ['host'], - where: { - isSuspended: false, - }, - }); - - return instances.map(instance => instance.host); - }); - - fastify.post<{ Params: { session: string; } }>('/miauth/:session/check', async (request, reply) => { - const token = await this.accessTokensRepository.findOneBy({ - session: request.params.session, - }); - - if (token && token.session != null && !token.fetched) { - this.accessTokensRepository.update(token.id, { - fetched: true, - }); - - return { - ok: true, - token: token.token, - user: await this.userEntityService.pack(token.userId, null, { detail: true }), - }; - } else { - return { - ok: false, - }; - } - }); - - // Make sure any unknown path under /api returns HTTP 404 Not Found, - // because otherwise ClientServerService will return the base client HTML - // page with HTTP 200. - fastify.get('/*', (request, reply) => { - reply.code(404); - // Mock ApiCallService.send's error handling - reply.send({ - error: { - message: 'Unknown API endpoint.', - code: 'UNKNOWN_API_ENDPOINT', - id: '2ca3b769-540a-4f08-9dd5-b5a825b6d0f1', - kind: 'client', - }, - }); - }); - - done(); - } -} diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts deleted file mode 100644 index f075688194..0000000000 --- a/packages/backend/src/server/api/AuthenticateService.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { AccessTokensRepository, AppsRepository, UsersRepository } from '@/models/_.js'; -import type { MiLocalUser } from '@/models/User.js'; -import type { MiAccessToken } from '@/models/AccessToken.js'; -import { MemoryKVCache } from '@/misc/cache.js'; -import type { MiApp } from '@/models/App.js'; -import { CacheService } from '@/core/CacheService.js'; -import isNativeToken from '@/misc/is-native-token.js'; -import { bindThis } from '@/decorators.js'; - -export class AuthenticationError extends Error { - constructor(message: string) { - super(message); - this.name = 'AuthenticationError'; - } -} - -@Injectable() -export class AuthenticateService implements OnApplicationShutdown { - private appCache: MemoryKVCache; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.accessTokensRepository) - private accessTokensRepository: AccessTokensRepository, - - @Inject(DI.appsRepository) - private appsRepository: AppsRepository, - - private cacheService: CacheService, - ) { - this.appCache = new MemoryKVCache(Infinity); - } - - @bindThis - public async authenticate(token: string | null | undefined): Promise<[MiLocalUser | null, MiAccessToken | null]> { - if (token == null) { - return [null, null]; - } - - if (isNativeToken(token)) { - const user = await this.cacheService.localUserByNativeTokenCache.fetch(token, - () => this.usersRepository.findOneBy({ token }) as Promise); - - if (user == null) { - throw new AuthenticationError('user not found'); - } - - return [user, null]; - } else { - const accessToken = await this.accessTokensRepository.findOne({ - where: [{ - hash: token.toLowerCase(), // app - }, { - token: token, // miauth - }], - }); - - if (accessToken == null) { - throw new AuthenticationError('invalid signature'); - } - - this.accessTokensRepository.update(accessToken.id, { - lastUsedAt: new Date(), - }); - - const user = await this.cacheService.localUserByIdCache.fetch(accessToken.userId, - () => this.usersRepository.findOneBy({ - id: accessToken.userId, - }) as Promise); - - if (accessToken.appId) { - const app = await this.appCache.fetch(accessToken.appId, - () => this.appsRepository.findOneByOrFail({ id: accessToken.appId! })); - - return [user, { - id: accessToken.id, - permission: app.permission, - } as MiAccessToken]; - } else { - return [user, accessToken]; - } - } - } - - @bindThis - public dispose(): void { - this.appCache.dispose(); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts deleted file mode 100644 index ed1b2d4377..0000000000 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ /dev/null @@ -1,1502 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Module } from '@nestjs/common'; - -import { CoreModule } from '@/core/CoreModule.js'; -import * as ep___admin_meta from './endpoints/admin/meta.js'; -import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.js'; -import * as ep___admin_accounts_create from './endpoints/admin/accounts/create.js'; -import * as ep___admin_accounts_delete from './endpoints/admin/accounts/delete.js'; -import * as ep___admin_accounts_findByEmail from './endpoints/admin/accounts/find-by-email.js'; -import * as ep___admin_ad_create from './endpoints/admin/ad/create.js'; -import * as ep___admin_ad_delete from './endpoints/admin/ad/delete.js'; -import * as ep___admin_ad_list from './endpoints/admin/ad/list.js'; -import * as ep___admin_ad_update from './endpoints/admin/ad/update.js'; -import * as ep___admin_announcements_create from './endpoints/admin/announcements/create.js'; -import * as ep___admin_announcements_delete from './endpoints/admin/announcements/delete.js'; -import * as ep___admin_announcements_list from './endpoints/admin/announcements/list.js'; -import * as ep___admin_announcements_update from './endpoints/admin/announcements/update.js'; -import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-decorations/create.js'; -import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; -import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; -import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; -import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; -import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js'; -import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js'; -import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js'; -import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js'; -import * as ep___admin_drive_files from './endpoints/admin/drive/files.js'; -import * as ep___admin_drive_showFile from './endpoints/admin/drive/show-file.js'; -import * as ep___admin_emoji_addAliasesBulk from './endpoints/admin/emoji/add-aliases-bulk.js'; -import * as ep___admin_emoji_add from './endpoints/admin/emoji/add.js'; -import * as ep___admin_emoji_copy from './endpoints/admin/emoji/copy.js'; -import * as ep___admin_emoji_deleteBulk from './endpoints/admin/emoji/delete-bulk.js'; -import * as ep___admin_emoji_delete from './endpoints/admin/emoji/delete.js'; -import * as ep___admin_emoji_importZip from './endpoints/admin/emoji/import-zip.js'; -import * as ep___admin_emoji_listRemote from './endpoints/admin/emoji/list-remote.js'; -import * as ep___admin_emoji_list from './endpoints/admin/emoji/list.js'; -import * as ep___admin_emoji_removeAliasesBulk from './endpoints/admin/emoji/remove-aliases-bulk.js'; -import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-aliases-bulk.js'; -import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; -import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; -import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; -import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; -import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; -import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/federation/remove-all-following.js'; -import * as ep___admin_federation_updateInstance from './endpoints/admin/federation/update-instance.js'; -import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js'; -import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js'; -import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js'; -import * as ep___admin_invite_create from './endpoints/admin/invite/create.js'; -import * as ep___admin_invite_list from './endpoints/admin/invite/list.js'; -import * as ep___admin_promo_create from './endpoints/admin/promo/create.js'; -import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; -import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; -import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js'; -import * as ep___admin_queue_promote from './endpoints/admin/queue/promote.js'; -import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js'; -import * as ep___admin_relays_add from './endpoints/admin/relays/add.js'; -import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; -import * as ep___admin_relays_remove from './endpoints/admin/relays/remove.js'; -import * as ep___admin_resetPassword from './endpoints/admin/reset-password.js'; -import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-abuse-user-report.js'; -import * as ep___admin_sendEmail from './endpoints/admin/send-email.js'; -import * as ep___admin_serverInfo from './endpoints/admin/server-info.js'; -import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js'; -import * as ep___admin_showUser from './endpoints/admin/show-user.js'; -import * as ep___admin_showUsers from './endpoints/admin/show-users.js'; -import * as ep___admin_nsfwUser from './endpoints/admin/nsfw-user.js'; -import * as ep___admin_unnsfwUser from './endpoints/admin/unnsfw-user.js'; -import * as ep___admin_silenceUser from './endpoints/admin/silence-user.js'; -import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-user.js'; -import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; -import * as ep___admin_approveUser from './endpoints/admin/approve-user.js'; -import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; -import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; -import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js'; -import * as ep___admin_updateUserNote from './endpoints/admin/update-user-note.js'; -import * as ep___admin_roles_create from './endpoints/admin/roles/create.js'; -import * as ep___admin_roles_delete from './endpoints/admin/roles/delete.js'; -import * as ep___admin_roles_list from './endpoints/admin/roles/list.js'; -import * as ep___admin_roles_show from './endpoints/admin/roles/show.js'; -import * as ep___admin_roles_update from './endpoints/admin/roles/update.js'; -import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js'; -import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'; -import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js'; -import * as ep___admin_roles_users from './endpoints/admin/roles/users.js'; -import * as ep___announcements from './endpoints/announcements.js'; -import * as ep___antennas_create from './endpoints/antennas/create.js'; -import * as ep___antennas_delete from './endpoints/antennas/delete.js'; -import * as ep___antennas_list from './endpoints/antennas/list.js'; -import * as ep___antennas_notes from './endpoints/antennas/notes.js'; -import * as ep___antennas_show from './endpoints/antennas/show.js'; -import * as ep___antennas_update from './endpoints/antennas/update.js'; -import * as ep___ap_get from './endpoints/ap/get.js'; -import * as ep___ap_show from './endpoints/ap/show.js'; -import * as ep___app_create from './endpoints/app/create.js'; -import * as ep___app_show from './endpoints/app/show.js'; -import * as ep___auth_accept from './endpoints/auth/accept.js'; -import * as ep___auth_session_generate from './endpoints/auth/session/generate.js'; -import * as ep___auth_session_show from './endpoints/auth/session/show.js'; -import * as ep___auth_session_userkey from './endpoints/auth/session/userkey.js'; -import * as ep___blocking_create from './endpoints/blocking/create.js'; -import * as ep___blocking_delete from './endpoints/blocking/delete.js'; -import * as ep___blocking_list from './endpoints/blocking/list.js'; -import * as ep___channels_create from './endpoints/channels/create.js'; -import * as ep___channels_featured from './endpoints/channels/featured.js'; -import * as ep___channels_follow from './endpoints/channels/follow.js'; -import * as ep___channels_followed from './endpoints/channels/followed.js'; -import * as ep___channels_owned from './endpoints/channels/owned.js'; -import * as ep___channels_show from './endpoints/channels/show.js'; -import * as ep___channels_timeline from './endpoints/channels/timeline.js'; -import * as ep___channels_unfollow from './endpoints/channels/unfollow.js'; -import * as ep___channels_update from './endpoints/channels/update.js'; -import * as ep___channels_favorite from './endpoints/channels/favorite.js'; -import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; -import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; -import * as ep___channels_search from './endpoints/channels/search.js'; -import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; -import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; -import * as ep___charts_drive from './endpoints/charts/drive.js'; -import * as ep___charts_federation from './endpoints/charts/federation.js'; -import * as ep___charts_instance from './endpoints/charts/instance.js'; -import * as ep___charts_notes from './endpoints/charts/notes.js'; -import * as ep___charts_user_drive from './endpoints/charts/user/drive.js'; -import * as ep___charts_user_following from './endpoints/charts/user/following.js'; -import * as ep___charts_user_notes from './endpoints/charts/user/notes.js'; -import * as ep___charts_user_pv from './endpoints/charts/user/pv.js'; -import * as ep___charts_user_reactions from './endpoints/charts/user/reactions.js'; -import * as ep___charts_users from './endpoints/charts/users.js'; -import * as ep___clips_addNote from './endpoints/clips/add-note.js'; -import * as ep___clips_removeNote from './endpoints/clips/remove-note.js'; -import * as ep___clips_create from './endpoints/clips/create.js'; -import * as ep___clips_delete from './endpoints/clips/delete.js'; -import * as ep___clips_list from './endpoints/clips/list.js'; -import * as ep___clips_notes from './endpoints/clips/notes.js'; -import * as ep___clips_show from './endpoints/clips/show.js'; -import * as ep___clips_update from './endpoints/clips/update.js'; -import * as ep___clips_favorite from './endpoints/clips/favorite.js'; -import * as ep___clips_unfavorite from './endpoints/clips/unfavorite.js'; -import * as ep___clips_myFavorites from './endpoints/clips/my-favorites.js'; -import * as ep___drive from './endpoints/drive.js'; -import * as ep___drive_files from './endpoints/drive/files.js'; -import * as ep___drive_files_attachedNotes from './endpoints/drive/files/attached-notes.js'; -import * as ep___drive_files_checkExistence from './endpoints/drive/files/check-existence.js'; -import * as ep___drive_files_create from './endpoints/drive/files/create.js'; -import * as ep___drive_files_delete from './endpoints/drive/files/delete.js'; -import * as ep___drive_files_findByHash from './endpoints/drive/files/find-by-hash.js'; -import * as ep___drive_files_find from './endpoints/drive/files/find.js'; -import * as ep___drive_files_show from './endpoints/drive/files/show.js'; -import * as ep___drive_files_update from './endpoints/drive/files/update.js'; -import * as ep___drive_files_uploadFromUrl from './endpoints/drive/files/upload-from-url.js'; -import * as ep___drive_folders from './endpoints/drive/folders.js'; -import * as ep___drive_folders_create from './endpoints/drive/folders/create.js'; -import * as ep___drive_folders_delete from './endpoints/drive/folders/delete.js'; -import * as ep___drive_folders_find from './endpoints/drive/folders/find.js'; -import * as ep___drive_folders_show from './endpoints/drive/folders/show.js'; -import * as ep___drive_folders_update from './endpoints/drive/folders/update.js'; -import * as ep___drive_stream from './endpoints/drive/stream.js'; -import * as ep___emailAddress_available from './endpoints/email-address/available.js'; -import * as ep___endpoint from './endpoints/endpoint.js'; -import * as ep___endpoints from './endpoints/endpoints.js'; -import * as ep___exportCustomEmojis from './endpoints/export-custom-emojis.js'; -import * as ep___federation_followers from './endpoints/federation/followers.js'; -import * as ep___federation_following from './endpoints/federation/following.js'; -import * as ep___federation_instances from './endpoints/federation/instances.js'; -import * as ep___federation_showInstance from './endpoints/federation/show-instance.js'; -import * as ep___federation_updateRemoteUser from './endpoints/federation/update-remote-user.js'; -import * as ep___federation_users from './endpoints/federation/users.js'; -import * as ep___federation_stats from './endpoints/federation/stats.js'; -import * as ep___following_create from './endpoints/following/create.js'; -import * as ep___following_delete from './endpoints/following/delete.js'; -import * as ep___following_update from './endpoints/following/update.js'; -import * as ep___following_update_all from './endpoints/following/update-all.js'; -import * as ep___following_invalidate from './endpoints/following/invalidate.js'; -import * as ep___following_requests_accept from './endpoints/following/requests/accept.js'; -import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js'; -import * as ep___following_requests_list from './endpoints/following/requests/list.js'; -import * as ep___following_requests_reject from './endpoints/following/requests/reject.js'; -import * as ep___gallery_featured from './endpoints/gallery/featured.js'; -import * as ep___gallery_popular from './endpoints/gallery/popular.js'; -import * as ep___gallery_posts from './endpoints/gallery/posts.js'; -import * as ep___gallery_posts_create from './endpoints/gallery/posts/create.js'; -import * as ep___gallery_posts_delete from './endpoints/gallery/posts/delete.js'; -import * as ep___gallery_posts_like from './endpoints/gallery/posts/like.js'; -import * as ep___gallery_posts_show from './endpoints/gallery/posts/show.js'; -import * as ep___gallery_posts_unlike from './endpoints/gallery/posts/unlike.js'; -import * as ep___gallery_posts_update from './endpoints/gallery/posts/update.js'; -import * as ep___getOnlineUsersCount from './endpoints/get-online-users-count.js'; -import * as ep___getAvatarDecorations from './endpoints/get-avatar-decorations.js'; -import * as ep___hashtags_list from './endpoints/hashtags/list.js'; -import * as ep___hashtags_search from './endpoints/hashtags/search.js'; -import * as ep___hashtags_show from './endpoints/hashtags/show.js'; -import * as ep___hashtags_trend from './endpoints/hashtags/trend.js'; -import * as ep___hashtags_users from './endpoints/hashtags/users.js'; -import * as ep___i from './endpoints/i.js'; -import * as ep___i_2fa_done from './endpoints/i/2fa/done.js'; -import * as ep___i_2fa_keyDone from './endpoints/i/2fa/key-done.js'; -import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js'; -import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js'; -import * as ep___i_2fa_register from './endpoints/i/2fa/register.js'; -import * as ep___i_2fa_updateKey from './endpoints/i/2fa/update-key.js'; -import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js'; -import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js'; -import * as ep___i_apps from './endpoints/i/apps.js'; -import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js'; -import * as ep___i_claimAchievement from './endpoints/i/claim-achievement.js'; -import * as ep___i_changePassword from './endpoints/i/change-password.js'; -import * as ep___i_deleteAccount from './endpoints/i/delete-account.js'; -import * as ep___i_exportData from './endpoints/i/export-data.js'; -import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js'; -import * as ep___i_exportFollowing from './endpoints/i/export-following.js'; -import * as ep___i_exportMute from './endpoints/i/export-mute.js'; -import * as ep___i_exportNotes from './endpoints/i/export-notes.js'; -import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js'; -import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js'; -import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js'; -import * as ep___i_favorites from './endpoints/i/favorites.js'; -import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js'; -import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js'; -import * as ep___i_importBlocking from './endpoints/i/import-blocking.js'; -import * as ep___i_importFollowing from './endpoints/i/import-following.js'; -import * as ep___i_importNotes from './endpoints/i/import-notes.js'; -import * as ep___i_importMuting from './endpoints/i/import-muting.js'; -import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js'; -import * as ep___i_importAntennas from './endpoints/i/import-antennas.js'; -import * as ep___i_notifications from './endpoints/i/notifications.js'; -import * as ep___i_notificationsGrouped from './endpoints/i/notifications-grouped.js'; -import * as ep___i_pageLikes from './endpoints/i/page-likes.js'; -import * as ep___i_pages from './endpoints/i/pages.js'; -import * as ep___i_pin from './endpoints/i/pin.js'; -import * as ep___i_readAllUnreadNotes from './endpoints/i/read-all-unread-notes.js'; -import * as ep___i_readAnnouncement from './endpoints/i/read-announcement.js'; -import * as ep___i_regenerateToken from './endpoints/i/regenerate-token.js'; -import * as ep___i_registry_getAll from './endpoints/i/registry/get-all.js'; -import * as ep___i_registry_getUnsecure from './endpoints/i/registry/get-unsecure.js'; -import * as ep___i_registry_getDetail from './endpoints/i/registry/get-detail.js'; -import * as ep___i_registry_get from './endpoints/i/registry/get.js'; -import * as ep___i_registry_keysWithType from './endpoints/i/registry/keys-with-type.js'; -import * as ep___i_registry_keys from './endpoints/i/registry/keys.js'; -import * as ep___i_registry_remove from './endpoints/i/registry/remove.js'; -import * as ep___i_registry_scopesWithDomain from './endpoints/i/registry/scopes-with-domain.js'; -import * as ep___i_registry_set from './endpoints/i/registry/set.js'; -import * as ep___i_revokeToken from './endpoints/i/revoke-token.js'; -import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; -import * as ep___i_unpin from './endpoints/i/unpin.js'; -import * as ep___i_updateEmail from './endpoints/i/update-email.js'; -import * as ep___i_update from './endpoints/i/update.js'; -import * as ep___i_move from './endpoints/i/move.js'; -import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; -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___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'; -import * as ep___invite_limit from './endpoints/invite/limit.js'; -import * as ep___meta from './endpoints/meta.js'; -import * as ep___emojis from './endpoints/emojis.js'; -import * as ep___emoji from './endpoints/emoji.js'; -import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; -import * as ep___mute_create from './endpoints/mute/create.js'; -import * as ep___mute_delete from './endpoints/mute/delete.js'; -import * as ep___mute_list from './endpoints/mute/list.js'; -import * as ep___renoteMute_create from './endpoints/renote-mute/create.js'; -import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js'; -import * as ep___renoteMute_list from './endpoints/renote-mute/list.js'; -import * as ep___my_apps from './endpoints/my/apps.js'; -import * as ep___notes from './endpoints/notes.js'; -import * as ep___notes_children from './endpoints/notes/children.js'; -import * as ep___notes_clips from './endpoints/notes/clips.js'; -import * as ep___notes_conversation from './endpoints/notes/conversation.js'; -import * as ep___notes_create from './endpoints/notes/create.js'; -import * as ep___notes_delete from './endpoints/notes/delete.js'; -import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; -import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; -import * as ep___notes_featured from './endpoints/notes/featured.js'; -import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js'; -import * as ep___notes_bubbleTimeline from './endpoints/notes/bubble-timeline.js'; -import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js'; -import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; -import * as ep___notes_mentions from './endpoints/notes/mentions.js'; -import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js'; -import * as ep___notes_polls_vote from './endpoints/notes/polls/vote.js'; -import * as ep___notes_reactions from './endpoints/notes/reactions.js'; -import * as ep___notes_reactions_create from './endpoints/notes/reactions/create.js'; -import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.js'; -import * as ep___notes_like from './endpoints/notes/like.js'; -import * as ep___notes_renotes from './endpoints/notes/renotes.js'; -import * as ep___notes_replies from './endpoints/notes/replies.js'; -import * as ep___notes_edit from './endpoints/notes/edit.js'; -import * as ep___notes_versions from './endpoints/notes/versions.js'; -import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js'; -import * as ep___notes_search from './endpoints/notes/search.js'; -import * as ep___notes_show from './endpoints/notes/show.js'; -import * as ep___notes_state from './endpoints/notes/state.js'; -import * as ep___notes_threadMuting_create from './endpoints/notes/thread-muting/create.js'; -import * as ep___notes_threadMuting_delete from './endpoints/notes/thread-muting/delete.js'; -import * as ep___notes_timeline from './endpoints/notes/timeline.js'; -import * as ep___notes_translate from './endpoints/notes/translate.js'; -import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; -import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; -import * as ep___notifications_create from './endpoints/notifications/create.js'; -import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; -import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js'; -import * as ep___pagePush from './endpoints/page-push.js'; -import * as ep___pages_create from './endpoints/pages/create.js'; -import * as ep___pages_delete from './endpoints/pages/delete.js'; -import * as ep___pages_featured from './endpoints/pages/featured.js'; -import * as ep___pages_like from './endpoints/pages/like.js'; -import * as ep___pages_show from './endpoints/pages/show.js'; -import * as ep___pages_unlike from './endpoints/pages/unlike.js'; -import * as ep___pages_update from './endpoints/pages/update.js'; -import * as ep___flash_create from './endpoints/flash/create.js'; -import * as ep___flash_delete from './endpoints/flash/delete.js'; -import * as ep___flash_featured from './endpoints/flash/featured.js'; -import * as ep___flash_like from './endpoints/flash/like.js'; -import * as ep___flash_show from './endpoints/flash/show.js'; -import * as ep___flash_unlike from './endpoints/flash/unlike.js'; -import * as ep___flash_update from './endpoints/flash/update.js'; -import * as ep___flash_my from './endpoints/flash/my.js'; -import * as ep___flash_myLikes from './endpoints/flash/my-likes.js'; -import * as ep___ping from './endpoints/ping.js'; -import * as ep___pinnedUsers from './endpoints/pinned-users.js'; -import * as ep___promo_read from './endpoints/promo/read.js'; -import * as ep___roles_list from './endpoints/roles/list.js'; -import * as ep___roles_show from './endpoints/roles/show.js'; -import * as ep___roles_users from './endpoints/roles/users.js'; -import * as ep___roles_notes from './endpoints/roles/notes.js'; -import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; -import * as ep___resetDb from './endpoints/reset-db.js'; -import * as ep___resetPassword from './endpoints/reset-password.js'; -import * as ep___serverInfo from './endpoints/server-info.js'; -import * as ep___stats from './endpoints/stats.js'; -import * as ep___sw_show_registration from './endpoints/sw/show-registration.js'; -import * as ep___sw_update_registration from './endpoints/sw/update-registration.js'; -import * as ep___sw_register from './endpoints/sw/register.js'; -import * as ep___sw_unregister from './endpoints/sw/unregister.js'; -import * as ep___test from './endpoints/test.js'; -import * as ep___username_available from './endpoints/username/available.js'; -import * as ep___users from './endpoints/users.js'; -import * as ep___users_clips from './endpoints/users/clips.js'; -import * as ep___users_followers from './endpoints/users/followers.js'; -import * as ep___users_following from './endpoints/users/following.js'; -import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js'; -import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js'; -import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js'; -import * as ep___users_lists_create from './endpoints/users/lists/create.js'; -import * as ep___users_lists_delete from './endpoints/users/lists/delete.js'; -import * as ep___users_lists_list from './endpoints/users/lists/list.js'; -import * as ep___users_lists_pull from './endpoints/users/lists/pull.js'; -import * as ep___users_lists_push from './endpoints/users/lists/push.js'; -import * as ep___users_lists_show from './endpoints/users/lists/show.js'; -import * as ep___users_lists_update from './endpoints/users/lists/update.js'; -import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js'; -import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js'; -import * as ep___users_lists_createFromPublic from './endpoints/users/lists/create-from-public.js'; -import * as ep___users_lists_updateMembership from './endpoints/users/lists/update-membership.js'; -import * as ep___users_lists_getMemberships from './endpoints/users/lists/get-memberships.js'; -import * as ep___users_notes from './endpoints/users/notes.js'; -import * as ep___users_pages from './endpoints/users/pages.js'; -import * as ep___users_flashs from './endpoints/users/flashs.js'; -import * as ep___users_reactions from './endpoints/users/reactions.js'; -import * as ep___users_recommendation from './endpoints/users/recommendation.js'; -import * as ep___users_relation from './endpoints/users/relation.js'; -import * as ep___users_reportAbuse from './endpoints/users/report-abuse.js'; -import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by-username-and-host.js'; -import * as ep___users_search from './endpoints/users/search.js'; -import * as ep___users_show from './endpoints/users/show.js'; -import * as ep___users_achievements from './endpoints/users/achievements.js'; -import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; -import * as ep___fetchRss from './endpoints/fetch-rss.js'; -import * as ep___fetchExternalResources from './endpoints/fetch-external-resources.js'; -import * as ep___retention from './endpoints/retention.js'; -import * as ep___sponsors from './endpoints/sponsors.js'; -import { GetterService } from './GetterService.js'; -import { ApiLoggerService } from './ApiLoggerService.js'; -import type { Provider } from '@nestjs/common'; - -const $admin_meta: Provider = { provide: 'ep:admin/meta', useClass: ep___admin_meta.default }; -const $admin_abuseUserReports: Provider = { provide: 'ep:admin/abuse-user-reports', useClass: ep___admin_abuseUserReports.default }; -const $admin_accounts_create: Provider = { provide: 'ep:admin/accounts/create', useClass: ep___admin_accounts_create.default }; -const $admin_accounts_delete: Provider = { provide: 'ep:admin/accounts/delete', useClass: ep___admin_accounts_delete.default }; -const $admin_accounts_findByEmail: Provider = { provide: 'ep:admin/accounts/find-by-email', useClass: ep___admin_accounts_findByEmail.default }; -const $admin_ad_create: Provider = { provide: 'ep:admin/ad/create', useClass: ep___admin_ad_create.default }; -const $admin_ad_delete: Provider = { provide: 'ep:admin/ad/delete', useClass: ep___admin_ad_delete.default }; -const $admin_ad_list: Provider = { provide: 'ep:admin/ad/list', useClass: ep___admin_ad_list.default }; -const $admin_ad_update: Provider = { provide: 'ep:admin/ad/update', useClass: ep___admin_ad_update.default }; -const $admin_announcements_create: Provider = { provide: 'ep:admin/announcements/create', useClass: ep___admin_announcements_create.default }; -const $admin_announcements_delete: Provider = { provide: 'ep:admin/announcements/delete', useClass: ep___admin_announcements_delete.default }; -const $admin_announcements_list: Provider = { provide: 'ep:admin/announcements/list', useClass: ep___admin_announcements_list.default }; -const $admin_announcements_update: Provider = { provide: 'ep:admin/announcements/update', useClass: ep___admin_announcements_update.default }; -const $admin_avatarDecorations_create: Provider = { provide: 'ep:admin/avatar-decorations/create', useClass: ep___admin_avatarDecorations_create.default }; -const $admin_avatarDecorations_delete: Provider = { provide: 'ep:admin/avatar-decorations/delete', useClass: ep___admin_avatarDecorations_delete.default }; -const $admin_avatarDecorations_list: Provider = { provide: 'ep:admin/avatar-decorations/list', useClass: ep___admin_avatarDecorations_list.default }; -const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-decorations/update', useClass: ep___admin_avatarDecorations_update.default }; -const $admin_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/delete-all-files-of-a-user', useClass: ep___admin_deleteAllFilesOfAUser.default }; -const $admin_unsetUserAvatar: Provider = { provide: 'ep:admin/unset-user-avatar', useClass: ep___admin_unsetUserAvatar.default }; -const $admin_unsetUserBanner: Provider = { provide: 'ep:admin/unset-user-banner', useClass: ep___admin_unsetUserBanner.default }; -const $admin_drive_cleanRemoteFiles: Provider = { provide: 'ep:admin/drive/clean-remote-files', useClass: ep___admin_drive_cleanRemoteFiles.default }; -const $admin_drive_cleanup: Provider = { provide: 'ep:admin/drive/cleanup', useClass: ep___admin_drive_cleanup.default }; -const $admin_drive_files: Provider = { provide: 'ep:admin/drive/files', useClass: ep___admin_drive_files.default }; -const $admin_drive_showFile: Provider = { provide: 'ep:admin/drive/show-file', useClass: ep___admin_drive_showFile.default }; -const $admin_emoji_addAliasesBulk: Provider = { provide: 'ep:admin/emoji/add-aliases-bulk', useClass: ep___admin_emoji_addAliasesBulk.default }; -const $admin_emoji_add: Provider = { provide: 'ep:admin/emoji/add', useClass: ep___admin_emoji_add.default }; -const $admin_emoji_copy: Provider = { provide: 'ep:admin/emoji/copy', useClass: ep___admin_emoji_copy.default }; -const $admin_emoji_deleteBulk: Provider = { provide: 'ep:admin/emoji/delete-bulk', useClass: ep___admin_emoji_deleteBulk.default }; -const $admin_emoji_delete: Provider = { provide: 'ep:admin/emoji/delete', useClass: ep___admin_emoji_delete.default }; -const $admin_emoji_importZip: Provider = { provide: 'ep:admin/emoji/import-zip', useClass: ep___admin_emoji_importZip.default }; -const $admin_emoji_listRemote: Provider = { provide: 'ep:admin/emoji/list-remote', useClass: ep___admin_emoji_listRemote.default }; -const $admin_emoji_list: Provider = { provide: 'ep:admin/emoji/list', useClass: ep___admin_emoji_list.default }; -const $admin_emoji_removeAliasesBulk: Provider = { provide: 'ep:admin/emoji/remove-aliases-bulk', useClass: ep___admin_emoji_removeAliasesBulk.default }; -const $admin_emoji_setAliasesBulk: Provider = { provide: 'ep:admin/emoji/set-aliases-bulk', useClass: ep___admin_emoji_setAliasesBulk.default }; -const $admin_emoji_setCategoryBulk: Provider = { provide: 'ep:admin/emoji/set-category-bulk', useClass: ep___admin_emoji_setCategoryBulk.default }; -const $admin_emoji_setLicenseBulk: Provider = { provide: 'ep:admin/emoji/set-license-bulk', useClass: ep___admin_emoji_setLicenseBulk.default }; -const $admin_emoji_update: Provider = { provide: 'ep:admin/emoji/update', useClass: ep___admin_emoji_update.default }; -const $admin_federation_deleteAllFiles: Provider = { provide: 'ep:admin/federation/delete-all-files', useClass: ep___admin_federation_deleteAllFiles.default }; -const $admin_federation_refreshRemoteInstanceMetadata: Provider = { provide: 'ep:admin/federation/refresh-remote-instance-metadata', useClass: ep___admin_federation_refreshRemoteInstanceMetadata.default }; -const $admin_federation_removeAllFollowing: Provider = { provide: 'ep:admin/federation/remove-all-following', useClass: ep___admin_federation_removeAllFollowing.default }; -const $admin_federation_updateInstance: Provider = { provide: 'ep:admin/federation/update-instance', useClass: ep___admin_federation_updateInstance.default }; -const $admin_getIndexStats: Provider = { provide: 'ep:admin/get-index-stats', useClass: ep___admin_getIndexStats.default }; -const $admin_getTableStats: Provider = { provide: 'ep:admin/get-table-stats', useClass: ep___admin_getTableStats.default }; -const $admin_getUserIps: Provider = { provide: 'ep:admin/get-user-ips', useClass: ep___admin_getUserIps.default }; -const $admin_invite_create: Provider = { provide: 'ep:admin/invite/create', useClass: ep___admin_invite_create.default }; -const $admin_invite_list: Provider = { provide: 'ep:admin/invite/list', useClass: ep___admin_invite_list.default }; -const $admin_promo_create: Provider = { provide: 'ep:admin/promo/create', useClass: ep___admin_promo_create.default }; -const $admin_queue_clear: Provider = { provide: 'ep:admin/queue/clear', useClass: ep___admin_queue_clear.default }; -const $admin_queue_deliverDelayed: Provider = { provide: 'ep:admin/queue/deliver-delayed', useClass: ep___admin_queue_deliverDelayed.default }; -const $admin_queue_inboxDelayed: Provider = { provide: 'ep:admin/queue/inbox-delayed', useClass: ep___admin_queue_inboxDelayed.default }; -const $admin_queue_promote: Provider = { provide: 'ep:admin/queue/promote', useClass: ep___admin_queue_promote.default }; -const $admin_queue_stats: Provider = { provide: 'ep:admin/queue/stats', useClass: ep___admin_queue_stats.default }; -const $admin_relays_add: Provider = { provide: 'ep:admin/relays/add', useClass: ep___admin_relays_add.default }; -const $admin_relays_list: Provider = { provide: 'ep:admin/relays/list', useClass: ep___admin_relays_list.default }; -const $admin_relays_remove: Provider = { provide: 'ep:admin/relays/remove', useClass: ep___admin_relays_remove.default }; -const $admin_resetPassword: Provider = { provide: 'ep:admin/reset-password', useClass: ep___admin_resetPassword.default }; -const $admin_resolveAbuseUserReport: Provider = { provide: 'ep:admin/resolve-abuse-user-report', useClass: ep___admin_resolveAbuseUserReport.default }; -const $admin_sendEmail: Provider = { provide: 'ep:admin/send-email', useClass: ep___admin_sendEmail.default }; -const $admin_serverInfo: Provider = { provide: 'ep:admin/server-info', useClass: ep___admin_serverInfo.default }; -const $admin_showModerationLogs: Provider = { provide: 'ep:admin/show-moderation-logs', useClass: ep___admin_showModerationLogs.default }; -const $admin_showUser: Provider = { provide: 'ep:admin/show-user', useClass: ep___admin_showUser.default }; -const $admin_showUsers: Provider = { provide: 'ep:admin/show-users', useClass: ep___admin_showUsers.default }; -const $admin_nsfwUser: Provider = { provide: 'ep:admin/nsfw-user', useClass: ep___admin_nsfwUser.default }; -const $admin_unnsfwUser: Provider = { provide: 'ep:admin/unnsfw-user', useClass: ep___admin_unnsfwUser.default }; -const $admin_silenceUser: Provider = { provide: 'ep:admin/silence-user', useClass: ep___admin_silenceUser.default }; -const $admin_unsilenceUser: Provider = { provide: 'ep:admin/unsilence-user', useClass: ep___admin_unsilenceUser.default }; -const $admin_suspendUser: Provider = { provide: 'ep:admin/suspend-user', useClass: ep___admin_suspendUser.default }; -const $admin_approveUser: Provider = { provide: 'ep:admin/approve-user', useClass: ep___admin_approveUser.default }; -const $admin_unsuspendUser: Provider = { provide: 'ep:admin/unsuspend-user', useClass: ep___admin_unsuspendUser.default }; -const $admin_updateMeta: Provider = { provide: 'ep:admin/update-meta', useClass: ep___admin_updateMeta.default }; -const $admin_deleteAccount: Provider = { provide: 'ep:admin/delete-account', useClass: ep___admin_deleteAccount.default }; -const $admin_updateUserNote: Provider = { provide: 'ep:admin/update-user-note', useClass: ep___admin_updateUserNote.default }; -const $admin_roles_create: Provider = { provide: 'ep:admin/roles/create', useClass: ep___admin_roles_create.default }; -const $admin_roles_delete: Provider = { provide: 'ep:admin/roles/delete', useClass: ep___admin_roles_delete.default }; -const $admin_roles_list: Provider = { provide: 'ep:admin/roles/list', useClass: ep___admin_roles_list.default }; -const $admin_roles_show: Provider = { provide: 'ep:admin/roles/show', useClass: ep___admin_roles_show.default }; -const $admin_roles_update: Provider = { provide: 'ep:admin/roles/update', useClass: ep___admin_roles_update.default }; -const $admin_roles_assign: Provider = { provide: 'ep:admin/roles/assign', useClass: ep___admin_roles_assign.default }; -const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', useClass: ep___admin_roles_unassign.default }; -const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default }; -const $admin_roles_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.default }; -const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default }; -const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default }; -const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default }; -const $antennas_list: Provider = { provide: 'ep:antennas/list', useClass: ep___antennas_list.default }; -const $antennas_notes: Provider = { provide: 'ep:antennas/notes', useClass: ep___antennas_notes.default }; -const $antennas_show: Provider = { provide: 'ep:antennas/show', useClass: ep___antennas_show.default }; -const $antennas_update: Provider = { provide: 'ep:antennas/update', useClass: ep___antennas_update.default }; -const $ap_get: Provider = { provide: 'ep:ap/get', useClass: ep___ap_get.default }; -const $ap_show: Provider = { provide: 'ep:ap/show', useClass: ep___ap_show.default }; -const $app_create: Provider = { provide: 'ep:app/create', useClass: ep___app_create.default }; -const $app_show: Provider = { provide: 'ep:app/show', useClass: ep___app_show.default }; -const $auth_accept: Provider = { provide: 'ep:auth/accept', useClass: ep___auth_accept.default }; -const $auth_session_generate: Provider = { provide: 'ep:auth/session/generate', useClass: ep___auth_session_generate.default }; -const $auth_session_show: Provider = { provide: 'ep:auth/session/show', useClass: ep___auth_session_show.default }; -const $auth_session_userkey: Provider = { provide: 'ep:auth/session/userkey', useClass: ep___auth_session_userkey.default }; -const $blocking_create: Provider = { provide: 'ep:blocking/create', useClass: ep___blocking_create.default }; -const $blocking_delete: Provider = { provide: 'ep:blocking/delete', useClass: ep___blocking_delete.default }; -const $blocking_list: Provider = { provide: 'ep:blocking/list', useClass: ep___blocking_list.default }; -const $channels_create: Provider = { provide: 'ep:channels/create', useClass: ep___channels_create.default }; -const $channels_featured: Provider = { provide: 'ep:channels/featured', useClass: ep___channels_featured.default }; -const $channels_follow: Provider = { provide: 'ep:channels/follow', useClass: ep___channels_follow.default }; -const $channels_followed: Provider = { provide: 'ep:channels/followed', useClass: ep___channels_followed.default }; -const $channels_owned: Provider = { provide: 'ep:channels/owned', useClass: ep___channels_owned.default }; -const $channels_show: Provider = { provide: 'ep:channels/show', useClass: ep___channels_show.default }; -const $channels_timeline: Provider = { provide: 'ep:channels/timeline', useClass: ep___channels_timeline.default }; -const $channels_unfollow: Provider = { provide: 'ep:channels/unfollow', useClass: ep___channels_unfollow.default }; -const $channels_update: Provider = { provide: 'ep:channels/update', useClass: ep___channels_update.default }; -const $channels_favorite: Provider = { provide: 'ep:channels/favorite', useClass: ep___channels_favorite.default }; -const $channels_unfavorite: Provider = { provide: 'ep:channels/unfavorite', useClass: ep___channels_unfavorite.default }; -const $channels_myFavorites: Provider = { provide: 'ep:channels/my-favorites', useClass: ep___channels_myFavorites.default }; -const $channels_search: Provider = { provide: 'ep:channels/search', useClass: ep___channels_search.default }; -const $charts_activeUsers: Provider = { provide: 'ep:charts/active-users', useClass: ep___charts_activeUsers.default }; -const $charts_apRequest: Provider = { provide: 'ep:charts/ap-request', useClass: ep___charts_apRequest.default }; -const $charts_drive: Provider = { provide: 'ep:charts/drive', useClass: ep___charts_drive.default }; -const $charts_federation: Provider = { provide: 'ep:charts/federation', useClass: ep___charts_federation.default }; -const $charts_instance: Provider = { provide: 'ep:charts/instance', useClass: ep___charts_instance.default }; -const $charts_notes: Provider = { provide: 'ep:charts/notes', useClass: ep___charts_notes.default }; -const $charts_user_drive: Provider = { provide: 'ep:charts/user/drive', useClass: ep___charts_user_drive.default }; -const $charts_user_following: Provider = { provide: 'ep:charts/user/following', useClass: ep___charts_user_following.default }; -const $charts_user_notes: Provider = { provide: 'ep:charts/user/notes', useClass: ep___charts_user_notes.default }; -const $charts_user_pv: Provider = { provide: 'ep:charts/user/pv', useClass: ep___charts_user_pv.default }; -const $charts_user_reactions: Provider = { provide: 'ep:charts/user/reactions', useClass: ep___charts_user_reactions.default }; -const $charts_users: Provider = { provide: 'ep:charts/users', useClass: ep___charts_users.default }; -const $clips_addNote: Provider = { provide: 'ep:clips/add-note', useClass: ep___clips_addNote.default }; -const $clips_removeNote: Provider = { provide: 'ep:clips/remove-note', useClass: ep___clips_removeNote.default }; -const $clips_create: Provider = { provide: 'ep:clips/create', useClass: ep___clips_create.default }; -const $clips_delete: Provider = { provide: 'ep:clips/delete', useClass: ep___clips_delete.default }; -const $clips_list: Provider = { provide: 'ep:clips/list', useClass: ep___clips_list.default }; -const $clips_notes: Provider = { provide: 'ep:clips/notes', useClass: ep___clips_notes.default }; -const $clips_show: Provider = { provide: 'ep:clips/show', useClass: ep___clips_show.default }; -const $clips_update: Provider = { provide: 'ep:clips/update', useClass: ep___clips_update.default }; -const $clips_favorite: Provider = { provide: 'ep:clips/favorite', useClass: ep___clips_favorite.default }; -const $clips_unfavorite: Provider = { provide: 'ep:clips/unfavorite', useClass: ep___clips_unfavorite.default }; -const $clips_myFavorites: Provider = { provide: 'ep:clips/my-favorites', useClass: ep___clips_myFavorites.default }; -const $drive: Provider = { provide: 'ep:drive', useClass: ep___drive.default }; -const $drive_files: Provider = { provide: 'ep:drive/files', useClass: ep___drive_files.default }; -const $drive_files_attachedNotes: Provider = { provide: 'ep:drive/files/attached-notes', useClass: ep___drive_files_attachedNotes.default }; -const $drive_files_checkExistence: Provider = { provide: 'ep:drive/files/check-existence', useClass: ep___drive_files_checkExistence.default }; -const $drive_files_create: Provider = { provide: 'ep:drive/files/create', useClass: ep___drive_files_create.default }; -const $drive_files_delete: Provider = { provide: 'ep:drive/files/delete', useClass: ep___drive_files_delete.default }; -const $drive_files_findByHash: Provider = { provide: 'ep:drive/files/find-by-hash', useClass: ep___drive_files_findByHash.default }; -const $drive_files_find: Provider = { provide: 'ep:drive/files/find', useClass: ep___drive_files_find.default }; -const $drive_files_show: Provider = { provide: 'ep:drive/files/show', useClass: ep___drive_files_show.default }; -const $drive_files_update: Provider = { provide: 'ep:drive/files/update', useClass: ep___drive_files_update.default }; -const $drive_files_uploadFromUrl: Provider = { provide: 'ep:drive/files/upload-from-url', useClass: ep___drive_files_uploadFromUrl.default }; -const $drive_folders: Provider = { provide: 'ep:drive/folders', useClass: ep___drive_folders.default }; -const $drive_folders_create: Provider = { provide: 'ep:drive/folders/create', useClass: ep___drive_folders_create.default }; -const $drive_folders_delete: Provider = { provide: 'ep:drive/folders/delete', useClass: ep___drive_folders_delete.default }; -const $drive_folders_find: Provider = { provide: 'ep:drive/folders/find', useClass: ep___drive_folders_find.default }; -const $drive_folders_show: Provider = { provide: 'ep:drive/folders/show', useClass: ep___drive_folders_show.default }; -const $drive_folders_update: Provider = { provide: 'ep:drive/folders/update', useClass: ep___drive_folders_update.default }; -const $drive_stream: Provider = { provide: 'ep:drive/stream', useClass: ep___drive_stream.default }; -const $emailAddress_available: Provider = { provide: 'ep:email-address/available', useClass: ep___emailAddress_available.default }; -const $endpoint: Provider = { provide: 'ep:endpoint', useClass: ep___endpoint.default }; -const $endpoints: Provider = { provide: 'ep:endpoints', useClass: ep___endpoints.default }; -const $exportCustomEmojis: Provider = { provide: 'ep:export-custom-emojis', useClass: ep___exportCustomEmojis.default }; -const $federation_followers: Provider = { provide: 'ep:federation/followers', useClass: ep___federation_followers.default }; -const $federation_following: Provider = { provide: 'ep:federation/following', useClass: ep___federation_following.default }; -const $federation_instances: Provider = { provide: 'ep:federation/instances', useClass: ep___federation_instances.default }; -const $federation_showInstance: Provider = { provide: 'ep:federation/show-instance', useClass: ep___federation_showInstance.default }; -const $federation_updateRemoteUser: Provider = { provide: 'ep:federation/update-remote-user', useClass: ep___federation_updateRemoteUser.default }; -const $federation_users: Provider = { provide: 'ep:federation/users', useClass: ep___federation_users.default }; -const $federation_stats: Provider = { provide: 'ep:federation/stats', useClass: ep___federation_stats.default }; -const $following_create: Provider = { provide: 'ep:following/create', useClass: ep___following_create.default }; -const $following_delete: Provider = { provide: 'ep:following/delete', useClass: ep___following_delete.default }; -const $following_update: Provider = { provide: 'ep:following/update', useClass: ep___following_update.default }; -const $following_update_all: Provider = { provide: 'ep:following/update-all', useClass: ep___following_update_all.default }; -const $following_invalidate: Provider = { provide: 'ep:following/invalidate', useClass: ep___following_invalidate.default }; -const $following_requests_accept: Provider = { provide: 'ep:following/requests/accept', useClass: ep___following_requests_accept.default }; -const $following_requests_cancel: Provider = { provide: 'ep:following/requests/cancel', useClass: ep___following_requests_cancel.default }; -const $following_requests_list: Provider = { provide: 'ep:following/requests/list', useClass: ep___following_requests_list.default }; -const $following_requests_reject: Provider = { provide: 'ep:following/requests/reject', useClass: ep___following_requests_reject.default }; -const $gallery_featured: Provider = { provide: 'ep:gallery/featured', useClass: ep___gallery_featured.default }; -const $gallery_popular: Provider = { provide: 'ep:gallery/popular', useClass: ep___gallery_popular.default }; -const $gallery_posts: Provider = { provide: 'ep:gallery/posts', useClass: ep___gallery_posts.default }; -const $gallery_posts_create: Provider = { provide: 'ep:gallery/posts/create', useClass: ep___gallery_posts_create.default }; -const $gallery_posts_delete: Provider = { provide: 'ep:gallery/posts/delete', useClass: ep___gallery_posts_delete.default }; -const $gallery_posts_like: Provider = { provide: 'ep:gallery/posts/like', useClass: ep___gallery_posts_like.default }; -const $gallery_posts_show: Provider = { provide: 'ep:gallery/posts/show', useClass: ep___gallery_posts_show.default }; -const $gallery_posts_unlike: Provider = { provide: 'ep:gallery/posts/unlike', useClass: ep___gallery_posts_unlike.default }; -const $gallery_posts_update: Provider = { provide: 'ep:gallery/posts/update', useClass: ep___gallery_posts_update.default }; -const $getOnlineUsersCount: Provider = { provide: 'ep:get-online-users-count', useClass: ep___getOnlineUsersCount.default }; -const $getAvatarDecorations: Provider = { provide: 'ep:get-avatar-decorations', useClass: ep___getAvatarDecorations.default }; -const $hashtags_list: Provider = { provide: 'ep:hashtags/list', useClass: ep___hashtags_list.default }; -const $hashtags_search: Provider = { provide: 'ep:hashtags/search', useClass: ep___hashtags_search.default }; -const $hashtags_show: Provider = { provide: 'ep:hashtags/show', useClass: ep___hashtags_show.default }; -const $hashtags_trend: Provider = { provide: 'ep:hashtags/trend', useClass: ep___hashtags_trend.default }; -const $hashtags_users: Provider = { provide: 'ep:hashtags/users', useClass: ep___hashtags_users.default }; -const $i: Provider = { provide: 'ep:i', useClass: ep___i.default }; -const $i_2fa_done: Provider = { provide: 'ep:i/2fa/done', useClass: ep___i_2fa_done.default }; -const $i_2fa_keyDone: Provider = { provide: 'ep:i/2fa/key-done', useClass: ep___i_2fa_keyDone.default }; -const $i_2fa_passwordLess: Provider = { provide: 'ep:i/2fa/password-less', useClass: ep___i_2fa_passwordLess.default }; -const $i_2fa_registerKey: Provider = { provide: 'ep:i/2fa/register-key', useClass: ep___i_2fa_registerKey.default }; -const $i_2fa_register: Provider = { provide: 'ep:i/2fa/register', useClass: ep___i_2fa_register.default }; -const $i_2fa_updateKey: Provider = { provide: 'ep:i/2fa/update-key', useClass: ep___i_2fa_updateKey.default }; -const $i_2fa_removeKey: Provider = { provide: 'ep:i/2fa/remove-key', useClass: ep___i_2fa_removeKey.default }; -const $i_2fa_unregister: Provider = { provide: 'ep:i/2fa/unregister', useClass: ep___i_2fa_unregister.default }; -const $i_apps: Provider = { provide: 'ep:i/apps', useClass: ep___i_apps.default }; -const $i_authorizedApps: Provider = { provide: 'ep:i/authorized-apps', useClass: ep___i_authorizedApps.default }; -const $i_claimAchievement: Provider = { provide: 'ep:i/claim-achievement', useClass: ep___i_claimAchievement.default }; -const $i_changePassword: Provider = { provide: 'ep:i/change-password', useClass: ep___i_changePassword.default }; -const $i_deleteAccount: Provider = { provide: 'ep:i/delete-account', useClass: ep___i_deleteAccount.default }; -const $i_exportData: Provider = { provide: 'ep:i/export-data', useClass: ep___i_exportData.default }; -const $i_exportBlocking: Provider = { provide: 'ep:i/export-blocking', useClass: ep___i_exportBlocking.default }; -const $i_exportFollowing: Provider = { provide: 'ep:i/export-following', useClass: ep___i_exportFollowing.default }; -const $i_exportMute: Provider = { provide: 'ep:i/export-mute', useClass: ep___i_exportMute.default }; -const $i_exportNotes: Provider = { provide: 'ep:i/export-notes', useClass: ep___i_exportNotes.default }; -const $i_exportFavorites: Provider = { provide: 'ep:i/export-favorites', useClass: ep___i_exportFavorites.default }; -const $i_exportUserLists: Provider = { provide: 'ep:i/export-user-lists', useClass: ep___i_exportUserLists.default }; -const $i_exportAntennas: Provider = { provide: 'ep:i/export-antennas', useClass: ep___i_exportAntennas.default }; -const $i_favorites: Provider = { provide: 'ep:i/favorites', useClass: ep___i_favorites.default }; -const $i_gallery_likes: Provider = { provide: 'ep:i/gallery/likes', useClass: ep___i_gallery_likes.default }; -const $i_gallery_posts: Provider = { provide: 'ep:i/gallery/posts', useClass: ep___i_gallery_posts.default }; -const $i_importBlocking: Provider = { provide: 'ep:i/import-blocking', useClass: ep___i_importBlocking.default }; -const $i_importFollowing: Provider = { provide: 'ep:i/import-following', useClass: ep___i_importFollowing.default }; -const $i_importNotes: Provider = { provide: 'ep:i/import-notes', useClass: ep___i_importNotes.default }; -const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep___i_importMuting.default }; -const $i_importUserLists: Provider = { provide: 'ep:i/import-user-lists', useClass: ep___i_importUserLists.default }; -const $i_importAntennas: Provider = { provide: 'ep:i/import-antennas', useClass: ep___i_importAntennas.default }; -const $i_notifications: Provider = { provide: 'ep:i/notifications', useClass: ep___i_notifications.default }; -const $i_notificationsGrouped: Provider = { provide: 'ep:i/notifications-grouped', useClass: ep___i_notificationsGrouped.default }; -const $i_pageLikes: Provider = { provide: 'ep:i/page-likes', useClass: ep___i_pageLikes.default }; -const $i_pages: Provider = { provide: 'ep:i/pages', useClass: ep___i_pages.default }; -const $i_pin: Provider = { provide: 'ep:i/pin', useClass: ep___i_pin.default }; -const $i_readAllUnreadNotes: Provider = { provide: 'ep:i/read-all-unread-notes', useClass: ep___i_readAllUnreadNotes.default }; -const $i_readAnnouncement: Provider = { provide: 'ep:i/read-announcement', useClass: ep___i_readAnnouncement.default }; -const $i_regenerateToken: Provider = { provide: 'ep:i/regenerate-token', useClass: ep___i_regenerateToken.default }; -const $i_registry_getAll: Provider = { provide: 'ep:i/registry/get-all', useClass: ep___i_registry_getAll.default }; -const $i_registry_getUnsecure: Provider = { provide: 'ep:i/registry/get-unsecure', useClass: ep___i_registry_getUnsecure.default }; -const $i_registry_getDetail: Provider = { provide: 'ep:i/registry/get-detail', useClass: ep___i_registry_getDetail.default }; -const $i_registry_get: Provider = { provide: 'ep:i/registry/get', useClass: ep___i_registry_get.default }; -const $i_registry_keysWithType: Provider = { provide: 'ep:i/registry/keys-with-type', useClass: ep___i_registry_keysWithType.default }; -const $i_registry_keys: Provider = { provide: 'ep:i/registry/keys', useClass: ep___i_registry_keys.default }; -const $i_registry_remove: Provider = { provide: 'ep:i/registry/remove', useClass: ep___i_registry_remove.default }; -const $i_registry_scopesWithDomain: Provider = { provide: 'ep:i/registry/scopes-with-domain', useClass: ep___i_registry_scopesWithDomain.default }; -const $i_registry_set: Provider = { provide: 'ep:i/registry/set', useClass: ep___i_registry_set.default }; -const $i_revokeToken: Provider = { provide: 'ep:i/revoke-token', useClass: ep___i_revokeToken.default }; -const $i_signinHistory: Provider = { provide: 'ep:i/signin-history', useClass: ep___i_signinHistory.default }; -const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.default }; -const $i_updateEmail: Provider = { provide: 'ep:i/update-email', useClass: ep___i_updateEmail.default }; -const $i_update: Provider = { provide: 'ep:i/update', useClass: ep___i_update.default }; -const $i_move: Provider = { provide: 'ep:i/move', useClass: ep___i_move.default }; -const $i_webhooks_create: Provider = { provide: 'ep:i/webhooks/create', useClass: ep___i_webhooks_create.default }; -const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep___i_webhooks_list.default }; -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 $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 }; -const $invite_limit: Provider = { provide: 'ep:invite/limit', useClass: ep___invite_limit.default }; -const $meta: Provider = { provide: 'ep:meta', useClass: ep___meta.default }; -const $emojis: Provider = { provide: 'ep:emojis', useClass: ep___emojis.default }; -const $emoji: Provider = { provide: 'ep:emoji', useClass: ep___emoji.default }; -const $miauth_genToken: Provider = { provide: 'ep:miauth/gen-token', useClass: ep___miauth_genToken.default }; -const $mute_create: Provider = { provide: 'ep:mute/create', useClass: ep___mute_create.default }; -const $mute_delete: Provider = { provide: 'ep:mute/delete', useClass: ep___mute_delete.default }; -const $mute_list: Provider = { provide: 'ep:mute/list', useClass: ep___mute_list.default }; -const $renoteMute_create: Provider = { provide: 'ep:renote-mute/create', useClass: ep___renoteMute_create.default }; -const $renoteMute_delete: Provider = { provide: 'ep:renote-mute/delete', useClass: ep___renoteMute_delete.default }; -const $renoteMute_list: Provider = { provide: 'ep:renote-mute/list', useClass: ep___renoteMute_list.default }; -const $my_apps: Provider = { provide: 'ep:my/apps', useClass: ep___my_apps.default }; -const $notes: Provider = { provide: 'ep:notes', useClass: ep___notes.default }; -const $notes_children: Provider = { provide: 'ep:notes/children', useClass: ep___notes_children.default }; -const $notes_clips: Provider = { provide: 'ep:notes/clips', useClass: ep___notes_clips.default }; -const $notes_conversation: Provider = { provide: 'ep:notes/conversation', useClass: ep___notes_conversation.default }; -const $notes_create: Provider = { provide: 'ep:notes/create', useClass: ep___notes_create.default }; -const $notes_delete: Provider = { provide: 'ep:notes/delete', useClass: ep___notes_delete.default }; -const $notes_favorites_create: Provider = { provide: 'ep:notes/favorites/create', useClass: ep___notes_favorites_create.default }; -const $notes_favorites_delete: Provider = { provide: 'ep:notes/favorites/delete', useClass: ep___notes_favorites_delete.default }; -const $notes_featured: Provider = { provide: 'ep:notes/featured', useClass: ep___notes_featured.default }; -const $notes_globalTimeline: Provider = { provide: 'ep:notes/global-timeline', useClass: ep___notes_globalTimeline.default }; -const $notes_bubbleTimeline: Provider = { provide: 'ep:notes/bubble-timeline', useClass: ep___notes_bubbleTimeline.default }; -const $notes_hybridTimeline: Provider = { provide: 'ep:notes/hybrid-timeline', useClass: ep___notes_hybridTimeline.default }; -const $notes_localTimeline: Provider = { provide: 'ep:notes/local-timeline', useClass: ep___notes_localTimeline.default }; -const $notes_mentions: Provider = { provide: 'ep:notes/mentions', useClass: ep___notes_mentions.default }; -const $notes_polls_recommendation: Provider = { provide: 'ep:notes/polls/recommendation', useClass: ep___notes_polls_recommendation.default }; -const $notes_polls_vote: Provider = { provide: 'ep:notes/polls/vote', useClass: ep___notes_polls_vote.default }; -const $notes_reactions: Provider = { provide: 'ep:notes/reactions', useClass: ep___notes_reactions.default }; -const $notes_reactions_create: Provider = { provide: 'ep:notes/reactions/create', useClass: ep___notes_reactions_create.default }; -const $notes_reactions_delete: Provider = { provide: 'ep:notes/reactions/delete', useClass: ep___notes_reactions_delete.default }; -const $notes_like: Provider = { provide: 'ep:notes/like', useClass: ep___notes_like.default }; -const $notes_renotes: Provider = { provide: 'ep:notes/renotes', useClass: ep___notes_renotes.default }; -const $notes_replies: Provider = { provide: 'ep:notes/replies', useClass: ep___notes_replies.default }; -const $notes_searchByTag: Provider = { provide: 'ep:notes/search-by-tag', useClass: ep___notes_searchByTag.default }; -const $notes_search: Provider = { provide: 'ep:notes/search', useClass: ep___notes_search.default }; -const $notes_show: Provider = { provide: 'ep:notes/show', useClass: ep___notes_show.default }; -const $notes_state: Provider = { provide: 'ep:notes/state', useClass: ep___notes_state.default }; -const $notes_threadMuting_create: Provider = { provide: 'ep:notes/thread-muting/create', useClass: ep___notes_threadMuting_create.default }; -const $notes_threadMuting_delete: Provider = { provide: 'ep:notes/thread-muting/delete', useClass: ep___notes_threadMuting_delete.default }; -const $notes_timeline: Provider = { provide: 'ep:notes/timeline', useClass: ep___notes_timeline.default }; -const $notes_translate: Provider = { provide: 'ep:notes/translate', useClass: ep___notes_translate.default }; -const $notes_unrenote: Provider = { provide: 'ep:notes/unrenote', useClass: ep___notes_unrenote.default }; -const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timeline', useClass: ep___notes_userListTimeline.default }; -const $notes_edit: Provider = { provide: 'ep:notes/edit', useClass: ep___notes_edit.default }; -const $notes_versions: Provider = { provide: 'ep:notes/versions', useClass: ep___notes_versions.default }; -const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default }; -const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default }; -const $notifications_testNotification: Provider = { provide: 'ep:notifications/test-notification', useClass: ep___notifications_testNotification.default }; -const $pagePush: Provider = { provide: 'ep:page-push', useClass: ep___pagePush.default }; -const $pages_create: Provider = { provide: 'ep:pages/create', useClass: ep___pages_create.default }; -const $pages_delete: Provider = { provide: 'ep:pages/delete', useClass: ep___pages_delete.default }; -const $pages_featured: Provider = { provide: 'ep:pages/featured', useClass: ep___pages_featured.default }; -const $pages_like: Provider = { provide: 'ep:pages/like', useClass: ep___pages_like.default }; -const $pages_show: Provider = { provide: 'ep:pages/show', useClass: ep___pages_show.default }; -const $pages_unlike: Provider = { provide: 'ep:pages/unlike', useClass: ep___pages_unlike.default }; -const $pages_update: Provider = { provide: 'ep:pages/update', useClass: ep___pages_update.default }; -const $flash_create: Provider = { provide: 'ep:flash/create', useClass: ep___flash_create.default }; -const $flash_delete: Provider = { provide: 'ep:flash/delete', useClass: ep___flash_delete.default }; -const $flash_featured: Provider = { provide: 'ep:flash/featured', useClass: ep___flash_featured.default }; -const $flash_like: Provider = { provide: 'ep:flash/like', useClass: ep___flash_like.default }; -const $flash_show: Provider = { provide: 'ep:flash/show', useClass: ep___flash_show.default }; -const $flash_unlike: Provider = { provide: 'ep:flash/unlike', useClass: ep___flash_unlike.default }; -const $flash_update: Provider = { provide: 'ep:flash/update', useClass: ep___flash_update.default }; -const $flash_my: Provider = { provide: 'ep:flash/my', useClass: ep___flash_my.default }; -const $flash_myLikes: Provider = { provide: 'ep:flash/my-likes', useClass: ep___flash_myLikes.default }; -const $ping: Provider = { provide: 'ep:ping', useClass: ep___ping.default }; -const $pinnedUsers: Provider = { provide: 'ep:pinned-users', useClass: ep___pinnedUsers.default }; -const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_read.default }; -const $roles_list: Provider = { provide: 'ep:roles/list', useClass: ep___roles_list.default }; -const $roles_show: Provider = { provide: 'ep:roles/show', useClass: ep___roles_show.default }; -const $roles_users: Provider = { provide: 'ep:roles/users', useClass: ep___roles_users.default }; -const $roles_notes: Provider = { provide: 'ep:roles/notes', useClass: ep___roles_notes.default }; -const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default }; -const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default }; -const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default }; -const $serverInfo: Provider = { provide: 'ep:server-info', useClass: ep___serverInfo.default }; -const $stats: Provider = { provide: 'ep:stats', useClass: ep___stats.default }; -const $sw_show_registration: Provider = { provide: 'ep:sw/show-registration', useClass: ep___sw_show_registration.default }; -const $sw_update_registration: Provider = { provide: 'ep:sw/update-registration', useClass: ep___sw_update_registration.default }; -const $sw_register: Provider = { provide: 'ep:sw/register', useClass: ep___sw_register.default }; -const $sw_unregister: Provider = { provide: 'ep:sw/unregister', useClass: ep___sw_unregister.default }; -const $test: Provider = { provide: 'ep:test', useClass: ep___test.default }; -const $username_available: Provider = { provide: 'ep:username/available', useClass: ep___username_available.default }; -const $users: Provider = { provide: 'ep:users', useClass: ep___users.default }; -const $users_clips: Provider = { provide: 'ep:users/clips', useClass: ep___users_clips.default }; -const $users_followers: Provider = { provide: 'ep:users/followers', useClass: ep___users_followers.default }; -const $users_following: Provider = { provide: 'ep:users/following', useClass: ep___users_following.default }; -const $users_gallery_posts: Provider = { provide: 'ep:users/gallery/posts', useClass: ep___users_gallery_posts.default }; -const $users_getFrequentlyRepliedUsers: Provider = { provide: 'ep:users/get-frequently-replied-users', useClass: ep___users_getFrequentlyRepliedUsers.default }; -const $users_featuredNotes: Provider = { provide: 'ep:users/featured-notes', useClass: ep___users_featuredNotes.default }; -const $users_lists_create: Provider = { provide: 'ep:users/lists/create', useClass: ep___users_lists_create.default }; -const $users_lists_delete: Provider = { provide: 'ep:users/lists/delete', useClass: ep___users_lists_delete.default }; -const $users_lists_list: Provider = { provide: 'ep:users/lists/list', useClass: ep___users_lists_list.default }; -const $users_lists_pull: Provider = { provide: 'ep:users/lists/pull', useClass: ep___users_lists_pull.default }; -const $users_lists_push: Provider = { provide: 'ep:users/lists/push', useClass: ep___users_lists_push.default }; -const $users_lists_show: Provider = { provide: 'ep:users/lists/show', useClass: ep___users_lists_show.default }; -const $users_lists_update: Provider = { provide: 'ep:users/lists/update', useClass: ep___users_lists_update.default }; -const $users_lists_favorite: Provider = { provide: 'ep:users/lists/favorite', useClass: ep___users_lists_favorite.default }; -const $users_lists_unfavorite: Provider = { provide: 'ep:users/lists/unfavorite', useClass: ep___users_lists_unfavorite.default }; -const $users_lists_createFromPublic: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_createFromPublic.default }; -const $users_lists_updateMembership: Provider = { provide: 'ep:users/lists/update-membership', useClass: ep___users_lists_updateMembership.default }; -const $users_lists_getMemberships: Provider = { provide: 'ep:users/lists/get-memberships', useClass: ep___users_lists_getMemberships.default }; -const $users_notes: Provider = { provide: 'ep:users/notes', useClass: ep___users_notes.default }; -const $users_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default }; -const $users_flashs: Provider = { provide: 'ep:users/flashs', useClass: ep___users_flashs.default }; -const $users_reactions: Provider = { provide: 'ep:users/reactions', useClass: ep___users_reactions.default }; -const $users_recommendation: Provider = { provide: 'ep:users/recommendation', useClass: ep___users_recommendation.default }; -const $users_relation: Provider = { provide: 'ep:users/relation', useClass: ep___users_relation.default }; -const $users_reportAbuse: Provider = { provide: 'ep:users/report-abuse', useClass: ep___users_reportAbuse.default }; -const $users_searchByUsernameAndHost: Provider = { provide: 'ep:users/search-by-username-and-host', useClass: ep___users_searchByUsernameAndHost.default }; -const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___users_search.default }; -const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default }; -const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default }; -const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default }; -const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default }; -const $fetchExternalResources: Provider = { provide: 'ep:fetch-external-resources', useClass: ep___fetchExternalResources.default }; -const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default }; -const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.default }; - -@Module({ - imports: [ - CoreModule, - ], - providers: [ - GetterService, - ApiLoggerService, - $admin_meta, - $admin_abuseUserReports, - $admin_accounts_create, - $admin_accounts_delete, - $admin_accounts_findByEmail, - $admin_ad_create, - $admin_ad_delete, - $admin_ad_list, - $admin_ad_update, - $admin_announcements_create, - $admin_announcements_delete, - $admin_announcements_list, - $admin_announcements_update, - $admin_avatarDecorations_create, - $admin_avatarDecorations_delete, - $admin_avatarDecorations_list, - $admin_avatarDecorations_update, - $admin_deleteAllFilesOfAUser, - $admin_unsetUserAvatar, - $admin_unsetUserBanner, - $admin_drive_cleanRemoteFiles, - $admin_drive_cleanup, - $admin_drive_files, - $admin_drive_showFile, - $admin_emoji_addAliasesBulk, - $admin_emoji_add, - $admin_emoji_copy, - $admin_emoji_deleteBulk, - $admin_emoji_delete, - $admin_emoji_importZip, - $admin_emoji_listRemote, - $admin_emoji_list, - $admin_emoji_removeAliasesBulk, - $admin_emoji_setAliasesBulk, - $admin_emoji_setCategoryBulk, - $admin_emoji_setLicenseBulk, - $admin_emoji_update, - $admin_federation_deleteAllFiles, - $admin_federation_refreshRemoteInstanceMetadata, - $admin_federation_removeAllFollowing, - $admin_federation_updateInstance, - $admin_getIndexStats, - $admin_getTableStats, - $admin_getUserIps, - $admin_invite_create, - $admin_invite_list, - $admin_promo_create, - $admin_queue_clear, - $admin_queue_deliverDelayed, - $admin_queue_inboxDelayed, - $admin_queue_promote, - $admin_queue_stats, - $admin_relays_add, - $admin_relays_list, - $admin_relays_remove, - $admin_resetPassword, - $admin_resolveAbuseUserReport, - $admin_sendEmail, - $admin_serverInfo, - $admin_showModerationLogs, - $admin_showUser, - $admin_showUsers, - $admin_nsfwUser, - $admin_unnsfwUser, - $admin_silenceUser, - $admin_unsilenceUser, - $admin_suspendUser, - $admin_approveUser, - $admin_unsuspendUser, - $admin_updateMeta, - $admin_deleteAccount, - $admin_updateUserNote, - $admin_roles_create, - $admin_roles_delete, - $admin_roles_list, - $admin_roles_show, - $admin_roles_update, - $admin_roles_assign, - $admin_roles_unassign, - $admin_roles_updateDefaultPolicies, - $admin_roles_users, - $announcements, - $antennas_create, - $antennas_delete, - $antennas_list, - $antennas_notes, - $antennas_show, - $antennas_update, - $ap_get, - $ap_show, - $app_create, - $app_show, - $auth_accept, - $auth_session_generate, - $auth_session_show, - $auth_session_userkey, - $blocking_create, - $blocking_delete, - $blocking_list, - $channels_create, - $channels_featured, - $channels_follow, - $channels_followed, - $channels_owned, - $channels_show, - $channels_timeline, - $channels_unfollow, - $channels_update, - $channels_favorite, - $channels_unfavorite, - $channels_myFavorites, - $channels_search, - $charts_activeUsers, - $charts_apRequest, - $charts_drive, - $charts_federation, - $charts_instance, - $charts_notes, - $charts_user_drive, - $charts_user_following, - $charts_user_notes, - $charts_user_pv, - $charts_user_reactions, - $charts_users, - $clips_addNote, - $clips_removeNote, - $clips_create, - $clips_delete, - $clips_list, - $clips_notes, - $clips_show, - $clips_update, - $clips_favorite, - $clips_unfavorite, - $clips_myFavorites, - $drive, - $drive_files, - $drive_files_attachedNotes, - $drive_files_checkExistence, - $drive_files_create, - $drive_files_delete, - $drive_files_findByHash, - $drive_files_find, - $drive_files_show, - $drive_files_update, - $drive_files_uploadFromUrl, - $drive_folders, - $drive_folders_create, - $drive_folders_delete, - $drive_folders_find, - $drive_folders_show, - $drive_folders_update, - $drive_stream, - $emailAddress_available, - $endpoint, - $endpoints, - $exportCustomEmojis, - $federation_followers, - $federation_following, - $federation_instances, - $federation_showInstance, - $federation_updateRemoteUser, - $federation_users, - $federation_stats, - $following_create, - $following_delete, - $following_update, - $following_update_all, - $following_invalidate, - $following_requests_accept, - $following_requests_cancel, - $following_requests_list, - $following_requests_reject, - $gallery_featured, - $gallery_popular, - $gallery_posts, - $gallery_posts_create, - $gallery_posts_delete, - $gallery_posts_like, - $gallery_posts_show, - $gallery_posts_unlike, - $gallery_posts_update, - $getOnlineUsersCount, - $getAvatarDecorations, - $hashtags_list, - $hashtags_search, - $hashtags_show, - $hashtags_trend, - $hashtags_users, - $i, - $i_2fa_done, - $i_2fa_keyDone, - $i_2fa_passwordLess, - $i_2fa_registerKey, - $i_2fa_register, - $i_2fa_updateKey, - $i_2fa_removeKey, - $i_2fa_unregister, - $i_apps, - $i_authorizedApps, - $i_claimAchievement, - $i_changePassword, - $i_deleteAccount, - $i_exportData, - $i_exportBlocking, - $i_exportFollowing, - $i_exportMute, - $i_exportNotes, - $i_exportFavorites, - $i_exportUserLists, - $i_exportAntennas, - $i_favorites, - $i_gallery_likes, - $i_gallery_posts, - $i_importBlocking, - $i_importFollowing, - $i_importNotes, - $i_importMuting, - $i_importUserLists, - $i_importAntennas, - $i_notifications, - $i_notificationsGrouped, - $i_pageLikes, - $i_pages, - $i_pin, - $i_readAllUnreadNotes, - $i_readAnnouncement, - $i_regenerateToken, - $i_registry_getAll, - $i_registry_getUnsecure, - $i_registry_getDetail, - $i_registry_get, - $i_registry_keysWithType, - $i_registry_keys, - $i_registry_remove, - $i_registry_scopesWithDomain, - $i_registry_set, - $i_revokeToken, - $i_signinHistory, - $i_unpin, - $i_updateEmail, - $i_update, - $i_move, - $i_webhooks_create, - $i_webhooks_list, - $i_webhooks_show, - $i_webhooks_update, - $i_webhooks_delete, - $invite_create, - $invite_delete, - $invite_list, - $invite_limit, - $meta, - $emojis, - $emoji, - $miauth_genToken, - $mute_create, - $mute_delete, - $mute_list, - $renoteMute_create, - $renoteMute_delete, - $renoteMute_list, - $my_apps, - $notes, - $notes_children, - $notes_clips, - $notes_conversation, - $notes_create, - $notes_delete, - $notes_favorites_create, - $notes_favorites_delete, - $notes_featured, - $notes_globalTimeline, - $notes_bubbleTimeline, - $notes_hybridTimeline, - $notes_localTimeline, - $notes_mentions, - $notes_polls_recommendation, - $notes_polls_vote, - $notes_reactions, - $notes_reactions_create, - $notes_reactions_delete, - $notes_like, - $notes_renotes, - $notes_replies, - $notes_searchByTag, - $notes_search, - $notes_show, - $notes_state, - $notes_threadMuting_create, - $notes_threadMuting_delete, - $notes_timeline, - $notes_translate, - $notes_unrenote, - $notes_userListTimeline, - $notes_edit, - $notes_versions, - $notifications_create, - $notifications_markAllAsRead, - $notifications_testNotification, - $pagePush, - $pages_create, - $pages_delete, - $pages_featured, - $pages_like, - $pages_show, - $pages_unlike, - $pages_update, - $flash_create, - $flash_delete, - $flash_featured, - $flash_like, - $flash_show, - $flash_unlike, - $flash_update, - $flash_my, - $flash_myLikes, - $ping, - $pinnedUsers, - $promo_read, - $roles_list, - $roles_show, - $roles_users, - $roles_notes, - $requestResetPassword, - $resetDb, - $resetPassword, - $serverInfo, - $stats, - $sw_show_registration, - $sw_update_registration, - $sw_register, - $sw_unregister, - $test, - $username_available, - $users, - $users_clips, - $users_followers, - $users_following, - $users_gallery_posts, - $users_getFrequentlyRepliedUsers, - $users_featuredNotes, - $users_lists_create, - $users_lists_delete, - $users_lists_list, - $users_lists_pull, - $users_lists_push, - $users_lists_show, - $users_lists_update, - $users_lists_favorite, - $users_lists_unfavorite, - $users_lists_createFromPublic, - $users_lists_updateMembership, - $users_lists_getMemberships, - $users_notes, - $users_pages, - $users_flashs, - $users_reactions, - $users_recommendation, - $users_relation, - $users_reportAbuse, - $users_searchByUsernameAndHost, - $users_search, - $users_show, - $users_achievements, - $users_updateMemo, - $fetchRss, - $fetchExternalResources, - $retention, - $sponsors, - ], - exports: [ - $admin_meta, - $admin_abuseUserReports, - $admin_accounts_create, - $admin_accounts_delete, - $admin_accounts_findByEmail, - $admin_ad_create, - $admin_ad_delete, - $admin_ad_list, - $admin_ad_update, - $admin_announcements_create, - $admin_announcements_delete, - $admin_announcements_list, - $admin_announcements_update, - $admin_avatarDecorations_create, - $admin_avatarDecorations_delete, - $admin_avatarDecorations_list, - $admin_avatarDecorations_update, - $admin_deleteAllFilesOfAUser, - $admin_unsetUserAvatar, - $admin_unsetUserBanner, - $admin_drive_cleanRemoteFiles, - $admin_drive_cleanup, - $admin_drive_files, - $admin_drive_showFile, - $admin_emoji_addAliasesBulk, - $admin_emoji_add, - $admin_emoji_copy, - $admin_emoji_deleteBulk, - $admin_emoji_delete, - $admin_emoji_importZip, - $admin_emoji_listRemote, - $admin_emoji_list, - $admin_emoji_removeAliasesBulk, - $admin_emoji_setAliasesBulk, - $admin_emoji_setCategoryBulk, - $admin_emoji_setLicenseBulk, - $admin_emoji_update, - $admin_federation_deleteAllFiles, - $admin_federation_refreshRemoteInstanceMetadata, - $admin_federation_removeAllFollowing, - $admin_federation_updateInstance, - $admin_getIndexStats, - $admin_getTableStats, - $admin_getUserIps, - $admin_invite_create, - $admin_invite_list, - $admin_promo_create, - $admin_queue_clear, - $admin_queue_deliverDelayed, - $admin_queue_inboxDelayed, - $admin_queue_promote, - $admin_queue_stats, - $admin_relays_add, - $admin_relays_list, - $admin_relays_remove, - $admin_resetPassword, - $admin_resolveAbuseUserReport, - $admin_sendEmail, - $admin_serverInfo, - $admin_showModerationLogs, - $admin_showUser, - $admin_showUsers, - $admin_nsfwUser, - $admin_unnsfwUser, - $admin_silenceUser, - $admin_unsilenceUser, - $admin_suspendUser, - $admin_approveUser, - $admin_unsuspendUser, - $admin_updateMeta, - $admin_deleteAccount, - $admin_updateUserNote, - $admin_roles_create, - $admin_roles_delete, - $admin_roles_list, - $admin_roles_show, - $admin_roles_update, - $admin_roles_assign, - $admin_roles_unassign, - $admin_roles_updateDefaultPolicies, - $admin_roles_users, - $announcements, - $antennas_create, - $antennas_delete, - $antennas_list, - $antennas_notes, - $antennas_show, - $antennas_update, - $ap_get, - $ap_show, - $app_create, - $app_show, - $auth_accept, - $auth_session_generate, - $auth_session_show, - $auth_session_userkey, - $blocking_create, - $blocking_delete, - $blocking_list, - $channels_create, - $channels_featured, - $channels_follow, - $channels_followed, - $channels_owned, - $channels_show, - $channels_timeline, - $channels_unfollow, - $channels_update, - $channels_favorite, - $channels_unfavorite, - $channels_myFavorites, - $channels_search, - $charts_activeUsers, - $charts_apRequest, - $charts_drive, - $charts_federation, - $charts_instance, - $charts_notes, - $charts_user_drive, - $charts_user_following, - $charts_user_notes, - $charts_user_pv, - $charts_user_reactions, - $charts_users, - $clips_addNote, - $clips_removeNote, - $clips_create, - $clips_delete, - $clips_list, - $clips_notes, - $clips_show, - $clips_update, - $clips_favorite, - $clips_unfavorite, - $clips_myFavorites, - $drive, - $drive_files, - $drive_files_attachedNotes, - $drive_files_checkExistence, - $drive_files_create, - $drive_files_delete, - $drive_files_findByHash, - $drive_files_find, - $drive_files_show, - $drive_files_update, - $drive_files_uploadFromUrl, - $drive_folders, - $drive_folders_create, - $drive_folders_delete, - $drive_folders_find, - $drive_folders_show, - $drive_folders_update, - $drive_stream, - $emailAddress_available, - $endpoint, - $endpoints, - $exportCustomEmojis, - $federation_followers, - $federation_following, - $federation_instances, - $federation_showInstance, - $federation_updateRemoteUser, - $federation_users, - $federation_stats, - $following_create, - $following_delete, - $following_update, - $following_update_all, - $following_invalidate, - $following_requests_accept, - $following_requests_cancel, - $following_requests_list, - $following_requests_reject, - $gallery_featured, - $gallery_popular, - $gallery_posts, - $gallery_posts_create, - $gallery_posts_delete, - $gallery_posts_like, - $gallery_posts_show, - $gallery_posts_unlike, - $gallery_posts_update, - $getOnlineUsersCount, - $getAvatarDecorations, - $hashtags_list, - $hashtags_search, - $hashtags_show, - $hashtags_trend, - $hashtags_users, - $i, - $i_2fa_done, - $i_2fa_keyDone, - $i_2fa_passwordLess, - $i_2fa_registerKey, - $i_2fa_register, - $i_2fa_updateKey, - $i_2fa_removeKey, - $i_2fa_unregister, - $i_apps, - $i_authorizedApps, - $i_claimAchievement, - $i_changePassword, - $i_deleteAccount, - $i_exportData, - $i_exportBlocking, - $i_exportFollowing, - $i_exportMute, - $i_exportNotes, - $i_exportFavorites, - $i_exportUserLists, - $i_exportAntennas, - $i_favorites, - $i_gallery_likes, - $i_gallery_posts, - $i_importBlocking, - $i_importFollowing, - $i_importNotes, - $i_importMuting, - $i_importUserLists, - $i_importAntennas, - $i_notifications, - $i_notificationsGrouped, - $i_pageLikes, - $i_pages, - $i_pin, - $i_readAllUnreadNotes, - $i_readAnnouncement, - $i_regenerateToken, - $i_registry_getAll, - $i_registry_getUnsecure, - $i_registry_getDetail, - $i_registry_get, - $i_registry_keysWithType, - $i_registry_keys, - $i_registry_remove, - $i_registry_scopesWithDomain, - $i_registry_set, - $i_revokeToken, - $i_signinHistory, - $i_unpin, - $i_updateEmail, - $i_update, - $i_move, - $i_webhooks_create, - $i_webhooks_list, - $i_webhooks_show, - $i_webhooks_update, - $i_webhooks_delete, - $invite_create, - $invite_delete, - $invite_list, - $invite_limit, - $meta, - $emojis, - $emoji, - $miauth_genToken, - $mute_create, - $mute_delete, - $mute_list, - $renoteMute_create, - $renoteMute_delete, - $renoteMute_list, - $my_apps, - $notes, - $notes_children, - $notes_clips, - $notes_conversation, - $notes_create, - $notes_delete, - $notes_favorites_create, - $notes_favorites_delete, - $notes_featured, - $notes_globalTimeline, - $notes_bubbleTimeline, - $notes_hybridTimeline, - $notes_localTimeline, - $notes_mentions, - $notes_polls_recommendation, - $notes_polls_vote, - $notes_reactions, - $notes_reactions_create, - $notes_reactions_delete, - $notes_like, - $notes_renotes, - $notes_replies, - $notes_searchByTag, - $notes_search, - $notes_show, - $notes_state, - $notes_threadMuting_create, - $notes_threadMuting_delete, - $notes_timeline, - $notes_translate, - $notes_unrenote, - $notes_userListTimeline, - $notes_edit, - $notes_versions, - $notifications_create, - $notifications_markAllAsRead, - $pagePush, - $pages_create, - $pages_delete, - $pages_featured, - $pages_like, - $pages_show, - $pages_unlike, - $pages_update, - $flash_create, - $flash_delete, - $flash_featured, - $flash_like, - $flash_show, - $flash_unlike, - $flash_update, - $flash_my, - $flash_myLikes, - $ping, - $pinnedUsers, - $promo_read, - $roles_list, - $roles_show, - $roles_users, - $roles_notes, - $requestResetPassword, - $resetDb, - $resetPassword, - $serverInfo, - $stats, - $sw_register, - $sw_unregister, - $test, - $username_available, - $users, - $users_clips, - $users_followers, - $users_following, - $users_gallery_posts, - $users_getFrequentlyRepliedUsers, - $users_featuredNotes, - $users_lists_create, - $users_lists_delete, - $users_lists_list, - $users_lists_pull, - $users_lists_push, - $users_lists_show, - $users_lists_update, - $users_lists_favorite, - $users_lists_unfavorite, - $users_lists_createFromPublic, - $users_lists_updateMembership, - $users_lists_getMemberships, - $users_notes, - $users_pages, - $users_flashs, - $users_reactions, - $users_recommendation, - $users_relation, - $users_reportAbuse, - $users_searchByUsernameAndHost, - $users_search, - $users_show, - $users_achievements, - $users_updateMemo, - $fetchRss, - $fetchExternalResources, - $retention, - $sponsors, - ], -}) -export class EndpointsModule {} diff --git a/packages/backend/src/server/api/GetterService.ts b/packages/backend/src/server/api/GetterService.ts deleted file mode 100644 index 2616cbb761..0000000000 --- a/packages/backend/src/server/api/GetterService.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { NotesRepository, UsersRepository, NoteEditRepository } from '@/models/_.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; -import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; -import type { MiNote } from '@/models/Note.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class GetterService { - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.noteEditRepository) - private noteEditRepository: NoteEditRepository, - - private userEntityService: UserEntityService, - ) { - } - - /** - * Get note for API processing - */ - @bindThis - public async getNote(noteId: MiNote['id']) { - const note = await this.notesRepository.findOneBy({ id: noteId }); - - if (note == null) { - throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.'); - } - - return note; - } - - /** - * Get note for API processing - */ - @bindThis - public async getEdits(noteId: MiNote['id']) { - const edits = await this.noteEditRepository.findBy({ noteId: noteId }).catch(() => { - throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.'); - }); - - return edits; - } - - /** - * Get user for API processing - */ - @bindThis - public async getUser(userId: MiUser['id']) { - const user = await this.usersRepository.findOneBy({ id: userId }); - - if (user == null) { - throw new IdentifiableError('15348ddd-432d-49c2-8a5a-8069753becff', 'No such user.'); - } - - return user as MiLocalUser | MiRemoteUser; - } - - /** - * Get remote user for API processing - */ - @bindThis - public async getRemoteUser(userId: MiUser['id']) { - const user = await this.getUser(userId); - - if (!this.userEntityService.isRemoteUser(user)) { - throw new Error('user is not a remote user'); - } - - return user; - } - - /** - * Get local user for API processing - */ - @bindThis - public async getLocalUser(userId: MiUser['id']) { - const user = await this.getUser(userId); - - if (!this.userEntityService.isLocalUser(user)) { - throw new Error('user is not a local user'); - } - - return user; - } -} - diff --git a/packages/backend/src/server/api/RateLimiterService.ts b/packages/backend/src/server/api/RateLimiterService.ts deleted file mode 100644 index 0e644aa091..0000000000 --- a/packages/backend/src/server/api/RateLimiterService.ts +++ /dev/null @@ -1,105 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import Limiter from 'ratelimiter'; -import * as Redis from 'ioredis'; -import { DI } from '@/di-symbols.js'; -import type Logger from '@/logger.js'; -import { LoggerService } from '@/core/LoggerService.js'; -import { bindThis } from '@/decorators.js'; -import type { IEndpointMeta } from './endpoints.js'; - -@Injectable() -export class RateLimiterService { - private logger: Logger; - private disabled = false; - - constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, - - private loggerService: LoggerService, - ) { - this.logger = this.loggerService.getLogger('limiter'); - - if (process.env.NODE_ENV !== 'production') { - this.disabled = true; - } - } - - @bindThis - public limit(limitation: IEndpointMeta['limit'] & { key: NonNullable }, actor: string, factor = 1) { - return new Promise((ok, reject) => { - if (this.disabled) ok(); - - // Short-term limit - const min = (): void => { - const minIntervalLimiter = new Limiter({ - id: `${actor}:${limitation.key}:min`, - duration: limitation.minInterval! * factor, - max: 1, - db: this.redisClient, - }); - - minIntervalLimiter.get((err, info) => { - if (err) { - return reject('ERR'); - } - - this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`); - - if (info.remaining === 0) { - reject('BRIEF_REQUEST_INTERVAL'); - } else { - if (hasLongTermLimit) { - max(); - } else { - ok(); - } - } - }); - }; - - // Long term limit - const max = (): void => { - const limiter = new Limiter({ - id: `${actor}:${limitation.key}`, - duration: limitation.duration! * factor, - max: limitation.max! / factor, - db: this.redisClient, - }); - - limiter.get((err, info) => { - if (err) { - return reject('ERR'); - } - - this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`); - - if (info.remaining === 0) { - reject('RATE_LIMIT_EXCEEDED'); - } else { - ok(); - } - }); - }; - - const hasShortTermLimit = typeof limitation.minInterval === 'number'; - - const hasLongTermLimit = - typeof limitation.duration === 'number' && - typeof limitation.max === 'number'; - - if (hasShortTermLimit) { - min(); - } else if (hasLongTermLimit) { - max(); - } else { - ok(); - } - }); - } -} diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts deleted file mode 100644 index fd247df22a..0000000000 --- a/packages/backend/src/server/api/SigninApiService.ts +++ /dev/null @@ -1,230 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import bcrypt from 'bcryptjs'; -import * as argon2 from 'argon2'; -import * as OTPAuth from 'otpauth'; -import { IsNull } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { - SigninsRepository, - UserProfilesRepository, - UsersRepository, -} from '@/models/_.js'; -import type { Config } from '@/config.js'; -import { getIpHash } from '@/misc/get-ip-hash.js'; -import type { MiLocalUser } from '@/models/User.js'; -import { IdService } from '@/core/IdService.js'; -import { bindThis } from '@/decorators.js'; -import { WebAuthnService } from '@/core/WebAuthnService.js'; -import { UserAuthService } from '@/core/UserAuthService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { RateLimiterService } from './RateLimiterService.js'; -import { SigninService } from './SigninService.js'; -import type { AuthenticationResponseJSON } from '@simplewebauthn/typescript-types'; -import type { FastifyReply, FastifyRequest } from 'fastify'; - -@Injectable() -export class SigninApiService { - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.signinsRepository) - private signinsRepository: SigninsRepository, - - private idService: IdService, - private rateLimiterService: RateLimiterService, - private signinService: SigninService, - private userAuthService: UserAuthService, - private webAuthnService: WebAuthnService, - private metaService: MetaService, - ) { - } - - @bindThis - public async signin( - request: FastifyRequest<{ - Body: { - username: string; - password: string; - token?: string; - credential?: AuthenticationResponseJSON; - }; - }>, - reply: FastifyReply, - ) { - reply.header('Access-Control-Allow-Origin', this.config.url); - reply.header('Access-Control-Allow-Credentials', 'true'); - - const instance = await this.metaService.fetch(true); - - const body = request.body; - const username = body['username']; - const password = body['password']; - const token = body['token']; - - function error(status: number, error: { id: string }) { - reply.code(status); - return { error }; - } - - try { - // not more than 1 attempt per second and not more than 10 attempts per hour - await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip)); - } catch (err) { - reply.code(429); - return { - error: { - message: 'Too many failed attempts to sign in. Try again later.', - code: 'TOO_MANY_AUTHENTICATION_FAILURES', - id: '22d05606-fbcf-421a-a2db-b32610dcfd1b', - }, - }; - } - - if (typeof username !== 'string') { - reply.code(400); - return; - } - - if (typeof password !== 'string') { - reply.code(400); - return; - } - - if (token != null && typeof token !== 'string') { - reply.code(400); - return; - } - - // Fetch user - const user = await this.usersRepository.findOneBy({ - usernameLower: username.toLowerCase(), - host: IsNull(), - }) as MiLocalUser; - - if (user == null) { - return error(404, { - id: '6cc579cc-885d-43d8-95c2-b8c7fc963280', - }); - } - - if (user.isSuspended) { - return error(403, { - id: 'e03a5f46-d309-4865-9b69-56282d94e1eb', - }); - } - - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - - if (!user.approved && instance.approvalRequiredForSignup) { - reply.code(403); - return { - error: { - message: 'The account has not been approved by an admin yet. Try again later.', - code: 'NOT_APPROVED', - id: '22d05606-fbcf-421a-a2db-b32241faft1b', - }, - }; - } - - // Compare password - const same = await argon2.verify(profile.password!, password) || bcrypt.compareSync(password, profile.password!); - - const fail = async (status?: number, failure?: { id: string }) => { - // Append signin history - await this.signinsRepository.insert({ - id: this.idService.gen(), - userId: user.id, - ip: request.ip, - headers: request.headers as any, - success: false, - }); - - return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' }); - }; - - if (!profile.twoFactorEnabled) { - if (same) { - if (profile.password!.startsWith('$2')) { - const newHash = await argon2.hash(password); - this.userProfilesRepository.update(user.id, { - password: newHash - }); - } - if (!instance.approvalRequiredForSignup && !user.approved) this.usersRepository.update(user.id, { approved: true }); - - return this.signinService.signin(request, reply, user); - } else { - return await fail(403, { - id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', - }); - } - } - - if (token) { - if (!same) { - return await fail(403, { - id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', - }); - } - - try { - if (profile.password!.startsWith('$2')) { - const newHash = await argon2.hash(password); - this.userProfilesRepository.update(user.id, { - password: newHash - }); - } - await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { - return await fail(403, { - id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f', - }); - } - - if (!instance.approvalRequiredForSignup && !user.approved) this.usersRepository.update(user.id, { approved: true }); - - return this.signinService.signin(request, reply, user); - } else if (body.credential) { - if (!same && !profile.usePasswordLessLogin) { - return await fail(403, { - id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', - }); - } - - const authorized = await this.webAuthnService.verifyAuthentication(user.id, body.credential); - - if (authorized) { - if (!instance.approvalRequiredForSignup && !user.approved) this.usersRepository.update(user.id, { approved: true }); - return this.signinService.signin(request, reply, user); - } else { - return await fail(403, { - id: '93b86c4b-72f9-40eb-9815-798928603d1e', - }); - } - } else { - if (!same && !profile.usePasswordLessLogin) { - return await fail(403, { - id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', - }); - } - - const authRequest = await this.webAuthnService.initiateAuthentication(user.id); - - reply.code(200); - return authRequest; - } - // never get here - } -} diff --git a/packages/backend/src/server/api/SigninService.ts b/packages/backend/src/server/api/SigninService.ts deleted file mode 100644 index 98e9027006..0000000000 --- a/packages/backend/src/server/api/SigninService.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { SigninsRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import type { MiLocalUser } from '@/models/User.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { SigninEntityService } from '@/core/entities/SigninEntityService.js'; -import { bindThis } from '@/decorators.js'; -import type { FastifyRequest, FastifyReply } from 'fastify'; - -@Injectable() -export class SigninService { - constructor( - @Inject(DI.signinsRepository) - private signinsRepository: SigninsRepository, - - private signinEntityService: SigninEntityService, - private idService: IdService, - private globalEventService: GlobalEventService, - ) { - } - - @bindThis - public signin(request: FastifyRequest, reply: FastifyReply, user: MiLocalUser) { - setImmediate(async () => { - // Append signin history - const record = await this.signinsRepository.insert({ - id: this.idService.gen(), - userId: user.id, - ip: request.ip, - headers: request.headers as any, - success: true, - }).then(x => this.signinsRepository.findOneByOrFail(x.identifiers[0])); - - // Publish signin event - this.globalEventService.publishMainStream(user.id, 'signin', await this.signinEntityService.pack(record)); - }); - - reply.code(200); - return { - id: user.id, - i: user.token, - }; - } -} - diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts deleted file mode 100644 index 63379c8878..0000000000 --- a/packages/backend/src/server/api/SignupApiService.ts +++ /dev/null @@ -1,347 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -//import bcrypt from 'bcryptjs'; -import * as argon2 from 'argon2'; -import { IsNull } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, MiRegistrationTicket } from '@/models/_.js'; -import type { Config } from '@/config.js'; -import { MetaService } from '@/core/MetaService.js'; -import { CaptchaService } from '@/core/CaptchaService.js'; -import { IdService } from '@/core/IdService.js'; -import { SignupService } from '@/core/SignupService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { EmailService } from '@/core/EmailService.js'; -import { MiLocalUser } from '@/models/User.js'; -import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; -import { bindThis } from '@/decorators.js'; -import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js'; -import { SigninService } from './SigninService.js'; -import type { FastifyRequest, FastifyReply } from 'fastify'; -import instance from './endpoints/charts/instance.js'; -import { RoleService } from '@/core/RoleService.js'; - -@Injectable() -export class SignupApiService { - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.userPendingsRepository) - private userPendingsRepository: UserPendingsRepository, - - @Inject(DI.usedUsernamesRepository) - private usedUsernamesRepository: UsedUsernamesRepository, - - @Inject(DI.registrationTicketsRepository) - private registrationTicketsRepository: RegistrationTicketsRepository, - - private userEntityService: UserEntityService, - private idService: IdService, - private metaService: MetaService, - private captchaService: CaptchaService, - private signupService: SignupService, - private signinService: SigninService, - private emailService: EmailService, - private roleService: RoleService, - ) { - } - - @bindThis - public async signup( - request: FastifyRequest<{ - Body: { - username: string; - password: string; - host?: string; - invitationCode?: string; - emailAddress?: string; - reason?: string; - 'hcaptcha-response'?: string; - 'g-recaptcha-response'?: string; - 'turnstile-response'?: string; - } - }>, - reply: FastifyReply, - ) { - const body = request.body; - - const instance = await this.metaService.fetch(true); - - // Verify *Captcha - // ただしテスト時はこの機構は障害となるため無効にする - if (process.env.NODE_ENV !== 'test') { - if (instance.enableHcaptcha && instance.hcaptchaSecretKey) { - await this.captchaService.verifyHcaptcha(instance.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => { - throw new FastifyReplyError(400, err); - }); - } - - if (instance.enableRecaptcha && instance.recaptchaSecretKey) { - await this.captchaService.verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { - throw new FastifyReplyError(400, err); - }); - } - - if (instance.enableTurnstile && instance.turnstileSecretKey) { - await this.captchaService.verifyTurnstile(instance.turnstileSecretKey, body['turnstile-response']).catch(err => { - throw new FastifyReplyError(400, err); - }); - } - } - - const username = body['username']; - const password = body['password']; - const host: string | null = process.env.NODE_ENV === 'test' ? (body['host'] ?? null) : null; - const invitationCode = body['invitationCode']; - const reason = body['reason']; - const emailAddress = body['emailAddress']; - - if (instance.emailRequiredForSignup) { - if (emailAddress == null || typeof emailAddress !== 'string') { - reply.code(400); - return; - } - - const res = await this.emailService.validateEmailForAccount(emailAddress); - if (!res.available) { - reply.code(400); - return; - } - } - - if (instance.approvalRequiredForSignup) { - if (reason == null || typeof reason !== 'string') { - reply.code(400); - return; - } - } - - let ticket: MiRegistrationTicket | null = null; - - if (instance.disableRegistration) { - if (invitationCode == null || typeof invitationCode !== 'string') { - reply.code(400); - return; - } - - ticket = await this.registrationTicketsRepository.findOneBy({ - code: invitationCode, - }); - - if (ticket == null || ticket.usedById != null) { - reply.code(400); - return; - } - - if (ticket.expiresAt && ticket.expiresAt < new Date()) { - reply.code(400); - return; - } - - // メアド認証が有効の場合 - if (instance.emailRequiredForSignup) { - // メアド認証済みならエラー - if (ticket.usedBy) { - reply.code(400); - return; - } - - // 認証しておらず、メール送信から30分以内ならエラー - if (ticket.usedAt && ticket.usedAt.getTime() + (1000 * 60 * 30) > Date.now()) { - reply.code(400); - return; - } - } else if (ticket.usedAt) { - reply.code(400); - return; - } - } - - if (instance.emailRequiredForSignup) { - if (await this.usersRepository.exist({ where: { usernameLower: username.toLowerCase(), host: IsNull() } })) { - throw new FastifyReplyError(400, 'DUPLICATED_USERNAME'); - } - - // Check deleted username duplication - if (await this.usedUsernamesRepository.exist({ where: { username: username.toLowerCase() } })) { - throw new FastifyReplyError(400, 'USED_USERNAME'); - } - - const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); - if (isPreserved) { - throw new FastifyReplyError(400, 'DENIED_USERNAME'); - } - - const code = secureRndstr(16, { chars: L_CHARS }); - - // Generate hash of password - //const salt = await bcrypt.genSalt(8); - const hash = await argon2.hash(password); - - const pendingUser = await this.userPendingsRepository.insert({ - id: this.idService.gen(), - code, - email: emailAddress!, - username: username, - password: hash, - reason: reason, - }).then(x => this.userPendingsRepository.findOneByOrFail(x.identifiers[0])); - - const link = `${this.config.url}/signup-complete/${code}`; - - this.emailService.sendEmail(emailAddress!, 'Signup', - `To complete signup, please click this link:
${link}`, - `To complete signup, please click this link: ${link}`); - - if (ticket) { - await this.registrationTicketsRepository.update(ticket.id, { - usedAt: new Date(), - pendingUserId: pendingUser.id, - }); - } - - reply.code(204); - return; - } else if (instance.approvalRequiredForSignup) { - const { account } = await this.signupService.signup({ - username, password, host, reason, - }); - - if (emailAddress) { - this.emailService.sendEmail(emailAddress, 'Approval pending', - 'Congratulations! Your account is now pending approval. You will get notified when you have been accepted.', - 'Congratulations! Your account is now pending approval. You will get notified when you have been accepted.'); - } - - if (ticket) { - await this.registrationTicketsRepository.update(ticket.id, { - usedAt: new Date(), - usedBy: account, - usedById: account.id, - }); - } - - const moderators = await this.roleService.getModerators(); - - for (const moderator of moderators) { - const profile = await this.userProfilesRepository.findOneBy({ userId: moderator.id }); - - if (profile?.email) { - this.emailService.sendEmail(profile.email, 'New user awaiting approval', - `A new user called ${account.username} is awaiting approval with the following reason: "${reason}"`, - `A new user called ${account.username} is awaiting approval with the following reason: "${reason}"`); - } - } - - reply.code(204); - return; - } else { - try { - const { account, secret } = await this.signupService.signup({ - username, password, host, - }); - - const res = await this.userEntityService.pack(account, account, { - detail: true, - includeSecrets: true, - }); - - if (ticket) { - await this.registrationTicketsRepository.update(ticket.id, { - usedAt: new Date(), - usedBy: account, - usedById: account.id, - }); - } - - return { - ...res, - token: secret, - }; - } catch (err) { - throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString()); - } - } - } - - @bindThis - public async signupPending(request: FastifyRequest<{ Body: { code: string; } }>, reply: FastifyReply) { - const body = request.body; - - const code = body['code']; - - const instance = await this.metaService.fetch(true); - - try { - const pendingUser = await this.userPendingsRepository.findOneByOrFail({ code }); - - if (this.idService.parse(pendingUser.id).date.getTime() + (1000 * 60 * 30) < Date.now()) { - throw new FastifyReplyError(400, 'EXPIRED'); - } - - const { account, secret } = await this.signupService.signup({ - username: pendingUser.username, - passwordHash: pendingUser.password, - reason: pendingUser.reason, - }); - - this.userPendingsRepository.delete({ - id: pendingUser.id, - }); - - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: account.id }); - - await this.userProfilesRepository.update({ userId: profile.userId }, { - email: pendingUser.email, - emailVerified: true, - emailVerifyCode: null, - }); - - const ticket = await this.registrationTicketsRepository.findOneBy({ pendingUserId: pendingUser.id }); - if (ticket) { - await this.registrationTicketsRepository.update(ticket.id, { - usedBy: account, - usedById: account.id, - pendingUserId: null, - }); - } - - if (instance.approvalRequiredForSignup) { - if (pendingUser.email) { - this.emailService.sendEmail(pendingUser.email, 'Approval pending', - 'Congratulations! Your account is now pending approval. You will get notified when you have been accepted.', - 'Congratulations! Your account is now pending approval. You will get notified when you have been accepted.'); - } - - const moderators = await this.roleService.getModerators(); - - for (const moderator of moderators) { - const profile = await this.userProfilesRepository.findOneBy({ userId: moderator.id }); - - if (profile?.email) { - this.emailService.sendEmail(profile.email, 'New user awaiting approval', - `A new user called ${pendingUser.username} is awaiting approval with the following reason: "${pendingUser.reason}"`, - `A new user called ${pendingUser.username} is awaiting approval with the following reason: "${pendingUser.reason}"`); - } - } - - return { pendingApproval: true }; - } - - return this.signinService.signin(request, reply, account as MiLocalUser); - } catch (err) { - throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString()); - } - } -} diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts deleted file mode 100644 index 3b387d92ca..0000000000 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ /dev/null @@ -1,185 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { EventEmitter } from 'events'; -import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import * as WebSocket from 'ws'; -import { DI } from '@/di-symbols.js'; -import type { UsersRepository, MiAccessToken } from '@/models/_.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; -import { NotificationService } from '@/core/NotificationService.js'; -import { bindThis } from '@/decorators.js'; -import { CacheService } from '@/core/CacheService.js'; -import { MiLocalUser } from '@/models/User.js'; -import { UserService } from '@/core/UserService.js'; -import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; -import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; -import MainStreamConnection from './stream/Connection.js'; -import { ChannelsService } from './stream/ChannelsService.js'; -import type * as http from 'node:http'; - -@Injectable() -export class StreamingApiServerService { - #wss: WebSocket.WebSocketServer; - #connections = new Map(); - #cleanConnectionsIntervalId: NodeJS.Timeout | null = null; - - constructor( - @Inject(DI.redisForSub) - private redisForSub: Redis.Redis, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private cacheService: CacheService, - private noteReadService: NoteReadService, - private authenticateService: AuthenticateService, - private channelsService: ChannelsService, - private notificationService: NotificationService, - private usersService: UserService, - private channelFollowingService: ChannelFollowingService, - ) { - } - - @bindThis - public attach(server: http.Server): void { - this.#wss = new WebSocket.WebSocketServer({ - noServer: true, - }); - - server.on('upgrade', async (request, socket, head) => { - if (request.url == null) { - socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); - socket.destroy(); - return; - } - - const q = new URL(request.url, `http://${request.headers.host}`).searchParams; - - let user: MiLocalUser | null = null; - let app: MiAccessToken | null = null; - - // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 - // Note that the standard WHATWG WebSocket API does not support setting any headers, - // but non-browser apps may still be able to set it. - const token = request.headers.authorization?.startsWith('Bearer ') - ? request.headers.authorization.slice(7) - : q.get('i'); - - try { - [user, app] = await this.authenticateService.authenticate(token); - - if (app !== null && !app.permission.some(p => p === 'read:account')) { - throw new AuthenticationError('Your app does not have necessary permissions to use websocket API.'); - } - } catch (e) { - if (e instanceof AuthenticationError) { - socket.write([ - 'HTTP/1.1 401 Unauthorized', - 'WWW-Authenticate: Bearer realm="Misskey", error="invalid_token", error_description="Failed to authenticate"', - ].join('\r\n') + '\r\n\r\n'); - } else { - socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n'); - } - socket.destroy(); - return; - } - - if (user?.isSuspended) { - socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); - socket.destroy(); - return; - } - - const stream = new MainStreamConnection( - this.channelsService, - this.noteReadService, - this.notificationService, - this.cacheService, - this.channelFollowingService, - user, app, - ); - - await stream.init(); - - this.#wss.handleUpgrade(request, socket, head, (ws) => { - this.#wss.emit('connection', ws, request, { - stream, user, app, - }); - }); - }); - - const globalEv = new EventEmitter(); - - this.redisForSub.on('message', (_: string, data: string) => { - const parsed = JSON.parse(data); - globalEv.emit('message', parsed); - }); - - this.#wss.on('connection', async (connection: WebSocket.WebSocket, request: http.IncomingMessage, ctx: { - stream: MainStreamConnection, - user: MiLocalUser | null; - app: MiAccessToken | null - }) => { - const { stream, user, app } = ctx; - - const ev = new EventEmitter(); - - function onRedisMessage(data: any): void { - ev.emit(data.channel, data.message); - } - - globalEv.on('message', onRedisMessage); - - await stream.listen(ev, connection); - - this.#connections.set(connection, Date.now()); - - const userUpdateIntervalId = user ? setInterval(() => { - this.usersService.updateLastActiveDate(user); - }, 1000 * 60 * 5) : null; - if (user) { - this.usersService.updateLastActiveDate(user); - } - - connection.once('close', () => { - ev.removeAllListeners(); - stream.dispose(); - globalEv.off('message', onRedisMessage); - this.#connections.delete(connection); - if (userUpdateIntervalId) clearInterval(userUpdateIntervalId); - }); - - connection.on('pong', () => { - this.#connections.set(connection, Date.now()); - }); - }); - - // 一定期間通信が無いコネクションは実際には切断されている可能性があるため定期的にterminateする - this.#cleanConnectionsIntervalId = setInterval(() => { - const now = Date.now(); - for (const [connection, lastActive] of this.#connections.entries()) { - if (now - lastActive > 1000 * 60 * 2) { - connection.terminate(); - this.#connections.delete(connection); - } else { - connection.ping(); - } - } - }, 1000 * 60); - } - - @bindThis - public detach(): Promise { - if (this.#cleanConnectionsIntervalId) { - clearInterval(this.#cleanConnectionsIntervalId); - this.#cleanConnectionsIntervalId = null; - } - return new Promise((resolve) => { - this.#wss.close(() => resolve()); - }); - } -} diff --git a/packages/backend/src/server/api/api-handler.ts b/packages/backend/src/server/api/api-handler.ts new file mode 100644 index 0000000000..ec71ddd2c0 --- /dev/null +++ b/packages/backend/src/server/api/api-handler.ts @@ -0,0 +1,92 @@ +import Koa from 'koa'; + +import { User } from '@/models/entities/user.js'; +import { UserIps } from '@/models/index.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { IEndpoint } from './endpoints.js'; +import authenticate, { AuthenticationError } from './authenticate.js'; +import call from './call.js'; +import { ApiError } from './error.js'; + +const userIpHistories = new Map>(); + +setInterval(() => { + userIpHistories.clear(); +}, 1000 * 60 * 60); + +export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise((res) => { + const body = ctx.is('multipart/form-data') + ? (ctx.request as any).body + : ctx.method === 'GET' + ? ctx.query + : ctx.request.body; + + const reply = (x?: any, y?: ApiError) => { + if (x == null) { + ctx.status = 204; + } else if (typeof x === 'number' && y) { + ctx.status = x; + ctx.body = { + error: { + message: y!.message, + code: y!.code, + id: y!.id, + kind: y!.kind, + ...(y!.info ? { info: y!.info } : {}), + }, + }; + } else { + // 文字列を返す場合は、JSON.stringify通さないとJSONと認識されない + ctx.body = typeof x === 'string' ? JSON.stringify(x) : x; + } + res(); + }; + + // Authentication + authenticate(body['i']).then(([user, app]) => { + // API invoking + call(endpoint.name, user, app, body, ctx).then((res: any) => { + if (ctx.method === 'GET' && endpoint.meta.cacheSec && !body['i'] && !user) { + ctx.set('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`); + } + reply(res); + }).catch((e: ApiError) => { + reply(e.httpStatusCode ? e.httpStatusCode : e.kind === 'client' ? 400 : 500, e); + }); + + // Log IP + if (user) { + fetchMeta().then(meta => { + if (!meta.enableIpLogging) return; + const ip = ctx.ip; + const ips = userIpHistories.get(user.id); + if (ips == null || !ips.has(ip)) { + if (ips == null) { + userIpHistories.set(user.id, new Set([ip])); + } else { + ips.add(ip); + } + + try { + UserIps.createQueryBuilder().insert().values({ + createdAt: new Date(), + userId: user.id, + ip: ip, + }).orIgnore(true).execute(); + } catch { + } + } + }); + } + }).catch(e => { + if (e instanceof AuthenticationError) { + reply(403, new ApiError({ + message: 'Authentication failed. Please ensure your token is correct.', + code: 'AUTHENTICATION_FAILED', + id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', + })); + } else { + reply(500, new ApiError()); + } + }); +}); diff --git a/packages/backend/src/server/api/authenticate.ts b/packages/backend/src/server/api/authenticate.ts new file mode 100644 index 0000000000..65ccfcf551 --- /dev/null +++ b/packages/backend/src/server/api/authenticate.ts @@ -0,0 +1,66 @@ +import isNativeToken from './common/is-native-token.js'; +import { CacheableLocalUser, ILocalUser } from '@/models/entities/user.js'; +import { Users, AccessTokens, Apps } from '@/models/index.js'; +import { AccessToken } from '@/models/entities/access-token.js'; +import { Cache } from '@/misc/cache.js'; +import { App } from '@/models/entities/app.js'; +import { localUserByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js'; + +const appCache = new Cache(Infinity); + +export class AuthenticationError extends Error { + constructor(message: string) { + super(message); + this.name = 'AuthenticationError'; + } +} + +export default async (token: string | null): Promise<[CacheableLocalUser | null | undefined, AccessToken | null | undefined]> => { + if (token == null) { + return [null, null]; + } + + if (isNativeToken(token)) { + const user = await localUserByNativeTokenCache.fetch(token, + () => Users.findOneBy({ token }) as Promise); + + if (user == null) { + throw new AuthenticationError('user not found'); + } + + return [user, null]; + } else { + const accessToken = await AccessTokens.findOne({ + where: [{ + hash: token.toLowerCase(), // app + }, { + token: token, // miauth + }], + }); + + if (accessToken == null) { + throw new AuthenticationError('invalid signature'); + } + + AccessTokens.update(accessToken.id, { + lastUsedAt: new Date(), + }); + + const user = await localUserByIdCache.fetch(accessToken.userId, + () => Users.findOneBy({ + id: accessToken.userId, + }) as Promise); + + if (accessToken.appId) { + const app = await appCache.fetch(accessToken.appId, + () => Apps.findOneByOrFail({ id: accessToken.appId! })); + + return [user, { + id: accessToken.id, + permission: app.permission, + } as AccessToken]; + } else { + return [user, accessToken]; + } + } +}; diff --git a/packages/backend/src/server/api/call.ts b/packages/backend/src/server/api/call.ts new file mode 100644 index 0000000000..aa130459a3 --- /dev/null +++ b/packages/backend/src/server/api/call.ts @@ -0,0 +1,147 @@ +import { performance } from 'perf_hooks'; +import Koa from 'koa'; +import { CacheableLocalUser, User } from '@/models/entities/user.js'; +import { AccessToken } from '@/models/entities/access-token.js'; +import { getIpHash } from '@/misc/get-ip-hash.js'; +import { limiter } from './limiter.js'; +import endpoints, { IEndpointMeta } from './endpoints.js'; +import { ApiError } from './error.js'; +import { apiLogger } from './logger.js'; + +const accessDenied = { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '56f35758-7dd5-468b-8439-5d6fb8ec9b8e', +}; + +export default async (endpoint: string, user: CacheableLocalUser | null | undefined, token: AccessToken | null | undefined, data: any, ctx?: Koa.Context) => { + const isSecure = user != null && token == null; + const isModerator = user != null && (user.isModerator || user.isAdmin); + + const ep = endpoints.find(e => e.name === endpoint); + + if (ep == null) { + throw new ApiError({ + message: 'No such endpoint.', + code: 'NO_SUCH_ENDPOINT', + id: 'f8080b67-5f9c-4eb7-8c18-7f1eeae8f709', + httpStatusCode: 404, + }); + } + + if (ep.meta.secure && !isSecure) { + throw new ApiError(accessDenied); + } + + if (ep.meta.limit) { + // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. + let limitActor: string; + if (user) { + limitActor = user.id; + } else { + limitActor = getIpHash(ctx!.ip); + } + + const limit = Object.assign({}, ep.meta.limit); + + if (!limit.key) { + limit.key = ep.name; + } + + // Rate limit + await limiter(limit as IEndpointMeta['limit'] & { key: NonNullable }, limitActor).catch(e => { + throw new ApiError({ + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', + httpStatusCode: 429, + }); + }); + } + + if (ep.meta.requireCredential && user == null) { + throw new ApiError({ + message: 'Credential required.', + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + httpStatusCode: 401, + }); + } + + if (ep.meta.requireCredential && user!.isSuspended) { + throw new ApiError({ + message: 'Your account has been suspended.', + code: 'YOUR_ACCOUNT_SUSPENDED', + id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370', + httpStatusCode: 403, + }); + } + + if (ep.meta.requireAdmin && !user!.isAdmin) { + throw new ApiError(accessDenied, { reason: 'You are not the admin.' }); + } + + if (ep.meta.requireModerator && !isModerator) { + throw new ApiError(accessDenied, { reason: 'You are not a moderator.' }); + } + + if (token && ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) { + throw new ApiError({ + message: 'Your app does not have the necessary permissions to use this endpoint.', + code: 'PERMISSION_DENIED', + id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', + }); + } + + // Cast non JSON input + if ((ep.meta.requireFile || ctx?.method === 'GET') && ep.params.properties) { + for (const k of Object.keys(ep.params.properties)) { + const param = ep.params.properties![k]; + if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { + try { + data[k] = JSON.parse(data[k]); + } catch (e) { + throw new ApiError({ + message: 'Invalid param.', + code: 'INVALID_PARAM', + id: '0b5f1631-7c1a-41a6-b399-cce335f34d85', + }, { + param: k, + reason: `cannot cast to ${param.type}`, + }); + } + } + } + } + + // API invoking + const before = performance.now(); + return await ep.exec(data, user, token, ctx?.file, ctx?.ip, ctx?.headers).catch((e: Error) => { + if (e instanceof ApiError) { + throw e; + } else { + apiLogger.error(`Internal error occurred in ${ep.name}: ${e.message}`, { + ep: ep.name, + ps: data, + e: { + message: e.message, + code: e.name, + stack: e.stack, + }, + }); + throw new ApiError(null, { + e: { + message: e.message, + code: e.name, + stack: e.stack, + }, + }); + } + }).finally(() => { + const after = performance.now(); + const time = after - before; + if (time > 1000) { + apiLogger.warn(`SLOW API CALL DETECTED: ${ep.name} (${time}ms)`); + } + }); +}; diff --git a/packages/backend/src/server/api/common/generate-block-query.ts b/packages/backend/src/server/api/common/generate-block-query.ts new file mode 100644 index 0000000000..60db1e731b --- /dev/null +++ b/packages/backend/src/server/api/common/generate-block-query.ts @@ -0,0 +1,42 @@ +import { User } from '@/models/entities/user.js'; +import { Blockings } from '@/models/index.js'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; + +// ここでいうBlockedは被Blockedの意 +export function generateBlockedUserQuery(q: SelectQueryBuilder, me: { id: User['id'] }) { + const blockingQuery = Blockings.createQueryBuilder('blocking') + .select('blocking.blockerId') + .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); + + // 投稿の作者にブロックされていない かつ + // 投稿の返信先の作者にブロックされていない かつ + // 投稿の引用元の作者にブロックされていない + q + .andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`) + .andWhere(new Brackets(qb => { qb + .where(`note.replyUserId IS NULL`) + .orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`); + })) + .andWhere(new Brackets(qb => { qb + .where(`note.renoteUserId IS NULL`) + .orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`); + })); + + q.setParameters(blockingQuery.getParameters()); +} + +export function generateBlockQueryForUsers(q: SelectQueryBuilder, me: { id: User['id'] }) { + const blockingQuery = Blockings.createQueryBuilder('blocking') + .select('blocking.blockeeId') + .where('blocking.blockerId = :blockerId', { blockerId: me.id }); + + const blockedQuery = Blockings.createQueryBuilder('blocking') + .select('blocking.blockerId') + .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); + + q.andWhere(`user.id NOT IN (${ blockingQuery.getQuery() })`); + q.setParameters(blockingQuery.getParameters()); + + q.andWhere(`user.id NOT IN (${ blockedQuery.getQuery() })`); + q.setParameters(blockedQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/common/generate-channel-query.ts b/packages/backend/src/server/api/common/generate-channel-query.ts new file mode 100644 index 0000000000..333bb73b86 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-channel-query.ts @@ -0,0 +1,24 @@ +import { User } from '@/models/entities/user.js'; +import { ChannelFollowings } from '@/models/index.js'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; + +export function generateChannelQuery(q: SelectQueryBuilder, me?: { id: User['id'] } | null) { + if (me == null) { + q.andWhere('note.channelId IS NULL'); + } else { + q.leftJoinAndSelect('note.channel', 'channel'); + + const channelFollowingQuery = ChannelFollowings.createQueryBuilder('channelFollowing') + .select('channelFollowing.followeeId') + .where('channelFollowing.followerId = :followerId', { followerId: me.id }); + + q.andWhere(new Brackets(qb => { qb + // チャンネルのノートではない + .where('note.channelId IS NULL') + // または自分がフォローしているチャンネルのノート + .orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`); + })); + + q.setParameters(channelFollowingQuery.getParameters()); + } +} diff --git a/packages/backend/src/server/api/common/generate-muted-note-query.ts b/packages/backend/src/server/api/common/generate-muted-note-query.ts new file mode 100644 index 0000000000..f544e334d3 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-muted-note-query.ts @@ -0,0 +1,13 @@ +import { User } from '@/models/entities/user.js'; +import { MutedNotes } from '@/models/index.js'; +import { SelectQueryBuilder } from 'typeorm'; + +export function generateMutedNoteQuery(q: SelectQueryBuilder, me: { id: User['id'] }) { + const mutedQuery = MutedNotes.createQueryBuilder('muted') + .select('muted.noteId') + .where('muted.userId = :userId', { userId: me.id }); + + q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); + + q.setParameters(mutedQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/common/generate-muted-note-thread-query.ts b/packages/backend/src/server/api/common/generate-muted-note-thread-query.ts new file mode 100644 index 0000000000..7263ea2e60 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-muted-note-thread-query.ts @@ -0,0 +1,17 @@ +import { User } from '@/models/entities/user.js'; +import { NoteThreadMutings } from '@/models/index.js'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; + +export function generateMutedNoteThreadQuery(q: SelectQueryBuilder, me: { id: User['id'] }) { + const mutedQuery = NoteThreadMutings.createQueryBuilder('threadMuted') + .select('threadMuted.threadId') + .where('threadMuted.userId = :userId', { userId: me.id }); + + q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); + q.andWhere(new Brackets(qb => { qb + .where(`note.threadId IS NULL`) + .orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`); + })); + + q.setParameters(mutedQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/common/generate-muted-user-query.ts b/packages/backend/src/server/api/common/generate-muted-user-query.ts new file mode 100644 index 0000000000..470ece1a62 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-muted-user-query.ts @@ -0,0 +1,57 @@ +import { SelectQueryBuilder, Brackets } from 'typeorm'; +import { User } from '@/models/entities/user.js'; +import { Mutings, UserProfiles } from '@/models/index.js'; + +export function generateMutedUserQuery(q: SelectQueryBuilder, me: { id: User['id'] }, exclude?: User) { + const mutingQuery = Mutings.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: me.id }); + + if (exclude) { + mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id }); + } + + const mutingInstanceQuery = UserProfiles.createQueryBuilder('user_profile') + .select('user_profile.mutedInstances') + .where('user_profile.userId = :muterId', { muterId: me.id }); + + // 投稿の作者をミュートしていない かつ + // 投稿の返信先の作者をミュートしていない かつ + // 投稿の引用元の作者をミュートしていない + q + .andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`) + .andWhere(new Brackets(qb => { qb + .where('note.replyUserId IS NULL') + .orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`); + })) + .andWhere(new Brackets(qb => { qb + .where('note.renoteUserId IS NULL') + .orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`); + })) + // mute instances + .andWhere(new Brackets(qb => { qb + .andWhere('note.userHost IS NULL') + .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`); + })) + .andWhere(new Brackets(qb => { qb + .where('note.replyUserHost IS NULL') + .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`); + })) + .andWhere(new Brackets(qb => { qb + .where('note.renoteUserHost IS NULL') + .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`); + })); + + q.setParameters(mutingQuery.getParameters()); + q.setParameters(mutingInstanceQuery.getParameters()); +} + +export function generateMutedUserQueryForUsers(q: SelectQueryBuilder, me: { id: User['id'] }) { + const mutingQuery = Mutings.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: me.id }); + + q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`); + + q.setParameters(mutingQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/common/generate-native-user-token.ts b/packages/backend/src/server/api/common/generate-native-user-token.ts new file mode 100644 index 0000000000..5d8a4c5378 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-native-user-token.ts @@ -0,0 +1,3 @@ +import { secureRndstr } from '@/misc/secure-rndstr.js'; + +export default () => secureRndstr(16, true); diff --git a/packages/backend/src/server/api/common/generate-replies-query.ts b/packages/backend/src/server/api/common/generate-replies-query.ts new file mode 100644 index 0000000000..301782eab9 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-replies-query.ts @@ -0,0 +1,27 @@ +import { User } from '@/models/entities/user.js'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; + +export function generateRepliesQuery(q: SelectQueryBuilder, me?: Pick | null) { + if (me == null) { + q.andWhere(new Brackets(qb => { qb + .where(`note.replyId IS NULL`) // 返信ではない + .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信 + .where(`note.replyId IS NOT NULL`) + .andWhere('note.replyUserId = note.userId'); + })); + })); + } else if (!me.showTimelineReplies) { + q.andWhere(new Brackets(qb => { qb + .where(`note.replyId IS NULL`) // 返信ではない + .orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信 + .orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信 + .where(`note.replyId IS NOT NULL`) + .andWhere('note.userId = :meId', { meId: me.id }); + })) + .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信 + .where(`note.replyId IS NOT NULL`) + .andWhere('note.replyUserId = note.userId'); + })); + })); + } +} diff --git a/packages/backend/src/server/api/common/generate-visibility-query.ts b/packages/backend/src/server/api/common/generate-visibility-query.ts new file mode 100644 index 0000000000..b50b6812f4 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-visibility-query.ts @@ -0,0 +1,42 @@ +import { User } from '@/models/entities/user.js'; +import { Followings } from '@/models/index.js'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; + +export function generateVisibilityQuery(q: SelectQueryBuilder, me?: { id: User['id'] } | null) { + // This code must always be synchronized with the checks in Notes.isVisibleForMe. + if (me == null) { + q.andWhere(new Brackets(qb => { qb + .where(`note.visibility = 'public'`) + .orWhere(`note.visibility = 'home'`); + })); + } else { + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :meId'); + + q.andWhere(new Brackets(qb => { qb + // 公開投稿である + .where(new Brackets(qb => { qb + .where(`note.visibility = 'public'`) + .orWhere(`note.visibility = 'home'`); + })) + // または 自分自身 + .orWhere('note.userId = :meId') + // または 自分宛て + .orWhere(':meId = ANY(note.visibleUserIds)') + .orWhere(':meId = ANY(note.mentions)') + .orWhere(new Brackets(qb => { qb + // または フォロワー宛ての投稿であり、 + .where(`note.visibility = 'followers'`) + .andWhere(new Brackets(qb => { qb + // 自分がフォロワーである + .where(`note.userId IN (${ followingQuery.getQuery() })`) + // または 自分の投稿へのリプライ + .orWhere('note.replyUserId = :meId'); + })); + })); + })); + + q.setParameters({ meId: me.id }); + } +} diff --git a/packages/backend/src/server/api/common/getters.ts b/packages/backend/src/server/api/common/getters.ts new file mode 100644 index 0000000000..783ea9ef75 --- /dev/null +++ b/packages/backend/src/server/api/common/getters.ts @@ -0,0 +1,56 @@ +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { User } from '@/models/entities/user.js'; +import { Note } from '@/models/entities/note.js'; +import { Notes, Users } from '@/models/index.js'; + +/** + * Get note for API processing + */ +export async function getNote(noteId: Note['id']) { + const note = await Notes.findOneBy({ id: noteId }); + + if (note == null) { + throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.'); + } + + return note; +} + +/** + * Get user for API processing + */ +export async function getUser(userId: User['id']) { + const user = await Users.findOneBy({ id: userId }); + + if (user == null) { + throw new IdentifiableError('15348ddd-432d-49c2-8a5a-8069753becff', 'No such user.'); + } + + return user; +} + +/** + * Get remote user for API processing + */ +export async function getRemoteUser(userId: User['id']) { + const user = await getUser(userId); + + if (!Users.isRemoteUser(user)) { + throw new Error('user is not a remote user'); + } + + return user; +} + +/** + * Get local user for API processing + */ +export async function getLocalUser(userId: User['id']) { + const user = await getUser(userId); + + if (!Users.isLocalUser(user)) { + throw new Error('user is not a local user'); + } + + return user; +} diff --git a/packages/backend/src/server/api/common/inject-featured.ts b/packages/backend/src/server/api/common/inject-featured.ts new file mode 100644 index 0000000000..f7cdd365ed --- /dev/null +++ b/packages/backend/src/server/api/common/inject-featured.ts @@ -0,0 +1,56 @@ +import rndstr from 'rndstr'; +import { Note } from '@/models/entities/note.js'; +import { User } from '@/models/entities/user.js'; +import { Notes, UserProfiles, NoteReactions } from '@/models/index.js'; +import { generateMutedUserQuery } from './generate-muted-user-query.js'; +import { generateBlockedUserQuery } from './generate-block-query.js'; + +// TODO: リアクション、Renote、返信などをしたノートは除外する + +export async function injectFeatured(timeline: Note[], user?: User | null) { + if (timeline.length < 5) return; + + if (user) { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + if (!profile.injectFeaturedNote) return; + } + + const max = 30; + const day = 1000 * 60 * 60 * 24 * 3; // 3日前まで + + const query = Notes.createQueryBuilder('note') + .addSelect('note.score') + .where('note.userHost IS NULL') + .andWhere(`note.score > 0`) + .andWhere(`note.createdAt > :date`, { date: new Date(Date.now() - day) }) + .andWhere(`note.visibility = 'public'`) + .innerJoinAndSelect('note.user', 'user'); + + if (user) { + query.andWhere('note.userId != :userId', { userId: user.id }); + + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + + const reactionQuery = NoteReactions.createQueryBuilder('reaction') + .select('reaction.noteId') + .where('reaction.userId = :userId', { userId: user.id }); + + query.andWhere(`note.id NOT IN (${ reactionQuery.getQuery() })`); + } + + const notes = await query + .orderBy('note.score', 'DESC') + .take(max) + .getMany(); + + if (notes.length === 0) return; + + // Pick random one + const featured = notes[Math.floor(Math.random() * notes.length)]; + + (featured as any)._featuredId_ = rndstr('a-z0-9', 8); + + // Inject featured + timeline.splice(3, 0, featured); +} diff --git a/packages/backend/src/server/api/common/inject-promo.ts b/packages/backend/src/server/api/common/inject-promo.ts new file mode 100644 index 0000000000..b0da8118b4 --- /dev/null +++ b/packages/backend/src/server/api/common/inject-promo.ts @@ -0,0 +1,34 @@ +import rndstr from 'rndstr'; +import { Note } from '@/models/entities/note.js'; +import { User } from '@/models/entities/user.js'; +import { PromoReads, PromoNotes, Notes, Users } from '@/models/index.js'; + +export async function injectPromo(timeline: Note[], user?: User | null) { + if (timeline.length < 5) return; + + // TODO: readやexpireフィルタはクエリ側でやる + + const reads = user ? await PromoReads.findBy({ + userId: user.id, + }) : []; + + let promos = await PromoNotes.find(); + + promos = promos.filter(n => n.expiresAt.getTime() > Date.now()); + promos = promos.filter(n => !reads.map(r => r.noteId).includes(n.noteId)); + + if (promos.length === 0) return; + + // Pick random promo + const promo = promos[Math.floor(Math.random() * promos.length)]; + + const note = await Notes.findOneByOrFail({ id: promo.noteId }); + + // Join + note.user = await Users.findOneByOrFail({ id: note.userId }); + + (note as any)._prId_ = rndstr('a-z0-9', 8); + + // Inject promo + timeline.splice(3, 0, note); +} diff --git a/packages/backend/src/server/api/common/is-native-token.ts b/packages/backend/src/server/api/common/is-native-token.ts new file mode 100644 index 0000000000..2833c570c8 --- /dev/null +++ b/packages/backend/src/server/api/common/is-native-token.ts @@ -0,0 +1 @@ +export default (token: string) => token.length === 16; diff --git a/packages/backend/src/server/api/common/make-pagination-query.ts b/packages/backend/src/server/api/common/make-pagination-query.ts new file mode 100644 index 0000000000..51c11e5dff --- /dev/null +++ b/packages/backend/src/server/api/common/make-pagination-query.ts @@ -0,0 +1,28 @@ +import { SelectQueryBuilder } from 'typeorm'; + +export function makePaginationQuery(q: SelectQueryBuilder, sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number) { + if (sinceId && untilId) { + q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); + q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); + q.orderBy(`${q.alias}.id`, 'DESC'); + } else if (sinceId) { + q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); + q.orderBy(`${q.alias}.id`, 'ASC'); + } else if (untilId) { + q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); + q.orderBy(`${q.alias}.id`, 'DESC'); + } else if (sinceDate && untilDate) { + q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) }); + q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) }); + q.orderBy(`${q.alias}.createdAt`, 'DESC'); + } else if (sinceDate) { + q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) }); + q.orderBy(`${q.alias}.createdAt`, 'ASC'); + } else if (untilDate) { + q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) }); + q.orderBy(`${q.alias}.createdAt`, 'DESC'); + } else { + q.orderBy(`${q.alias}.id`, 'DESC'); + } + return q; +} diff --git a/packages/backend/src/server/api/common/read-messaging-message.ts b/packages/backend/src/server/api/common/read-messaging-message.ts new file mode 100644 index 0000000000..c4c18ffa06 --- /dev/null +++ b/packages/backend/src/server/api/common/read-messaging-message.ts @@ -0,0 +1,151 @@ +import { publishMainStream, publishGroupMessagingStream } from '@/services/stream.js'; +import { publishMessagingStream } from '@/services/stream.js'; +import { publishMessagingIndexStream } from '@/services/stream.js'; +import { pushNotification } from '@/services/push-notification.js'; +import { User, IRemoteUser } from '@/models/entities/user.js'; +import { MessagingMessage } from '@/models/entities/messaging-message.js'; +import { MessagingMessages, UserGroupJoinings, Users } from '@/models/index.js'; +import { In } from 'typeorm'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { UserGroup } from '@/models/entities/user-group.js'; +import { toArray } from '@/prelude/array.js'; +import { renderReadActivity } from '@/remote/activitypub/renderer/read.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import { deliver } from '@/queue/index.js'; +import orderedCollection from '@/remote/activitypub/renderer/ordered-collection.js'; + +/** + * Mark messages as read + */ +export async function readUserMessagingMessage( + userId: User['id'], + otherpartyId: User['id'], + messageIds: MessagingMessage['id'][] +) { + if (messageIds.length === 0) return; + + const messages = await MessagingMessages.findBy({ + id: In(messageIds), + }); + + for (const message of messages) { + if (message.recipientId !== userId) { + throw new IdentifiableError('e140a4bf-49ce-4fb6-b67c-b78dadf6b52f', 'Access denied (user).'); + } + } + + // Update documents + await MessagingMessages.update({ + id: In(messageIds), + userId: otherpartyId, + recipientId: userId, + isRead: false, + }, { + isRead: true, + }); + + // Publish event + publishMessagingStream(otherpartyId, userId, 'read', messageIds); + publishMessagingIndexStream(userId, 'read', messageIds); + + if (!await Users.getHasUnreadMessagingMessage(userId)) { + // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 + publishMainStream(userId, 'readAllMessagingMessages'); + pushNotification(userId, 'readAllMessagingMessages', undefined); + } else { + // そのユーザーとのメッセージで未読がなければイベント発行 + const count = await MessagingMessages.count({ + where: { + userId: otherpartyId, + recipientId: userId, + isRead: false, + }, + take: 1 + }); + + if (!count) { + pushNotification(userId, 'readAllMessagingMessagesOfARoom', { userId: otherpartyId }); + } + } +} + +/** + * Mark messages as read + */ +export async function readGroupMessagingMessage( + userId: User['id'], + groupId: UserGroup['id'], + messageIds: MessagingMessage['id'][] +) { + if (messageIds.length === 0) return; + + // check joined + const joining = await UserGroupJoinings.findOneBy({ + userId: userId, + userGroupId: groupId, + }); + + if (joining == null) { + throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (group).'); + } + + const messages = await MessagingMessages.findBy({ + id: In(messageIds), + }); + + const reads: MessagingMessage['id'][] = []; + + for (const message of messages) { + if (message.userId === userId) continue; + if (message.reads.includes(userId)) continue; + + // Update document + await MessagingMessages.createQueryBuilder().update() + .set({ + reads: (() => `array_append("reads", '${joining.userId}')`) as any, + }) + .where('id = :id', { id: message.id }) + .execute(); + + reads.push(message.id); + } + + // Publish event + publishGroupMessagingStream(groupId, 'read', { + ids: reads, + userId: userId, + }); + publishMessagingIndexStream(userId, 'read', reads); + + if (!await Users.getHasUnreadMessagingMessage(userId)) { + // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 + publishMainStream(userId, 'readAllMessagingMessages'); + pushNotification(userId, 'readAllMessagingMessages', undefined); + } else { + // そのグループにおいて未読がなければイベント発行 + const unreadExist = await MessagingMessages.createQueryBuilder('message') + .where(`message.groupId = :groupId`, { groupId: groupId }) + .andWhere('message.userId != :userId', { userId: userId }) + .andWhere('NOT (:userId = ANY(message.reads))', { userId: userId }) + .andWhere('message.createdAt > :joinedAt', { joinedAt: joining.createdAt }) // 自分が加入する前の会話については、未読扱いしない + .getOne().then(x => x != null); + + if (!unreadExist) { + pushNotification(userId, 'readAllMessagingMessagesOfARoom', { groupId }); + } + } +} + +export async function deliverReadActivity(user: { id: User['id']; host: null; }, recipient: IRemoteUser, messages: MessagingMessage | MessagingMessage[]) { + messages = toArray(messages).filter(x => x.uri); + const contents = messages.map(x => renderReadActivity(user, x)); + + if (contents.length > 1) { + const collection = orderedCollection(null, contents.length, undefined, undefined, contents); + deliver(user, renderActivity(collection), recipient.inbox); + } else { + for (const content of contents) { + deliver(user, renderActivity(content), recipient.inbox); + } + } +} diff --git a/packages/backend/src/server/api/common/read-notification.ts b/packages/backend/src/server/api/common/read-notification.ts new file mode 100644 index 0000000000..b0d38a9e39 --- /dev/null +++ b/packages/backend/src/server/api/common/read-notification.ts @@ -0,0 +1,50 @@ +import { In } from 'typeorm'; +import { publishMainStream } from '@/services/stream.js'; +import { pushNotification } from '@/services/push-notification.js'; +import { User } from '@/models/entities/user.js'; +import { Notification } from '@/models/entities/notification.js'; +import { Notifications, Users } from '@/models/index.js'; + +export async function readNotification( + userId: User['id'], + notificationIds: Notification['id'][], +) { + if (notificationIds.length === 0) return; + + // Update documents + const result = await Notifications.update({ + notifieeId: userId, + id: In(notificationIds), + isRead: false, + }, { + isRead: true, + }); + + if (result.affected === 0) return; + + if (!await Users.getHasUnreadNotification(userId)) return postReadAllNotifications(userId); + else return postReadNotifications(userId, notificationIds); +} + +export async function readNotificationByQuery( + userId: User['id'], + query: Record, +) { + const notificationIds = await Notifications.findBy({ + ...query, + notifieeId: userId, + isRead: false, + }).then(notifications => notifications.map(notification => notification.id)); + + return readNotification(userId, notificationIds); +} + +function postReadAllNotifications(userId: User['id']) { + publishMainStream(userId, 'readAllNotifications'); + return pushNotification(userId, 'readAllNotifications', undefined); +} + +function postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) { + publishMainStream(userId, 'readNotifications', notificationIds); + return pushNotification(userId, 'readNotifications', { notificationIds }); +} diff --git a/packages/backend/src/server/api/common/signin.ts b/packages/backend/src/server/api/common/signin.ts new file mode 100644 index 0000000000..038fd8d96e --- /dev/null +++ b/packages/backend/src/server/api/common/signin.ts @@ -0,0 +1,44 @@ +import Koa from 'koa'; + +import config from '@/config/index.js'; +import { ILocalUser } from '@/models/entities/user.js'; +import { Signins } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { publishMainStream } from '@/services/stream.js'; + +export default function(ctx: Koa.Context, user: ILocalUser, redirect = false) { + if (redirect) { + //#region Cookie + ctx.cookies.set('igi', user.token!, { + path: '/', + // SEE: https://github.com/koajs/koa/issues/974 + // When using a SSL proxy it should be configured to add the "X-Forwarded-Proto: https" header + secure: config.url.startsWith('https'), + httpOnly: false, + }); + //#endregion + + ctx.redirect(config.url); + } else { + ctx.body = { + id: user.id, + i: user.token, + }; + ctx.status = 200; + } + + (async () => { + // Append signin history + const record = await Signins.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + ip: ctx.ip, + headers: ctx.headers, + success: true, + }).then(x => Signins.findOneByOrFail(x.identifiers[0])); + + // Publish signin event + publishMainStream(user.id, 'signin', await Signins.pack(record)); + })(); +} diff --git a/packages/backend/src/server/api/common/signup.ts b/packages/backend/src/server/api/common/signup.ts new file mode 100644 index 0000000000..abc142472a --- /dev/null +++ b/packages/backend/src/server/api/common/signup.ts @@ -0,0 +1,114 @@ +import bcrypt from 'bcryptjs'; +import { generateKeyPair } from 'node:crypto'; +import generateUserToken from './generate-native-user-token.js'; +import { User } from '@/models/entities/user.js'; +import { Users, UsedUsernames } from '@/models/index.js'; +import { UserProfile } from '@/models/entities/user-profile.js'; +import { IsNull } from 'typeorm'; +import { genId } from '@/misc/gen-id.js'; +import { toPunyNullable } from '@/misc/convert-host.js'; +import { UserKeypair } from '@/models/entities/user-keypair.js'; +import { usersChart } from '@/services/chart/index.js'; +import { UsedUsername } from '@/models/entities/used-username.js'; +import { db } from '@/db/postgre.js'; + +export async function signup(opts: { + username: User['username']; + password?: string | null; + passwordHash?: UserProfile['password'] | null; + host?: string | null; +}) { + const { username, password, passwordHash, host } = opts; + let hash = passwordHash; + + // Validate username + if (!Users.validateLocalUsername(username)) { + throw new Error('INVALID_USERNAME'); + } + + if (password != null && passwordHash == null) { + // Validate password + if (!Users.validatePassword(password)) { + throw new Error('INVALID_PASSWORD'); + } + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + hash = await bcrypt.hash(password, salt); + } + + // Generate secret + const secret = generateUserToken(); + + // Check username duplication + if (await Users.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) { + throw new Error('DUPLICATED_USERNAME'); + } + + // Check deleted username duplication + if (await UsedUsernames.findOneBy({ username: username.toLowerCase() })) { + throw new Error('USED_USERNAME'); + } + + const keyPair = await new Promise((res, rej) => + generateKeyPair('rsa', { + modulusLength: 4096, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: undefined, + passphrase: undefined, + }, + } as any, (err, publicKey, privateKey) => + err ? rej(err) : res([publicKey, privateKey]) + )); + + let account!: User; + + // Start transaction + await db.transaction(async transactionalEntityManager => { + const exist = await transactionalEntityManager.findOneBy(User, { + usernameLower: username.toLowerCase(), + host: IsNull(), + }); + + if (exist) throw new Error(' the username is already used'); + + account = await transactionalEntityManager.save(new User({ + id: genId(), + createdAt: new Date(), + username: username, + usernameLower: username.toLowerCase(), + host: toPunyNullable(host), + token: secret, + isAdmin: (await Users.countBy({ + host: IsNull(), + })) === 0, + })); + + await transactionalEntityManager.save(new UserKeypair({ + publicKey: keyPair[0], + privateKey: keyPair[1], + userId: account.id, + })); + + await transactionalEntityManager.save(new UserProfile({ + userId: account.id, + autoAcceptFollowed: true, + password: hash, + })); + + await transactionalEntityManager.save(new UsedUsername({ + createdAt: new Date(), + username: username.toLowerCase(), + })); + }); + + usersChart.update(account, true); + + return { account, secret }; +} diff --git a/packages/backend/src/server/api/define.ts b/packages/backend/src/server/api/define.ts new file mode 100644 index 0000000000..c1b56b8a83 --- /dev/null +++ b/packages/backend/src/server/api/define.ts @@ -0,0 +1,59 @@ +import * as fs from 'node:fs'; +import Ajv from 'ajv'; +import { CacheableLocalUser, ILocalUser } from '@/models/entities/user.js'; +import { Schema, SchemaType } from '@/misc/schema.js'; +import { AccessToken } from '@/models/entities/access-token.js'; +import { IEndpointMeta } from './endpoints.js'; +import { ApiError } from './error.js'; + +export type Response = Record | void; + +// TODO: paramsの型をT['params']のスキーマ定義から推論する +type executor = + (params: SchemaType, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, cleanup?: () => any, ip?: string | null, headers?: Record | null) => + Promise>>; + +const ajv = new Ajv({ + useDefaults: true, +}); + +ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); + +export default function (meta: T, paramDef: Ps, cb: executor) + : (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, ip?: string | null, headers?: Record | null) => Promise { + const validate = ajv.compile(paramDef); + + return (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, ip?: string | null, headers?: Record | null) => { + let cleanup: undefined | (() => void) = undefined; + + if (meta.requireFile) { + cleanup = () => { + fs.unlink(file.path, () => {}); + }; + + if (file == null) return Promise.reject(new ApiError({ + message: 'File required.', + code: 'FILE_REQUIRED', + id: '4267801e-70d1-416a-b011-4ee502885d8b', + })); + } + + const valid = validate(params); + if (!valid) { + if (file) cleanup!(); + + const errors = validate.errors!; + const err = new ApiError({ + message: 'Invalid param.', + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + }, { + param: errors[0].schemaPath, + reason: errors[0].message, + }); + return Promise.reject(err); + } + + return cb(params as SchemaType, user, token, file, cleanup, ip, headers); + }; +} diff --git a/packages/backend/src/server/api/endpoint-base.ts b/packages/backend/src/server/api/endpoint-base.ts deleted file mode 100644 index 8b8d4f2379..0000000000 --- a/packages/backend/src/server/api/endpoint-base.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as fs from 'node:fs'; -import _Ajv from 'ajv'; -import type { Schema, SchemaType } from '@/misc/json-schema.js'; -import type { MiLocalUser } from '@/models/User.js'; -import type { MiAccessToken } from '@/models/AccessToken.js'; -import { ApiError } from './error.js'; -import type { IEndpointMeta } from './endpoints.js'; - -const Ajv = _Ajv.default; - -const ajv = new Ajv({ - useDefaults: true, -}); - -ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); - -export type Response = Record | void; - -type File = { - name: string | null; - path: string; -}; - -// TODO: paramsの型をT['params']のスキーマ定義から推論する -type Executor = - (params: SchemaType, user: T['requireCredential'] extends 'always' ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, cleanup?: () => any, ip?: string | null, headers?: Record | null) => - Promise>>; - -export abstract class Endpoint { - public exec: (params: any, user: T['requireCredential'] extends 'always' ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, ip?: string | null, headers?: Record | null) => Promise; - - constructor(meta: T, paramDef: Ps, cb: Executor) { - const validate = ajv.compile(paramDef); - - this.exec = (params: any, user: T['requireCredential'] extends 'always' ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, ip?: string | null, headers?: Record | null) => { - let cleanup: undefined | (() => void) = undefined; - - if (meta.requireFile) { - cleanup = () => { - if (file) fs.unlink(file.path, () => {}); - }; - - if (file == null) return Promise.reject(new ApiError({ - message: 'File required.', - code: 'FILE_REQUIRED', - id: '4267801e-70d1-416a-b011-4ee502885d8b', - })); - } - - const valid = validate(params); - if (!valid) { - if (file) cleanup!(); - - const errors = validate.errors!; - const err = new ApiError({ - message: 'Invalid param.', - code: 'INVALID_PARAM', - id: '3d81ceae-475f-4600-b2a8-2bc116157532', - }, { - param: errors[0].schemaPath, - reason: errors[0].message, - }); - return Promise.reject(err); - } - - return cb(params as SchemaType, user, token, file, cleanup, ip, headers); - }; - } -} diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index b6f3c60f0e..4644f34d94 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -1,17 +1,9 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Schema } from '@/misc/json-schema.js'; -import { permissions } from 'misskey-js'; -import { RolePolicies } from '@/core/RoleService.js'; +import { Schema } from '@/misc/schema.js'; import * as ep___admin_meta from './endpoints/admin/meta.js'; import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.js'; import * as ep___admin_accounts_create from './endpoints/admin/accounts/create.js'; import * as ep___admin_accounts_delete from './endpoints/admin/accounts/delete.js'; -import * as ep___admin_accounts_findByEmail from './endpoints/admin/accounts/find-by-email.js'; import * as ep___admin_ad_create from './endpoints/admin/ad/create.js'; import * as ep___admin_ad_delete from './endpoints/admin/ad/delete.js'; import * as ep___admin_ad_list from './endpoints/admin/ad/list.js'; @@ -20,13 +12,7 @@ import * as ep___admin_announcements_create from './endpoints/admin/announcement import * as ep___admin_announcements_delete from './endpoints/admin/announcements/delete.js'; import * as ep___admin_announcements_list from './endpoints/admin/announcements/list.js'; import * as ep___admin_announcements_update from './endpoints/admin/announcements/update.js'; -import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-decorations/create.js'; -import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; -import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; -import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; -import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js'; -import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js'; import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js'; import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js'; import * as ep___admin_drive_files from './endpoints/admin/drive/files.js'; @@ -42,7 +28,6 @@ import * as ep___admin_emoji_list from './endpoints/admin/emoji/list.js'; import * as ep___admin_emoji_removeAliasesBulk from './endpoints/admin/emoji/remove-aliases-bulk.js'; import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-aliases-bulk.js'; import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; -import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; @@ -51,13 +36,13 @@ import * as ep___admin_federation_updateInstance from './endpoints/admin/federat import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js'; import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js'; import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js'; -import * as ep___admin_invite_create from './endpoints/admin/invite/create.js'; -import * as ep___admin_invite_list from './endpoints/admin/invite/list.js'; +import * as ep___admin_invite from './endpoints/admin/invite.js'; +import * as ep___admin_moderators_add from './endpoints/admin/moderators/add.js'; +import * as ep___admin_moderators_remove from './endpoints/admin/moderators/remove.js'; import * as ep___admin_promo_create from './endpoints/admin/promo/create.js'; import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js'; -import * as ep___admin_queue_promote from './endpoints/admin/queue/promote.js'; import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js'; import * as ep___admin_relays_add from './endpoints/admin/relays/add.js'; import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; @@ -69,25 +54,14 @@ import * as ep___admin_serverInfo from './endpoints/admin/server-info.js'; import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js'; import * as ep___admin_showUser from './endpoints/admin/show-user.js'; import * as ep___admin_showUsers from './endpoints/admin/show-users.js'; -import * as ep___admin_nsfwUser from './endpoints/admin/nsfw-user.js'; -import * as ep___admin_unnsfwUser from './endpoints/admin/unnsfw-user.js'; import * as ep___admin_silenceUser from './endpoints/admin/silence-user.js'; -import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-user.js'; import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; -import * as ep___admin_approveUser from './endpoints/admin/approve-user.js'; +import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-user.js'; import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; +import * as ep___admin_vacuum from './endpoints/admin/vacuum.js'; import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js'; import * as ep___admin_updateUserNote from './endpoints/admin/update-user-note.js'; -import * as ep___admin_roles_create from './endpoints/admin/roles/create.js'; -import * as ep___admin_roles_delete from './endpoints/admin/roles/delete.js'; -import * as ep___admin_roles_list from './endpoints/admin/roles/list.js'; -import * as ep___admin_roles_show from './endpoints/admin/roles/show.js'; -import * as ep___admin_roles_update from './endpoints/admin/roles/update.js'; -import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js'; -import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'; -import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js'; -import * as ep___admin_roles_users from './endpoints/admin/roles/users.js'; import * as ep___announcements from './endpoints/announcements.js'; import * as ep___antennas_create from './endpoints/antennas/create.js'; import * as ep___antennas_delete from './endpoints/antennas/delete.js'; @@ -115,20 +89,16 @@ import * as ep___channels_show from './endpoints/channels/show.js'; import * as ep___channels_timeline from './endpoints/channels/timeline.js'; import * as ep___channels_unfollow from './endpoints/channels/unfollow.js'; import * as ep___channels_update from './endpoints/channels/update.js'; -import * as ep___channels_favorite from './endpoints/channels/favorite.js'; -import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; -import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; -import * as ep___channels_search from './endpoints/channels/search.js'; import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; import * as ep___charts_drive from './endpoints/charts/drive.js'; import * as ep___charts_federation from './endpoints/charts/federation.js'; +import * as ep___charts_hashtag from './endpoints/charts/hashtag.js'; import * as ep___charts_instance from './endpoints/charts/instance.js'; import * as ep___charts_notes from './endpoints/charts/notes.js'; import * as ep___charts_user_drive from './endpoints/charts/user/drive.js'; import * as ep___charts_user_following from './endpoints/charts/user/following.js'; import * as ep___charts_user_notes from './endpoints/charts/user/notes.js'; -import * as ep___charts_user_pv from './endpoints/charts/user/pv.js'; import * as ep___charts_user_reactions from './endpoints/charts/user/reactions.js'; import * as ep___charts_users from './endpoints/charts/users.js'; import * as ep___clips_addNote from './endpoints/clips/add-note.js'; @@ -139,9 +109,6 @@ import * as ep___clips_list from './endpoints/clips/list.js'; import * as ep___clips_notes from './endpoints/clips/notes.js'; import * as ep___clips_show from './endpoints/clips/show.js'; import * as ep___clips_update from './endpoints/clips/update.js'; -import * as ep___clips_favorite from './endpoints/clips/favorite.js'; -import * as ep___clips_unfavorite from './endpoints/clips/unfavorite.js'; -import * as ep___clips_myFavorites from './endpoints/clips/my-favorites.js'; import * as ep___drive from './endpoints/drive.js'; import * as ep___drive_files from './endpoints/drive/files.js'; import * as ep___drive_files_attachedNotes from './endpoints/drive/files/attached-notes.js'; @@ -173,8 +140,6 @@ import * as ep___federation_users from './endpoints/federation/users.js'; import * as ep___federation_stats from './endpoints/federation/stats.js'; import * as ep___following_create from './endpoints/following/create.js'; import * as ep___following_delete from './endpoints/following/delete.js'; -import * as ep___following_update from './endpoints/following/update.js'; -import * as ep___following_update_all from './endpoints/following/update-all.js'; import * as ep___following_invalidate from './endpoints/following/invalidate.js'; import * as ep___following_requests_accept from './endpoints/following/requests/accept.js'; import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js'; @@ -190,7 +155,6 @@ import * as ep___gallery_posts_show from './endpoints/gallery/posts/show.js'; import * as ep___gallery_posts_unlike from './endpoints/gallery/posts/unlike.js'; import * as ep___gallery_posts_update from './endpoints/gallery/posts/update.js'; import * as ep___getOnlineUsersCount from './endpoints/get-online-users-count.js'; -import * as ep___getAvatarDecorations from './endpoints/get-avatar-decorations.js'; import * as ep___hashtags_list from './endpoints/hashtags/list.js'; import * as ep___hashtags_search from './endpoints/hashtags/search.js'; import * as ep___hashtags_show from './endpoints/hashtags/show.js'; @@ -202,73 +166,62 @@ import * as ep___i_2fa_keyDone from './endpoints/i/2fa/key-done.js'; import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js'; import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js'; import * as ep___i_2fa_register from './endpoints/i/2fa/register.js'; -import * as ep___i_2fa_updateKey from './endpoints/i/2fa/update-key.js'; import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js'; import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js'; import * as ep___i_apps from './endpoints/i/apps.js'; import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js'; -import * as ep___i_claimAchievement from './endpoints/i/claim-achievement.js'; import * as ep___i_changePassword from './endpoints/i/change-password.js'; import * as ep___i_deleteAccount from './endpoints/i/delete-account.js'; -import * as ep___i_exportData from './endpoints/i/export-data.js'; import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js'; import * as ep___i_exportFollowing from './endpoints/i/export-following.js'; import * as ep___i_exportMute from './endpoints/i/export-mute.js'; import * as ep___i_exportNotes from './endpoints/i/export-notes.js'; -import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js'; import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js'; -import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js'; import * as ep___i_favorites from './endpoints/i/favorites.js'; import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js'; import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js'; +import * as ep___i_getWordMutedNotesCount from './endpoints/i/get-word-muted-notes-count.js'; import * as ep___i_importBlocking from './endpoints/i/import-blocking.js'; import * as ep___i_importFollowing from './endpoints/i/import-following.js'; -import * as ep___i_importNotes from './endpoints/i/import-notes.js'; import * as ep___i_importMuting from './endpoints/i/import-muting.js'; import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js'; -import * as ep___i_importAntennas from './endpoints/i/import-antennas.js'; import * as ep___i_notifications from './endpoints/i/notifications.js'; -import * as ep___i_notificationsGrouped from './endpoints/i/notifications-grouped.js'; import * as ep___i_pageLikes from './endpoints/i/page-likes.js'; import * as ep___i_pages from './endpoints/i/pages.js'; import * as ep___i_pin from './endpoints/i/pin.js'; +import * as ep___i_readAllMessagingMessages from './endpoints/i/read-all-messaging-messages.js'; import * as ep___i_readAllUnreadNotes from './endpoints/i/read-all-unread-notes.js'; import * as ep___i_readAnnouncement from './endpoints/i/read-announcement.js'; import * as ep___i_regenerateToken from './endpoints/i/regenerate-token.js'; import * as ep___i_registry_getAll from './endpoints/i/registry/get-all.js'; -import * as ep___i_registry_getUnsecure from './endpoints/i/registry/get-unsecure.js'; import * as ep___i_registry_getDetail from './endpoints/i/registry/get-detail.js'; import * as ep___i_registry_get from './endpoints/i/registry/get.js'; import * as ep___i_registry_keysWithType from './endpoints/i/registry/keys-with-type.js'; import * as ep___i_registry_keys from './endpoints/i/registry/keys.js'; import * as ep___i_registry_remove from './endpoints/i/registry/remove.js'; -import * as ep___i_registry_scopesWithDomain from './endpoints/i/registry/scopes-with-domain.js'; +import * as ep___i_registry_scopes from './endpoints/i/registry/scopes.js'; import * as ep___i_registry_set from './endpoints/i/registry/set.js'; import * as ep___i_revokeToken from './endpoints/i/revoke-token.js'; import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; import * as ep___i_unpin from './endpoints/i/unpin.js'; import * as ep___i_updateEmail from './endpoints/i/update-email.js'; import * as ep___i_update from './endpoints/i/update.js'; -import * as ep___i_move from './endpoints/i/move.js'; +import * as ep___i_userGroupInvites from './endpoints/i/user-group-invites.js'; import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; 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___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'; -import * as ep___invite_limit from './endpoints/invite/limit.js'; +import * as ep___messaging_history from './endpoints/messaging/history.js'; +import * as ep___messaging_messages from './endpoints/messaging/messages.js'; +import * as ep___messaging_messages_create from './endpoints/messaging/messages/create.js'; +import * as ep___messaging_messages_delete from './endpoints/messaging/messages/delete.js'; +import * as ep___messaging_messages_read from './endpoints/messaging/messages/read.js'; import * as ep___meta from './endpoints/meta.js'; -import * as ep___emojis from './endpoints/emojis.js'; -import * as ep___emoji from './endpoints/emoji.js'; import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; import * as ep___mute_create from './endpoints/mute/create.js'; import * as ep___mute_delete from './endpoints/mute/delete.js'; import * as ep___mute_list from './endpoints/mute/list.js'; -import * as ep___renoteMute_create from './endpoints/renote-mute/create.js'; -import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js'; -import * as ep___renoteMute_list from './endpoints/renote-mute/list.js'; import * as ep___my_apps from './endpoints/my/apps.js'; import * as ep___notes from './endpoints/notes.js'; import * as ep___notes_children from './endpoints/notes/children.js'; @@ -280,7 +233,6 @@ import * as ep___notes_favorites_create from './endpoints/notes/favorites/create import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; import * as ep___notes_featured from './endpoints/notes/featured.js'; import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js'; -import * as ep___notes_bubbleTimeline from './endpoints/notes/bubble-timeline.js'; import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js'; import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; import * as ep___notes_mentions from './endpoints/notes/mentions.js'; @@ -289,7 +241,6 @@ import * as ep___notes_polls_vote from './endpoints/notes/polls/vote.js'; import * as ep___notes_reactions from './endpoints/notes/reactions.js'; import * as ep___notes_reactions_create from './endpoints/notes/reactions/create.js'; import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.js'; -import * as ep___notes_like from './endpoints/notes/like.js'; import * as ep___notes_renotes from './endpoints/notes/renotes.js'; import * as ep___notes_replies from './endpoints/notes/replies.js'; import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js'; @@ -302,11 +253,11 @@ import * as ep___notes_timeline from './endpoints/notes/timeline.js'; import * as ep___notes_translate from './endpoints/notes/translate.js'; import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; -import * as ep___notes_edit from './endpoints/notes/edit.js'; -import * as ep___notes_versions from './endpoints/notes/versions.js'; +import * as ep___notes_watching_create from './endpoints/notes/watching/create.js'; +import * as ep___notes_watching_delete from './endpoints/notes/watching/delete.js'; import * as ep___notifications_create from './endpoints/notifications/create.js'; import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; -import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js'; +import * as ep___notifications_read from './endpoints/notifications/read.js'; import * as ep___pagePush from './endpoints/page-push.js'; import * as ep___pages_create from './endpoints/pages/create.js'; import * as ep___pages_delete from './endpoints/pages/delete.js'; @@ -315,29 +266,14 @@ import * as ep___pages_like from './endpoints/pages/like.js'; import * as ep___pages_show from './endpoints/pages/show.js'; import * as ep___pages_unlike from './endpoints/pages/unlike.js'; import * as ep___pages_update from './endpoints/pages/update.js'; -import * as ep___flash_create from './endpoints/flash/create.js'; -import * as ep___flash_delete from './endpoints/flash/delete.js'; -import * as ep___flash_featured from './endpoints/flash/featured.js'; -import * as ep___flash_like from './endpoints/flash/like.js'; -import * as ep___flash_show from './endpoints/flash/show.js'; -import * as ep___flash_unlike from './endpoints/flash/unlike.js'; -import * as ep___flash_update from './endpoints/flash/update.js'; -import * as ep___flash_my from './endpoints/flash/my.js'; -import * as ep___flash_myLikes from './endpoints/flash/my-likes.js'; import * as ep___ping from './endpoints/ping.js'; import * as ep___pinnedUsers from './endpoints/pinned-users.js'; import * as ep___promo_read from './endpoints/promo/read.js'; -import * as ep___roles_list from './endpoints/roles/list.js'; -import * as ep___roles_show from './endpoints/roles/show.js'; -import * as ep___roles_users from './endpoints/roles/users.js'; -import * as ep___roles_notes from './endpoints/roles/notes.js'; import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; import * as ep___resetDb from './endpoints/reset-db.js'; import * as ep___resetPassword from './endpoints/reset-password.js'; import * as ep___serverInfo from './endpoints/server-info.js'; import * as ep___stats from './endpoints/stats.js'; -import * as ep___sw_show_registration from './endpoints/sw/show-registration.js'; -import * as ep___sw_update_registration from './endpoints/sw/update-registration.js'; import * as ep___sw_register from './endpoints/sw/register.js'; import * as ep___sw_unregister from './endpoints/sw/unregister.js'; import * as ep___test from './endpoints/test.js'; @@ -348,22 +284,27 @@ import * as ep___users_followers from './endpoints/users/followers.js'; import * as ep___users_following from './endpoints/users/following.js'; import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js'; import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js'; -import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js'; +import * as ep___users_groups_create from './endpoints/users/groups/create.js'; +import * as ep___users_groups_delete from './endpoints/users/groups/delete.js'; +import * as ep___users_groups_invitations_accept from './endpoints/users/groups/invitations/accept.js'; +import * as ep___users_groups_invitations_reject from './endpoints/users/groups/invitations/reject.js'; +import * as ep___users_groups_invite from './endpoints/users/groups/invite.js'; +import * as ep___users_groups_joined from './endpoints/users/groups/joined.js'; +import * as ep___users_groups_leave from './endpoints/users/groups/leave.js'; +import * as ep___users_groups_owned from './endpoints/users/groups/owned.js'; +import * as ep___users_groups_pull from './endpoints/users/groups/pull.js'; +import * as ep___users_groups_show from './endpoints/users/groups/show.js'; +import * as ep___users_groups_transfer from './endpoints/users/groups/transfer.js'; +import * as ep___users_groups_update from './endpoints/users/groups/update.js'; import * as ep___users_lists_create from './endpoints/users/lists/create.js'; import * as ep___users_lists_delete from './endpoints/users/lists/delete.js'; import * as ep___users_lists_list from './endpoints/users/lists/list.js'; import * as ep___users_lists_pull from './endpoints/users/lists/pull.js'; import * as ep___users_lists_push from './endpoints/users/lists/push.js'; import * as ep___users_lists_show from './endpoints/users/lists/show.js'; -import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js'; -import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js'; -import * as ep___users_lists_createFromPublic from './endpoints/users/lists/create-from-public.js'; import * as ep___users_lists_update from './endpoints/users/lists/update.js'; -import * as ep___users_lists_updateMembership from './endpoints/users/lists/update-membership.js'; -import * as ep___users_lists_getMemberships from './endpoints/users/lists/get-memberships.js'; import * as ep___users_notes from './endpoints/users/notes.js'; import * as ep___users_pages from './endpoints/users/pages.js'; -import * as ep___users_flashs from './endpoints/users/flashs.js'; import * as ep___users_reactions from './endpoints/users/reactions.js'; import * as ep___users_recommendation from './endpoints/users/recommendation.js'; import * as ep___users_relation from './endpoints/users/relation.js'; @@ -371,19 +312,15 @@ import * as ep___users_reportAbuse from './endpoints/users/report-abuse.js'; import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by-username-and-host.js'; import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; -import * as ep___users_achievements from './endpoints/users/achievements.js'; -import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; +import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; -import * as ep___fetchExternalResources from './endpoints/fetch-external-resources.js'; -import * as ep___retention from './endpoints/retention.js'; -import * as ep___sponsors from './endpoints/sponsors.js'; +import * as ep___admin_driveCapOverride from './endpoints/admin/drive-capacity-override.js'; const eps = [ ['admin/meta', ep___admin_meta], ['admin/abuse-user-reports', ep___admin_abuseUserReports], ['admin/accounts/create', ep___admin_accounts_create], ['admin/accounts/delete', ep___admin_accounts_delete], - ['admin/accounts/find-by-email', ep___admin_accounts_findByEmail], ['admin/ad/create', ep___admin_ad_create], ['admin/ad/delete', ep___admin_ad_delete], ['admin/ad/list', ep___admin_ad_list], @@ -392,13 +329,7 @@ const eps = [ ['admin/announcements/delete', ep___admin_announcements_delete], ['admin/announcements/list', ep___admin_announcements_list], ['admin/announcements/update', ep___admin_announcements_update], - ['admin/avatar-decorations/create', ep___admin_avatarDecorations_create], - ['admin/avatar-decorations/delete', ep___admin_avatarDecorations_delete], - ['admin/avatar-decorations/list', ep___admin_avatarDecorations_list], - ['admin/avatar-decorations/update', ep___admin_avatarDecorations_update], ['admin/delete-all-files-of-a-user', ep___admin_deleteAllFilesOfAUser], - ['admin/unset-user-avatar', ep___admin_unsetUserAvatar], - ['admin/unset-user-banner', ep___admin_unsetUserBanner], ['admin/drive/clean-remote-files', ep___admin_drive_cleanRemoteFiles], ['admin/drive/cleanup', ep___admin_drive_cleanup], ['admin/drive/files', ep___admin_drive_files], @@ -414,7 +345,6 @@ const eps = [ ['admin/emoji/remove-aliases-bulk', ep___admin_emoji_removeAliasesBulk], ['admin/emoji/set-aliases-bulk', ep___admin_emoji_setAliasesBulk], ['admin/emoji/set-category-bulk', ep___admin_emoji_setCategoryBulk], - ['admin/emoji/set-license-bulk', ep___admin_emoji_setLicenseBulk], ['admin/emoji/update', ep___admin_emoji_update], ['admin/federation/delete-all-files', ep___admin_federation_deleteAllFiles], ['admin/federation/refresh-remote-instance-metadata', ep___admin_federation_refreshRemoteInstanceMetadata], @@ -423,13 +353,13 @@ const eps = [ ['admin/get-index-stats', ep___admin_getIndexStats], ['admin/get-table-stats', ep___admin_getTableStats], ['admin/get-user-ips', ep___admin_getUserIps], - ['admin/invite/create', ep___admin_invite_create], - ['admin/invite/list', ep___admin_invite_list], + ['admin/invite', ep___admin_invite], + ['admin/moderators/add', ep___admin_moderators_add], + ['admin/moderators/remove', ep___admin_moderators_remove], ['admin/promo/create', ep___admin_promo_create], ['admin/queue/clear', ep___admin_queue_clear], ['admin/queue/deliver-delayed', ep___admin_queue_deliverDelayed], ['admin/queue/inbox-delayed', ep___admin_queue_inboxDelayed], - ['admin/queue/promote', ep___admin_queue_promote], ['admin/queue/stats', ep___admin_queue_stats], ['admin/relays/add', ep___admin_relays_add], ['admin/relays/list', ep___admin_relays_list], @@ -441,25 +371,14 @@ const eps = [ ['admin/show-moderation-logs', ep___admin_showModerationLogs], ['admin/show-user', ep___admin_showUser], ['admin/show-users', ep___admin_showUsers], - ['admin/nsfw-user', ep___admin_nsfwUser], - ['admin/unnsfw-user', ep___admin_unnsfwUser], ['admin/silence-user', ep___admin_silenceUser], - ['admin/unsilence-user', ep___admin_unsilenceUser], ['admin/suspend-user', ep___admin_suspendUser], - ['admin/approve-user', ep___admin_approveUser], + ['admin/unsilence-user', ep___admin_unsilenceUser], ['admin/unsuspend-user', ep___admin_unsuspendUser], ['admin/update-meta', ep___admin_updateMeta], + ['admin/vacuum', ep___admin_vacuum], ['admin/delete-account', ep___admin_deleteAccount], ['admin/update-user-note', ep___admin_updateUserNote], - ['admin/roles/create', ep___admin_roles_create], - ['admin/roles/delete', ep___admin_roles_delete], - ['admin/roles/list', ep___admin_roles_list], - ['admin/roles/show', ep___admin_roles_show], - ['admin/roles/update', ep___admin_roles_update], - ['admin/roles/assign', ep___admin_roles_assign], - ['admin/roles/unassign', ep___admin_roles_unassign], - ['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies], - ['admin/roles/users', ep___admin_roles_users], ['announcements', ep___announcements], ['antennas/create', ep___antennas_create], ['antennas/delete', ep___antennas_delete], @@ -487,20 +406,16 @@ const eps = [ ['channels/timeline', ep___channels_timeline], ['channels/unfollow', ep___channels_unfollow], ['channels/update', ep___channels_update], - ['channels/favorite', ep___channels_favorite], - ['channels/unfavorite', ep___channels_unfavorite], - ['channels/my-favorites', ep___channels_myFavorites], - ['channels/search', ep___channels_search], ['charts/active-users', ep___charts_activeUsers], ['charts/ap-request', ep___charts_apRequest], ['charts/drive', ep___charts_drive], ['charts/federation', ep___charts_federation], + ['charts/hashtag', ep___charts_hashtag], ['charts/instance', ep___charts_instance], ['charts/notes', ep___charts_notes], ['charts/user/drive', ep___charts_user_drive], ['charts/user/following', ep___charts_user_following], ['charts/user/notes', ep___charts_user_notes], - ['charts/user/pv', ep___charts_user_pv], ['charts/user/reactions', ep___charts_user_reactions], ['charts/users', ep___charts_users], ['clips/add-note', ep___clips_addNote], @@ -511,9 +426,6 @@ const eps = [ ['clips/notes', ep___clips_notes], ['clips/show', ep___clips_show], ['clips/update', ep___clips_update], - ['clips/favorite', ep___clips_favorite], - ['clips/unfavorite', ep___clips_unfavorite], - ['clips/my-favorites', ep___clips_myFavorites], ['drive', ep___drive], ['drive/files', ep___drive_files], ['drive/files/attached-notes', ep___drive_files_attachedNotes], @@ -545,8 +457,6 @@ const eps = [ ['federation/stats', ep___federation_stats], ['following/create', ep___following_create], ['following/delete', ep___following_delete], - ['following/update', ep___following_update], - ['following/update-all', ep___following_update_all], ['following/invalidate', ep___following_invalidate], ['following/requests/accept', ep___following_requests_accept], ['following/requests/cancel', ep___following_requests_cancel], @@ -562,7 +472,6 @@ const eps = [ ['gallery/posts/unlike', ep___gallery_posts_unlike], ['gallery/posts/update', ep___gallery_posts_update], ['get-online-users-count', ep___getOnlineUsersCount], - ['get-avatar-decorations', ep___getAvatarDecorations], ['hashtags/list', ep___hashtags_list], ['hashtags/search', ep___hashtags_search], ['hashtags/show', ep___hashtags_show], @@ -574,73 +483,62 @@ const eps = [ ['i/2fa/password-less', ep___i_2fa_passwordLess], ['i/2fa/register-key', ep___i_2fa_registerKey], ['i/2fa/register', ep___i_2fa_register], - ['i/2fa/update-key', ep___i_2fa_updateKey], ['i/2fa/remove-key', ep___i_2fa_removeKey], ['i/2fa/unregister', ep___i_2fa_unregister], ['i/apps', ep___i_apps], ['i/authorized-apps', ep___i_authorizedApps], - ['i/claim-achievement', ep___i_claimAchievement], ['i/change-password', ep___i_changePassword], ['i/delete-account', ep___i_deleteAccount], - ['i/export-data', ep___i_exportData], ['i/export-blocking', ep___i_exportBlocking], ['i/export-following', ep___i_exportFollowing], ['i/export-mute', ep___i_exportMute], ['i/export-notes', ep___i_exportNotes], - ['i/export-favorites', ep___i_exportFavorites], ['i/export-user-lists', ep___i_exportUserLists], - ['i/export-antennas', ep___i_exportAntennas], ['i/favorites', ep___i_favorites], ['i/gallery/likes', ep___i_gallery_likes], ['i/gallery/posts', ep___i_gallery_posts], + ['i/get-word-muted-notes-count', ep___i_getWordMutedNotesCount], ['i/import-blocking', ep___i_importBlocking], ['i/import-following', ep___i_importFollowing], - ['i/import-notes', ep___i_importNotes], ['i/import-muting', ep___i_importMuting], ['i/import-user-lists', ep___i_importUserLists], - ['i/import-antennas', ep___i_importAntennas], ['i/notifications', ep___i_notifications], - ['i/notifications-grouped', ep___i_notificationsGrouped], ['i/page-likes', ep___i_pageLikes], ['i/pages', ep___i_pages], ['i/pin', ep___i_pin], + ['i/read-all-messaging-messages', ep___i_readAllMessagingMessages], ['i/read-all-unread-notes', ep___i_readAllUnreadNotes], ['i/read-announcement', ep___i_readAnnouncement], ['i/regenerate-token', ep___i_regenerateToken], ['i/registry/get-all', ep___i_registry_getAll], - ['i/registry/get-unsecure', ep___i_registry_getUnsecure], ['i/registry/get-detail', ep___i_registry_getDetail], ['i/registry/get', ep___i_registry_get], ['i/registry/keys-with-type', ep___i_registry_keysWithType], ['i/registry/keys', ep___i_registry_keys], ['i/registry/remove', ep___i_registry_remove], - ['i/registry/scopes-with-domain', ep___i_registry_scopesWithDomain], + ['i/registry/scopes', ep___i_registry_scopes], ['i/registry/set', ep___i_registry_set], ['i/revoke-token', ep___i_revokeToken], ['i/signin-history', ep___i_signinHistory], ['i/unpin', ep___i_unpin], ['i/update-email', ep___i_updateEmail], ['i/update', ep___i_update], - ['i/move', ep___i_move], + ['i/user-group-invites', ep___i_userGroupInvites], ['i/webhooks/create', ep___i_webhooks_create], ['i/webhooks/list', ep___i_webhooks_list], ['i/webhooks/show', ep___i_webhooks_show], ['i/webhooks/update', ep___i_webhooks_update], ['i/webhooks/delete', ep___i_webhooks_delete], - ['invite/create', ep___invite_create], - ['invite/delete', ep___invite_delete], - ['invite/list', ep___invite_list], - ['invite/limit', ep___invite_limit], + ['messaging/history', ep___messaging_history], + ['messaging/messages', ep___messaging_messages], + ['messaging/messages/create', ep___messaging_messages_create], + ['messaging/messages/delete', ep___messaging_messages_delete], + ['messaging/messages/read', ep___messaging_messages_read], ['meta', ep___meta], - ['emojis', ep___emojis], - ['emoji', ep___emoji], ['miauth/gen-token', ep___miauth_genToken], ['mute/create', ep___mute_create], ['mute/delete', ep___mute_delete], ['mute/list', ep___mute_list], - ['renote-mute/create', ep___renoteMute_create], - ['renote-mute/delete', ep___renoteMute_delete], - ['renote-mute/list', ep___renoteMute_list], ['my/apps', ep___my_apps], ['notes', ep___notes], ['notes/children', ep___notes_children], @@ -652,7 +550,6 @@ const eps = [ ['notes/favorites/delete', ep___notes_favorites_delete], ['notes/featured', ep___notes_featured], ['notes/global-timeline', ep___notes_globalTimeline], - ['notes/bubble-timeline', ep___notes_bubbleTimeline], ['notes/hybrid-timeline', ep___notes_hybridTimeline], ['notes/local-timeline', ep___notes_localTimeline], ['notes/mentions', ep___notes_mentions], @@ -661,7 +558,6 @@ const eps = [ ['notes/reactions', ep___notes_reactions], ['notes/reactions/create', ep___notes_reactions_create], ['notes/reactions/delete', ep___notes_reactions_delete], - ['notes/like', ep___notes_like], ['notes/renotes', ep___notes_renotes], ['notes/replies', ep___notes_replies], ['notes/search-by-tag', ep___notes_searchByTag], @@ -674,11 +570,11 @@ const eps = [ ['notes/translate', ep___notes_translate], ['notes/unrenote', ep___notes_unrenote], ['notes/user-list-timeline', ep___notes_userListTimeline], - ['notes/edit', ep___notes_edit], - ['notes/versions', ep___notes_versions], + ['notes/watching/create', ep___notes_watching_create], + ['notes/watching/delete', ep___notes_watching_delete], ['notifications/create', ep___notifications_create], ['notifications/mark-all-as-read', ep___notifications_markAllAsRead], - ['notifications/test-notification', ep___notifications_testNotification], + ['notifications/read', ep___notifications_read], ['page-push', ep___pagePush], ['pages/create', ep___pages_create], ['pages/delete', ep___pages_delete], @@ -687,29 +583,14 @@ const eps = [ ['pages/show', ep___pages_show], ['pages/unlike', ep___pages_unlike], ['pages/update', ep___pages_update], - ['flash/create', ep___flash_create], - ['flash/delete', ep___flash_delete], - ['flash/featured', ep___flash_featured], - ['flash/like', ep___flash_like], - ['flash/show', ep___flash_show], - ['flash/unlike', ep___flash_unlike], - ['flash/update', ep___flash_update], - ['flash/my', ep___flash_my], - ['flash/my-likes', ep___flash_myLikes], ['ping', ep___ping], ['pinned-users', ep___pinnedUsers], ['promo/read', ep___promo_read], - ['roles/list', ep___roles_list], - ['roles/show', ep___roles_show], - ['roles/users', ep___roles_users], - ['roles/notes', ep___roles_notes], ['request-reset-password', ep___requestResetPassword], ['reset-db', ep___resetDb], ['reset-password', ep___resetPassword], ['server-info', ep___serverInfo], ['stats', ep___stats], - ['sw/show-registration', ep___sw_show_registration], - ['sw/update-registration', ep___sw_update_registration], ['sw/register', ep___sw_register], ['sw/unregister', ep___sw_unregister], ['test', ep___test], @@ -720,22 +601,27 @@ const eps = [ ['users/following', ep___users_following], ['users/gallery/posts', ep___users_gallery_posts], ['users/get-frequently-replied-users', ep___users_getFrequentlyRepliedUsers], - ['users/featured-notes', ep___users_featuredNotes], + ['users/groups/create', ep___users_groups_create], + ['users/groups/delete', ep___users_groups_delete], + ['users/groups/invitations/accept', ep___users_groups_invitations_accept], + ['users/groups/invitations/reject', ep___users_groups_invitations_reject], + ['users/groups/invite', ep___users_groups_invite], + ['users/groups/joined', ep___users_groups_joined], + ['users/groups/leave', ep___users_groups_leave], + ['users/groups/owned', ep___users_groups_owned], + ['users/groups/pull', ep___users_groups_pull], + ['users/groups/show', ep___users_groups_show], + ['users/groups/transfer', ep___users_groups_transfer], + ['users/groups/update', ep___users_groups_update], ['users/lists/create', ep___users_lists_create], ['users/lists/delete', ep___users_lists_delete], ['users/lists/list', ep___users_lists_list], ['users/lists/pull', ep___users_lists_pull], ['users/lists/push', ep___users_lists_push], ['users/lists/show', ep___users_lists_show], - ['users/lists/favorite', ep___users_lists_favorite], - ['users/lists/unfavorite', ep___users_lists_unfavorite], ['users/lists/update', ep___users_lists_update], - ['users/lists/create-from-public', ep___users_lists_createFromPublic], - ['users/lists/update-membership', ep___users_lists_updateMembership], - ['users/lists/get-memberships', ep___users_lists_getMemberships], ['users/notes', ep___users_notes], ['users/pages', ep___users_pages], - ['users/flashs', ep___users_flashs], ['users/reactions', ep___users_reactions], ['users/recommendation', ep___users_recommendation], ['users/relation', ep___users_relation], @@ -743,15 +629,12 @@ const eps = [ ['users/search-by-username-and-host', ep___users_searchByUsernameAndHost], ['users/search', ep___users_search], ['users/show', ep___users_show], - ['users/achievements', ep___users_achievements], - ['users/update-memo', ep___users_updateMemo], + ['users/stats', ep___users_stats], + ['admin/drive-capacity-override', ep___admin_driveCapOverride], ['fetch-rss', ep___fetchRss], - ['fetch-external-resources', ep___fetchExternalResources], - ['retention', ep___retention], - ['sponsors', ep___sponsors], ]; -interface IEndpointMetaBase { +export interface IEndpointMeta { readonly stability?: 'deprecated' | 'experimental' | 'stable'; readonly tags?: ReadonlyArray; @@ -770,25 +653,17 @@ interface IEndpointMetaBase { * このエンドポイントにリクエストするのにユーザー情報が必須か否か * 省略した場合は false として解釈されます。 */ - readonly requireCredential?: 'always' | 'conditional' | 'never'; + readonly requireCredential?: boolean; /** - * isModeratorなロールを必要とするか - */ - readonly requireModerator?: boolean; - - /** - * isAdministratorなロールを必要とするか + * 管理者のみ使えるエンドポイントか否か */ readonly requireAdmin?: boolean; - readonly requireRolePolicy?: keyof RolePolicies; - /** - * 引っ越し済みのユーザーによるリクエストを禁止するか - * 省略した場合は false として解釈されます。 + * 管理者またはモデレーターのみ使えるエンドポイントか否か */ - readonly prohibitMoved?: boolean; + readonly requireModerator?: boolean; /** * エンドポイントのリミテーションに関するやつ @@ -850,36 +725,20 @@ interface IEndpointMetaBase { readonly cacheSec?: number; } -export type IEndpointMeta = (Omit & { - requireCredential?: 'conditional', - requireAdmin?: false, - requireModerator?: false, -}) | (Omit & { - secure: true, -}) | (Omit & { - requireCredential: 'always', - kind: (typeof permissions)[number], -}) | (Omit & { - requireModerator: true, - kind: (typeof permissions)[number], -}) | (Omit & { - requireAdmin: true, - kind: (typeof permissions)[number], -}) - export interface IEndpoint { name: string; + exec: any; meta: IEndpointMeta; params: Schema; } -const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => { +const endpoints: IEndpoint[] = eps.map(([name, ep]) => { return { name: name, - get meta() { return ep.meta ?? {}; }, - get params() { return ep.paramDef; }, + exec: ep.default, + meta: ep.meta || {}, + params: ep.paramDef, }; }); -// eslint-disable-next-line import/no-default-export export default endpoints; diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts index d18b6c449c..333746f423 100644 --- a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts +++ b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts @@ -1,21 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AbuseUserReportsRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { DI } from '@/di-symbols.js'; -import { AbuseUserReportEntityService } from '@/core/entities/AbuseUserReportEntityService.js'; +import define from '../../define.js'; +import { AbuseUserReports } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'read:admin:abuse-user-reports', res: { type: 'array', @@ -86,43 +77,33 @@ export const paramDef = { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, state: { type: 'string', nullable: true, default: null }, - reporterOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' }, - targetUserOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' }, + reporterOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: "combined" }, + targetUserOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: "combined" }, forwarded: { type: 'boolean', default: false }, }, required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.abuseUserReportsRepository) - private abuseUserReportsRepository: AbuseUserReportsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const query = makePaginationQuery(AbuseUserReports.createQueryBuilder('report'), ps.sinceId, ps.untilId); - private abuseUserReportEntityService: AbuseUserReportEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.abuseUserReportsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId); - - switch (ps.state) { - case 'resolved': query.andWhere('report.resolved = TRUE'); break; - case 'unresolved': query.andWhere('report.resolved = FALSE'); break; - } - - switch (ps.reporterOrigin) { - case 'local': query.andWhere('report.reporterHost IS NULL'); break; - case 'remote': query.andWhere('report.reporterHost IS NOT NULL'); break; - } - - switch (ps.targetUserOrigin) { - case 'local': query.andWhere('report.targetUserHost IS NULL'); break; - case 'remote': query.andWhere('report.targetUserHost IS NOT NULL'); break; - } - - const reports = await query.limit(ps.limit).getMany(); - - return await this.abuseUserReportEntityService.packMany(reports); - }); + switch (ps.state) { + case 'resolved': query.andWhere('report.resolved = TRUE'); break; + case 'unresolved': query.andWhere('report.resolved = FALSE'); break; } -} + + switch (ps.reporterOrigin) { + case 'local': query.andWhere('report.reporterHost IS NULL'); break; + case 'remote': query.andWhere('report.reporterHost IS NOT NULL'); break; + } + + switch (ps.targetUserOrigin) { + case 'local': query.andWhere('report.targetUserHost IS NULL'); break; + case 'remote': query.andWhere('report.targetUserHost IS NOT NULL'); break; + } + + const reports = await query.take(ps.limit).getMany(); + + return await AbuseUserReports.packMany(reports); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts index cce255ac40..5f89219991 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -1,22 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; +import define from '../../../define.js'; +import { Users } from '@/models/index.js'; +import { signup } from '../../../common/signup.js'; import { IsNull } from 'typeorm'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository } from '@/models/_.js'; -import { SignupService } from '@/core/SignupService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { localUsernameSchema, passwordSchema } from '@/models/User.js'; -import { DI } from '@/di-symbols.js'; export const meta = { tags: ['admin'], - requireCredential: 'never', - res: { type: 'object', optional: false, nullable: false, @@ -33,42 +22,31 @@ export const meta = { export const paramDef = { type: 'object', properties: { - username: localUsernameSchema, - password: passwordSchema, + username: Users.localUsernameSchema, + password: Users.passwordSchema, }, required: ['username', 'password'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, _me) => { + const me = _me ? await Users.findOneByOrFail({ id: _me.id }) : null; + const noUsers = (await Users.countBy({ + host: IsNull(), + })) === 0; + if (!noUsers && !me?.isAdmin) throw new Error('access denied'); - private userEntityService: UserEntityService, - private signupService: SignupService, - ) { - super(meta, paramDef, async (ps, _me, token) => { - const me = _me ? await this.usersRepository.findOneByOrFail({ id: _me.id }) : null; - const noUsers = (await this.usersRepository.countBy({ - host: IsNull(), - })) === 0; - if ((!noUsers && !me?.isRoot) || token !== null) throw new Error('access denied'); + const { account, secret } = await signup({ + username: ps.username, + password: ps.password, + }); - const { account, secret } = await this.signupService.signup({ - username: ps.username, - password: ps.password, - ignorePreservedUsernames: true, - }); + const res = await Users.pack(account, account, { + detail: true, + includeSecrets: true, + }); - const res = await this.userEntityService.pack(account, account, { - detail: true, - includeSecrets: true, - }); + (res as any).token = secret; - (res as any).token = secret; - - return res; - }); - } -} + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts index ee1a5d91ba..629d700582 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts @@ -1,22 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository } from '@/models/_.js'; -import { QueueService } from '@/core/QueueService.js'; -import { UserSuspendService } from '@/core/UserSuspendService.js'; -import { DI } from '@/di-symbols.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import define from '../../../define.js'; +import { Users } from '@/models/index.js'; +import { doPostSuspend } from '@/services/suspend-user.js'; +import { publishUserEvent } from '@/services/stream.js'; +import { createDeleteAccountJob } from '@/queue/index.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', - requireAdmin: true, - kind: 'write:admin:account', + requireCredential: true, + requireModerator: true, } as const; export const paramDef = { @@ -27,43 +19,41 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy({ id: ps.userId }); - private userEntityService: UserEntityService, - private queueService: QueueService, - private userSuspendService: UserSuspendService, - ) { - super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); + if (user == null) { + throw new Error('user not found'); + } - if (user == null) { - throw new Error('user not found'); - } + if (user.isAdmin) { + throw new Error('cannot suspend admin'); + } - if (user.isRoot) { - throw new Error('cannot delete a root account'); - } + if (user.isModerator) { + throw new Error('cannot suspend moderator'); + } - if (this.userEntityService.isLocalUser(user)) { - // 物理削除する前にDelete activityを送信する - await this.userSuspendService.doPostSuspend(user).catch(err => {}); + if (Users.isLocalUser(user)) { + // 物理削除する前にDelete activityを送信する + await doPostSuspend(user).catch(e => {}); - this.queueService.createDeleteAccountJob(user, { - soft: false, - }); - } else { - this.queueService.createDeleteAccountJob(user, { - soft: true, // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する - }); - } - - await this.usersRepository.update(user.id, { - isDeleted: true, - }); + createDeleteAccountJob(user, { + soft: false, + }); + } else { + createDeleteAccountJob(user, { + soft: true, // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する }); } -} + + await Users.update(user.id, { + isDeleted: true, + }); + + if (Users.isLocalUser(user)) { + // Terminate streaming + publishUserEvent(user.id, 'terminate', {}); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/find-by-email.ts b/packages/backend/src/server/api/endpoints/admin/accounts/find-by-email.ts deleted file mode 100644 index b9689eb21d..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/accounts/find-by-email.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UserProfilesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { ApiError } from '@/server/api/error.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: 'always', - requireAdmin: true, - kind: 'read:admin:account', - - errors: { - userNotFound: { - message: 'No such user who has the email address.', - code: 'USER_NOT_FOUND', - id: 'cb865949-8af5-4062-a88c-ef55e8786d1d', - }, - }, - res: { - type: 'object', - optional: false, nullable: false, - ref: 'User', - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - email: { type: 'string' }, - }, - required: ['email'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - private userEntityService: UserEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const profile = await this.userProfilesRepository.findOne({ - where: { email: ps.email }, - relations: ['user'], - }); - - if (profile == null) { - throw new ApiError(meta.errors.userNotFound); - } - - const res = await this.userEntityService.pack(profile.user!, null, { - detail: true, - }); - - return res; - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/ad/create.ts b/packages/backend/src/server/api/endpoints/admin/ad/create.ts index 4906234fda..ab2c50b50f 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/create.ts @@ -1,27 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AdsRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { DI } from '@/di-symbols.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; +import define from '../../../define.js'; +import { Ads } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:ad', - res: { - type: 'object', - optional: false, - nullable: false, - ref: 'Ad', - }, } as const; export const paramDef = { @@ -33,53 +18,22 @@ export const paramDef = { priority: { type: 'string' }, ratio: { type: 'integer' }, expiresAt: { type: 'integer' }, - startsAt: { type: 'integer' }, imageUrl: { type: 'string', minLength: 1 }, - dayOfWeek: { type: 'integer' }, }, - required: ['url', 'memo', 'place', 'priority', 'ratio', 'expiresAt', 'startsAt', 'imageUrl', 'dayOfWeek'], + required: ['url', 'memo', 'place', 'priority', 'ratio', 'expiresAt', 'imageUrl'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.adsRepository) - private adsRepository: AdsRepository, - - private idService: IdService, - private moderationLogService: ModerationLogService, - ) { - super(meta, paramDef, async (ps, me) => { - const ad = await this.adsRepository.insert({ - id: this.idService.gen(), - expiresAt: new Date(ps.expiresAt), - startsAt: new Date(ps.startsAt), - dayOfWeek: ps.dayOfWeek, - url: ps.url, - imageUrl: ps.imageUrl, - priority: ps.priority, - ratio: ps.ratio, - place: ps.place, - memo: ps.memo, - }).then(r => this.adsRepository.findOneByOrFail({ id: r.identifiers[0].id })); - - this.moderationLogService.log(me, 'createAd', { - adId: ad.id, - ad: ad, - }); - - return { - id: ad.id, - expiresAt: ad.expiresAt.toISOString(), - startsAt: ad.startsAt.toISOString(), - dayOfWeek: ad.dayOfWeek, - url: ad.url, - imageUrl: ad.imageUrl, - priority: ad.priority, - ratio: ad.ratio, - place: ad.place, - memo: ad.memo, - }; - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + await Ads.insert({ + id: genId(), + createdAt: new Date(), + expiresAt: new Date(ps.expiresAt), + url: ps.url, + imageUrl: ps.imageUrl, + priority: ps.priority, + ratio: ps.ratio, + place: ps.place, + memo: ps.memo, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/ad/delete.ts b/packages/backend/src/server/api/endpoints/admin/ad/delete.ts index 79c7f6c5df..0ead2be005 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/delete.ts @@ -1,21 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AdsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; +import define from '../../../define.js'; +import { Ads } from '@/models/index.js'; import { ApiError } from '../../../error.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:ad', errors: { noSuchAd: { @@ -34,25 +25,11 @@ export const paramDef = { required: ['id'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.adsRepository) - private adsRepository: AdsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const ad = await Ads.findOneBy({ id: ps.id }); - private moderationLogService: ModerationLogService, - ) { - super(meta, paramDef, async (ps, me) => { - const ad = await this.adsRepository.findOneBy({ id: ps.id }); + if (ad == null) throw new ApiError(meta.errors.noSuchAd); - if (ad == null) throw new ApiError(meta.errors.noSuchAd); - - await this.adsRepository.delete(ad.id); - - this.moderationLogService.log(me, 'deleteAd', { - adId: ad.id, - ad: ad, - }); - }); - } -} + await Ads.delete(ad.id); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/ad/list.ts b/packages/backend/src/server/api/endpoints/admin/ad/list.ts index e70649d5a6..74f154f272 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/list.ts @@ -1,31 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AdsRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; +import { Ads } from '@/models/index.js'; +import { makePaginationQuery } from '../../../common/make-pagination-query.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'read:admin:ad', - res: { - type: 'array', - optional: false, - nullable: false, - items: { - type: 'object', - optional: false, - nullable: false, - ref: 'Ad', - }, - }, } as const; export const paramDef = { @@ -34,40 +15,16 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, - publishing: { type: 'boolean', default: null, nullable: true }, }, required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.adsRepository) - private adsRepository: AdsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const query = makePaginationQuery(Ads.createQueryBuilder('ad'), ps.sinceId, ps.untilId) + .andWhere('ad.expiresAt > :now', { now: new Date() }); - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.adsRepository.createQueryBuilder('ad'), ps.sinceId, ps.untilId); - if (ps.publishing === true) { - query.andWhere('ad.expiresAt > :now', { now: new Date() }).andWhere('ad.startsAt <= :now', { now: new Date() }); - } else if (ps.publishing === false) { - query.andWhere('ad.expiresAt <= :now', { now: new Date() }).orWhere('ad.startsAt > :now', { now: new Date() }); - } - const ads = await query.limit(ps.limit).getMany(); + const ads = await query.take(ps.limit).getMany(); - return ads.map(ad => ({ - id: ad.id, - expiresAt: ad.expiresAt.toISOString(), - startsAt: ad.startsAt.toISOString(), - dayOfWeek: ad.dayOfWeek, - url: ad.url, - imageUrl: ad.imageUrl, - memo: ad.memo, - place: ad.place, - priority: ad.priority, - ratio: ad.ratio, - })); - }); - } -} + return ads; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/ad/update.ts b/packages/backend/src/server/api/endpoints/admin/ad/update.ts index e8c7cea716..650f8670e3 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/update.ts @@ -1,21 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AdsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; +import define from '../../../define.js'; +import { Ads } from '@/models/index.js'; import { ApiError } from '../../../error.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:ad', errors: { noSuchAd: { @@ -37,44 +28,23 @@ export const paramDef = { priority: { type: 'string' }, ratio: { type: 'integer' }, expiresAt: { type: 'integer' }, - startsAt: { type: 'integer' }, - dayOfWeek: { type: 'integer' }, }, - required: ['id', 'memo', 'url', 'imageUrl', 'place', 'priority', 'ratio', 'expiresAt', 'startsAt', 'dayOfWeek'], + required: ['id', 'memo', 'url', 'imageUrl', 'place', 'priority', 'ratio', 'expiresAt'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.adsRepository) - private adsRepository: AdsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const ad = await Ads.findOneBy({ id: ps.id }); - private moderationLogService: ModerationLogService, - ) { - super(meta, paramDef, async (ps, me) => { - const ad = await this.adsRepository.findOneBy({ id: ps.id }); + if (ad == null) throw new ApiError(meta.errors.noSuchAd); - if (ad == null) throw new ApiError(meta.errors.noSuchAd); - - await this.adsRepository.update(ad.id, { - url: ps.url, - place: ps.place, - priority: ps.priority, - ratio: ps.ratio, - memo: ps.memo, - imageUrl: ps.imageUrl, - expiresAt: new Date(ps.expiresAt), - startsAt: new Date(ps.startsAt), - dayOfWeek: ps.dayOfWeek, - }); - - const updatedAd = await this.adsRepository.findOneByOrFail({ id: ad.id }); - - this.moderationLogService.log(me, 'updateAd', { - adId: ad.id, - before: ad, - after: updatedAd, - }); - }); - } -} + await Ads.update(ad.id, { + url: ps.url, + place: ps.place, + priority: ps.priority, + ratio: ps.ratio, + memo: ps.memo, + imageUrl: ps.imageUrl, + expiresAt: new Date(ps.expiresAt), + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts index 32f96dce63..33076b6d30 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -1,18 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { AnnouncementService } from '@/core/AnnouncementService.js'; +import define from '../../../define.js'; +import { Announcements } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:announcements', res: { type: 'object', @@ -56,36 +50,20 @@ export const paramDef = { title: { type: 'string', minLength: 1 }, text: { type: 'string', minLength: 1 }, imageUrl: { type: 'string', nullable: true, minLength: 1 }, - icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'], default: 'info' }, - display: { type: 'string', enum: ['normal', 'banner', 'dialog'], default: 'normal' }, - forExistingUsers: { type: 'boolean', default: false }, - silence: { type: 'boolean', default: false }, - needConfirmationToRead: { type: 'boolean', default: false }, - userId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, }, required: ['title', 'text', 'imageUrl'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private announcementService: AnnouncementService, - ) { - super(meta, paramDef, async (ps, me) => { - const { raw, packed } = await this.announcementService.create({ - updatedAt: null, - title: ps.title, - text: ps.text, - imageUrl: ps.imageUrl, - icon: ps.icon, - display: ps.display, - forExistingUsers: ps.forExistingUsers, - silence: ps.silence, - needConfirmationToRead: ps.needConfirmationToRead, - userId: ps.userId, - }, me); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const announcement = await Announcements.insert({ + id: genId(), + createdAt: new Date(), + updatedAt: null, + title: ps.title, + text: ps.text, + imageUrl: ps.imageUrl, + }).then(x => Announcements.findOneByOrFail(x.identifiers[0])); - return packed; - }); - } -} + return Object.assign({}, announcement, { createdAt: announcement.createdAt.toISOString(), updatedAt: null }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts b/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts index 507e62562f..c17765f4fc 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts @@ -1,21 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AnnouncementsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { AnnouncementService } from '@/core/AnnouncementService.js'; +import define from '../../../define.js'; +import { Announcements } from '@/models/index.js'; import { ApiError } from '../../../error.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:announcements', errors: { noSuchAnnouncement: { @@ -34,20 +25,11 @@ export const paramDef = { required: ['id'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.announcementsRepository) - private announcementsRepository: AnnouncementsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const announcement = await Announcements.findOneBy({ id: ps.id }); - private announcementService: AnnouncementService, - ) { - super(meta, paramDef, async (ps, me) => { - const announcement = await this.announcementsRepository.findOneBy({ id: ps.id }); + if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); - if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); - - await this.announcementService.delete(announcement, me); - }); - } -} + await Announcements.delete(announcement.id); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts index e04324be79..7a5758d75b 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -1,22 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/_.js'; -import type { MiAnnouncement } from '@/models/Announcement.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { DI } from '@/di-symbols.js'; -import { IdService } from '@/core/IdService.js'; +import { Announcements, AnnouncementReads } from '@/models/index.js'; +import { Announcement } from '@/models/entities/announcement.js'; +import define from '../../../define.js'; +import { makePaginationQuery } from '../../../common/make-pagination-query.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'read:admin:announcements', res: { type: 'array', @@ -68,58 +59,31 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, - userId: { type: 'string', format: 'misskey:id', nullable: true }, }, required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.announcementsRepository) - private announcementsRepository: AnnouncementsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const query = makePaginationQuery(Announcements.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); - @Inject(DI.announcementReadsRepository) - private announcementReadsRepository: AnnouncementReadsRepository, + const announcements = await query.take(ps.limit).getMany(); - private queryService: QueryService, - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); - query.andWhere('announcement.isActive = true'); - if (ps.userId) { - query.andWhere('announcement.userId = :userId', { userId: ps.userId }); - } else { - query.andWhere('announcement.userId IS NULL'); - } + const reads = new Map(); - const announcements = await query.limit(ps.limit).getMany(); - - const reads = new Map(); - - for (const announcement of announcements) { - reads.set(announcement, await this.announcementReadsRepository.countBy({ - announcementId: announcement.id, - })); - } - - return announcements.map(announcement => ({ - id: announcement.id, - createdAt: this.idService.parse(announcement.id).date.toISOString(), - updatedAt: announcement.updatedAt?.toISOString() ?? null, - title: announcement.title, - text: announcement.text, - imageUrl: announcement.imageUrl, - icon: announcement.icon, - display: announcement.display, - isActive: announcement.isActive, - forExistingUsers: announcement.forExistingUsers, - silence: announcement.silence, - needConfirmationToRead: announcement.needConfirmationToRead, - userId: announcement.userId, - reads: reads.get(announcement)!, - })); - }); + for (const announcement of announcements) { + reads.set(announcement, await AnnouncementReads.countBy({ + announcementId: announcement.id, + })); } -} + + return announcements.map(announcement => ({ + id: announcement.id, + createdAt: announcement.createdAt.toISOString(), + updatedAt: announcement.updatedAt?.toISOString() ?? null, + title: announcement.title, + text: announcement.text, + imageUrl: announcement.imageUrl, + reads: reads.get(announcement)!, + })); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts index b72a5c8be8..61ce106d88 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts @@ -1,21 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AnnouncementsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { AnnouncementService } from '@/core/AnnouncementService.js'; +import define from '../../../define.js'; +import { Announcements } from '@/models/index.js'; import { ApiError } from '../../../error.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:announcements', errors: { noSuchAnnouncement: { @@ -32,43 +23,21 @@ export const paramDef = { id: { type: 'string', format: 'misskey:id' }, title: { type: 'string', minLength: 1 }, text: { type: 'string', minLength: 1 }, - imageUrl: { type: 'string', nullable: true, minLength: 0 }, - icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'] }, - display: { type: 'string', enum: ['normal', 'banner', 'dialog'] }, - forExistingUsers: { type: 'boolean' }, - silence: { type: 'boolean' }, - needConfirmationToRead: { type: 'boolean' }, - isActive: { type: 'boolean' }, + imageUrl: { type: 'string', nullable: true, minLength: 1 }, }, - required: ['id'], + required: ['id', 'title', 'text', 'imageUrl'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.announcementsRepository) - private announcementsRepository: AnnouncementsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const announcement = await Announcements.findOneBy({ id: ps.id }); - private announcementService: AnnouncementService, - ) { - super(meta, paramDef, async (ps, me) => { - const announcement = await this.announcementsRepository.findOneBy({ id: ps.id }); + if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); - if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); - - await this.announcementService.update(announcement, { - updatedAt: new Date(), - title: ps.title, - text: ps.text, - /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */ - imageUrl: ps.imageUrl || null, - display: ps.display, - icon: ps.icon, - forExistingUsers: ps.forExistingUsers, - silence: ps.silence, - needConfirmationToRead: ps.needConfirmationToRead, - isActive: ps.isActive, - }, me); - }); - } -} + await Announcements.update(announcement.id, { + updatedAt: new Date(), + title: ps.title, + text: ps.text, + imageUrl: ps.imageUrl, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/approve-user.ts b/packages/backend/src/server/api/endpoints/admin/approve-user.ts deleted file mode 100644 index 02a8dbf1f2..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/approve-user.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UserProfilesRepository, UsersRepository } from '@/models/_.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { DI } from '@/di-symbols.js'; -import { EmailService } from '@/core/EmailService.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: 'always', - requireModerator: true, - kind: 'write:admin:approve-user', -} as const; - -export const paramDef = { - type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - private moderationLogService: ModerationLogService, - private emailService: EmailService, - ) { - super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new Error('user not found'); - } - - const profile = await this.userProfilesRepository.findOneBy({ userId: ps.userId }); - - await this.usersRepository.update(user.id, { - approved: true, - }); - - if (profile?.email) { - this.emailService.sendEmail(profile.email, 'Account Approved', - 'Your Account has been approved have fun socializing!', - 'Your Account has been approved have fun socializing!'); - } - - this.moderationLogService.log(me, 'approve', { - userId: user.id, - userUsername: user.username, - userHost: user.host, - }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts deleted file mode 100644 index 95ae583041..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: 'always', - requireRolePolicy: 'canManageAvatarDecorations', - kind: 'write:admin:avatar-decorations', -} as const; - -export const paramDef = { - type: 'object', - properties: { - name: { type: 'string', minLength: 1 }, - description: { type: 'string' }, - url: { type: 'string', minLength: 1 }, - roleIdsThatCanBeUsedThisDecoration: { type: 'array', items: { - type: 'string', - } }, - }, - required: ['name', 'description', 'url'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private avatarDecorationService: AvatarDecorationService, - ) { - super(meta, paramDef, async (ps, me) => { - await this.avatarDecorationService.create({ - name: ps.name, - description: ps.description, - url: ps.url, - roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration, - }, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts deleted file mode 100644 index 6e2f63e59c..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; -import { ApiError } from '../../../error.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: 'always', - requireRolePolicy: 'canManageAvatarDecorations', - kind: 'write:admin:avatar-decorations', - errors: { - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - id: { type: 'string', format: 'misskey:id' }, - }, - required: ['id'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private avatarDecorationService: AvatarDecorationService, - ) { - super(meta, paramDef, async (ps, me) => { - await this.avatarDecorationService.delete(ps.id, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts deleted file mode 100644 index 2a4463b5af..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/_.js'; -import type { MiAnnouncement } from '@/models/Announcement.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { DI } from '@/di-symbols.js'; -import { IdService } from '@/core/IdService.js'; -import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: 'always', - requireRolePolicy: 'canManageAvatarDecorations', - kind: 'read:admin:avatar-decorations', - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - updatedAt: { - type: 'string', - optional: false, nullable: true, - format: 'date-time', - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - description: { - type: 'string', - optional: false, nullable: false, - }, - url: { - type: 'string', - optional: false, nullable: false, - }, - roleIdsThatCanBeUsedThisDecoration: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - }, - }, - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - userId: { type: 'string', format: 'misskey:id', nullable: true }, - }, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private avatarDecorationService: AvatarDecorationService, - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const avatarDecorations = await this.avatarDecorationService.getAll(true); - - return avatarDecorations.map(avatarDecoration => ({ - id: avatarDecoration.id, - createdAt: this.idService.parse(avatarDecoration.id).date.toISOString(), - updatedAt: avatarDecoration.updatedAt?.toISOString() ?? null, - name: avatarDecoration.name, - description: avatarDecoration.description, - url: avatarDecoration.url, - roleIdsThatCanBeUsedThisDecoration: avatarDecoration.roleIdsThatCanBeUsedThisDecoration, - })); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts deleted file mode 100644 index c661478677..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; -import { ApiError } from '../../../error.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: 'always', - requireRolePolicy: 'canManageAvatarDecorations', - kind: 'write:admin:avatar-decorations', - - errors: { - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - id: { type: 'string', format: 'misskey:id' }, - name: { type: 'string', minLength: 1 }, - description: { type: 'string' }, - url: { type: 'string', minLength: 1 }, - roleIdsThatCanBeUsedThisDecoration: { type: 'array', items: { - type: 'string', - } }, - }, - required: ['id'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private avatarDecorationService: AvatarDecorationService, - ) { - super(meta, paramDef, async (ps, me) => { - await this.avatarDecorationService.update(ps.id, { - name: ps.name, - description: ps.description, - url: ps.url, - roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration, - }, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/delete-account.ts b/packages/backend/src/server/api/endpoints/admin/delete-account.ts index 38576392e7..2d7ef2f236 100644 --- a/packages/backend/src/server/api/endpoints/admin/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/admin/delete-account.ts @@ -1,20 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DeleteAccountService } from '@/core/DeleteAccountService.js'; -import { DI } from '@/di-symbols.js'; +import { Users } from '@/models/index.js'; +import { deleteAccount } from '@/services/delete-account.js'; +import define from '../../define.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireAdmin: true, - kind: 'write:admin:delete-account', res: { }, @@ -28,21 +20,12 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private deleteAccountService: DeleteAccountService, - ) { - super(meta, paramDef, async (ps) => { - const user = await this.usersRepository.findOneByOrFail({ id: ps.userId }); - if (user.isDeleted) { - return; - } - - await this.deleteAccountService.deleteAccount(user); - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const user = await Users.findOneByOrFail({ id: ps.userId }); + if (user.isDeleted) { + return; } -} + + await deleteAccount(user); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts index 46f81a92be..dc1976624d 100644 --- a/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts @@ -1,20 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { DriveService } from '@/core/DriveService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { deleteFile } from '@/services/drive/delete-file.js'; +import { DriveFiles } from '@/models/index.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', - requireAdmin: true, - kind: 'write:admin:delete-all-files-of-a-user', + requireCredential: true, + requireModerator: true, } as const; export const paramDef = { @@ -25,22 +17,13 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const files = await DriveFiles.findBy({ + userId: ps.userId, + }); - private driveService: DriveService, - ) { - super(meta, paramDef, async (ps, me) => { - const files = await this.driveFilesRepository.findBy({ - userId: ps.userId, - }); - - for (const file of files) { - this.driveService.deleteFile(file); - } - }); + for (const file of files) { + deleteFile(file); } -} +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts b/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts new file mode 100644 index 0000000000..a4b29770e1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts @@ -0,0 +1,47 @@ +import define from '../../define.js'; +import { Users } from '@/models/index.js'; +import { User } from '@/models/entities/user.js'; +import { insertModerationLog } from '@/services/insert-moderation-log.js'; +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + overrideMb: { type: 'number', nullable: true }, + }, + required: ['userId', 'overrideMb'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error('user not found'); + } + + if (!Users.isLocalUser(user)) { + throw new Error('user is not local user'); + } + + /*if (user.isAdmin) { + throw new Error('cannot suspend admin'); + } + if (user.isModerator) { + throw new Error('cannot suspend moderator'); + }*/ + + await Users.update(user.id, { + driveCapacityOverrideMb: ps.overrideMb, + }); + + insertModerationLog(me, 'change-drive-capacity-override', { + targetId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts index b141726a2e..bab149532e 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts @@ -1,18 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueueService } from '@/core/QueueService.js'; +import define from '../../../define.js'; +import { createCleanRemoteFilesJob } from '@/queue/index.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:drive', } as const; export const paramDef = { @@ -21,13 +14,7 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private queueService: QueueService, - ) { - super(meta, paramDef, async (ps, me) => { - this.queueService.createCleanRemoteFilesJob(); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + createCleanRemoteFilesJob(); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts b/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts index 9205aaa972..3db942e6cd 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts @@ -1,21 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { IsNull } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { DriveService } from '@/core/DriveService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; +import { deleteFile } from '@/services/drive/delete-file.js'; +import { DriveFiles } from '@/models/index.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:drive', } as const; export const paramDef = { @@ -24,22 +16,13 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const files = await DriveFiles.findBy({ + userId: IsNull(), + }); - private driveService: DriveService, - ) { - super(meta, paramDef, async (ps, me) => { - const files = await this.driveFilesRepository.findBy({ - userId: IsNull(), - }); - - for (const file of files) { - this.driveService.deleteFile(file); - } - }); + for (const file of files) { + deleteFile(file); } -} +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/files.ts b/packages/backend/src/server/api/endpoints/admin/drive/files.ts index 00eb0f0921..ba32aac431 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/files.ts @@ -1,21 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { DI } from '@/di-symbols.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { DriveFiles } from '@/models/index.js'; +import define from '../../../define.js'; +import { makePaginationQuery } from '../../../common/make-pagination-query.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: false, requireModerator: true, - kind: 'read:admin:drive', res: { type: 'array', @@ -47,43 +38,33 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId); - private driveFileEntityService: DriveFileEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.driveFilesRepository.createQueryBuilder('file'), ps.sinceId, ps.untilId); + if (ps.userId) { + query.andWhere('file.userId = :userId', { userId: ps.userId }); + } else { + if (ps.origin === 'local') { + query.andWhere('file.userHost IS NULL'); + } else if (ps.origin === 'remote') { + query.andWhere('file.userHost IS NOT NULL'); + } - if (ps.userId) { - query.andWhere('file.userId = :userId', { userId: ps.userId }); - } else { - if (ps.origin === 'local') { - query.andWhere('file.userHost IS NULL'); - } else if (ps.origin === 'remote') { - query.andWhere('file.userHost IS NOT NULL'); - } - - if (ps.hostname) { - query.andWhere('file.userHost = :hostname', { hostname: ps.hostname }); - } - } - - if (ps.type) { - if (ps.type.endsWith('/*')) { - query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); - } else { - query.andWhere('file.type = :type', { type: ps.type }); - } - } - - const files = await query.limit(ps.limit).getMany(); - - return await this.driveFileEntityService.packMany(files, { detail: true, withUser: true, self: true }); - }); + if (ps.hostname) { + query.andWhere('file.userHost = :hostname', { hostname: ps.hostname }); + } } -} + + if (ps.type) { + if (ps.type.endsWith('/*')) { + query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); + } else { + query.andWhere('file.type = :type', { type: ps.type }); + } + } + + const files = await query.take(ps.limit).getMany(); + + return await DriveFiles.packMany(files, { detail: true, withUser: true, self: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts index 61e726b276..e9117a23c8 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts @@ -1,22 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { DriveFilesRepository, UsersRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; -import { IdService } from '@/core/IdService.js'; +import { DriveFiles } from '@/models/index.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'read:admin:drive', errors: { noSuchFile: { @@ -84,6 +74,23 @@ export const meta = { properties: { type: 'object', optional: false, nullable: false, + properties: { + width: { + type: 'number', + optional: false, nullable: false, + example: 1280, + }, + height: { + type: 'number', + optional: false, nullable: false, + example: 720, + }, + avgColor: { + type: 'string', + optional: true, nullable: false, + example: 'rgb(40,65,87)', + }, + }, }, storedInternal: { type: 'boolean', @@ -107,15 +114,15 @@ export const meta = { }, accessKey: { type: 'string', - optional: false, nullable: true, + optional: false, nullable: false, }, thumbnailAccessKey: { type: 'string', - optional: false, nullable: true, + optional: false, nullable: false, }, webpublicAccessKey: { type: 'string', - optional: false, nullable: true, + optional: false, nullable: false, }, uri: { type: 'string', @@ -145,80 +152,42 @@ export const meta = { export const paramDef = { type: 'object', - properties: { - fileId: { type: 'string', format: 'misskey:id' }, - url: { type: 'string' }, - }, anyOf: [ - { required: ['fileId'] }, - { required: ['url'] }, + { + properties: { + fileId: { type: 'string', format: 'misskey:id' }, + }, + required: ['fileId'], + }, + { + properties: { + url: { type: 'string' }, + }, + required: ['url'], + }, ], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const file = ps.fileId ? await DriveFiles.findOneBy({ id: ps.fileId }) : await DriveFiles.findOne({ + where: [{ + url: ps.url, + }, { + thumbnailUrl: ps.url, + }, { + webpublicUrl: ps.url, + }], + }); - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private roleService: RoleService, - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const file = ps.fileId ? await this.driveFilesRepository.findOneBy({ id: ps.fileId }) : await this.driveFilesRepository.findOne({ - where: [{ - url: ps.url, - }, { - thumbnailUrl: ps.url, - }, { - webpublicUrl: ps.url, - }], - }); - - if (file == null) { - throw new ApiError(meta.errors.noSuchFile); - } - - const owner = file.userId ? await this.usersRepository.findOneByOrFail({ - id: file.userId, - }) : null; - - const iAmModerator = await this.roleService.isModerator(me); - const ownerIsModerator = owner ? await this.roleService.isModerator(owner) : false; - - return { - id: file.id, - userId: file.userId, - userHost: file.userHost, - isLink: file.isLink, - maybePorn: file.maybePorn, - maybeSensitive: file.maybeSensitive, - isSensitive: file.isSensitive, - folderId: file.folderId, - src: file.src, - uri: file.uri, - webpublicAccessKey: file.webpublicAccessKey, - thumbnailAccessKey: file.thumbnailAccessKey, - accessKey: file.accessKey, - webpublicType: file.webpublicType, - webpublicUrl: file.webpublicUrl, - thumbnailUrl: file.thumbnailUrl, - url: file.url, - storedInternal: file.storedInternal, - properties: file.properties, - blurhash: file.blurhash, - comment: file.comment, - size: file.size, - type: file.type, - name: file.name, - md5: file.md5, - createdAt: this.idService.parse(file.id).date.toISOString(), - requestIp: iAmModerator ? file.requestIp : null, - requestHeaders: iAmModerator && !ownerIsModerator ? file.requestHeaders : null, - }; - }); + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); } -} + + if (!me.isAdmin) { + delete file.requestIp; + delete file.requestHeaders; + } + + return file; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts index 3724e1fdb4..232fbbd573 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts @@ -1,18 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import define from '../../../define.js'; +import { Emojis } from '@/models/index.js'; +import { In } from 'typeorm'; +import { ApiError } from '../../../error.js'; +import { db } from '@/db/postgre.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', - requireRolePolicy: 'canManageCustomEmojis', - kind: 'write:admin:emoji', + requireCredential: true, + requireModerator: true, } as const; export const paramDef = { @@ -28,13 +24,18 @@ export const paramDef = { required: ['ids', 'aliases'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private customEmojiService: CustomEmojiService, - ) { - super(meta, paramDef, async (ps, me) => { - await this.customEmojiService.addAliasesBulk(ps.ids, ps.aliases); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const emojis = await Emojis.findBy({ + id: In(ps.ids), + }); + + for (const emoji of emojis) { + await Emojis.update(emoji.id, { + updatedAt: new Date(), + aliases: [...new Set(emoji.aliases.concat(ps.aliases))], }); } -} + + await db.queryResultCache!.remove(['meta_emojis']); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts index ff5c3f11c2..67349c24e0 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -1,95 +1,66 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { CustomEmojiService } from '@/core/CustomEmojiService.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import define from '../../../define.js'; +import { Emojis, DriveFiles } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { insertModerationLog } from '@/services/insert-moderation-log.js'; import { ApiError } from '../../../error.js'; +import rndstr from 'rndstr'; +import { publishBroadcastStream } from '@/services/stream.js'; +import { db } from '@/db/postgre.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', - requireRolePolicy: 'canManageCustomEmojis', - kind: 'write:admin:emoji', + requireCredential: true, + requireModerator: true, errors: { noSuchFile: { message: 'No such file.', - code: 'NO_SUCH_FILE', + code: 'MO_SUCH_FILE', id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf', }, - duplicateName: { - message: 'Duplicate name.', - code: 'DUPLICATE_NAME', - id: 'f7a3462c-4e6e-4069-8421-b9bd4f4c3975', - }, }, - - ref: 'EmojiDetailed', } as const; export const paramDef = { type: 'object', properties: { - name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, fileId: { type: 'string', format: 'misskey:id' }, - category: { - type: 'string', - nullable: true, - description: 'Use `null` to reset the category.', - }, - aliases: { type: 'array', items: { - type: 'string', - } }, - license: { type: 'string', nullable: true }, - isSensitive: { type: 'boolean' }, - localOnly: { type: 'boolean' }, - roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { - type: 'string', - } }, }, - required: ['name', 'fileId'], + required: ['fileId'], } as const; -// TODO: ロジックをサービスに切り出す +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const file = await DriveFiles.findOneBy({ id: ps.fileId }); -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, + if (file == null) throw new ApiError(meta.errors.noSuchFile); - private customEmojiService: CustomEmojiService, + const name = file.name.split('.')[0].match(/^[a-z0-9_]+$/) ? file.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`; - private emojiEntityService: EmojiEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); - if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); - const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); - if (isDuplicate) throw new ApiError(meta.errors.duplicateName); + const emoji = await Emojis.insert({ + id: genId(), + updatedAt: new Date(), + name: name, + category: null, + host: null, + aliases: [], + originalUrl: file.url, + publicUrl: file.webpublicUrl ?? file.url, + type: file.webpublicType ?? file.type, + }).then(x => Emojis.findOneByOrFail(x.identifiers[0])); - if (driveFile.user !== null) await this.driveFilesRepository.update(driveFile.id, { user: null }); + await db.queryResultCache!.remove(['meta_emojis']); - const emoji = await this.customEmojiService.add({ - driveFile, - name: ps.name, - category: ps.category ?? null, - aliases: ps.aliases ?? [], - host: null, - license: ps.license ?? null, - isSensitive: ps.isSensitive ?? false, - localOnly: ps.localOnly ?? false, - roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [], - }, me); + publishBroadcastStream('emojiAdded', { + emoji: await Emojis.pack(emoji.id), + }); - return this.emojiEntityService.packDetailed(emoji); - }); - } -} + insertModerationLog(me, 'addEmoji', { + emojiId: emoji.id, + }); + + return { + id: emoji.id, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts index 48771074a5..7010ade0d8 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -1,25 +1,17 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull } from 'typeorm'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/_.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import { DI } from '@/di-symbols.js'; -import { DriveService } from '@/core/DriveService.js'; -import { CustomEmojiService } from '@/core/CustomEmojiService.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import define from '../../../define.js'; +import { Emojis } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; import { ApiError } from '../../../error.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { uploadFromUrl } from '@/services/drive/upload-from-url.js'; +import { publishBroadcastStream } from '@/services/stream.js'; +import { db } from '@/db/postgre.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', - requireRolePolicy: 'canManageCustomEmojis', - kind: 'write:admin:emoji', + requireCredential: true, + requireModerator: true, errors: { noSuchEmoji: { @@ -27,11 +19,6 @@ export const meta = { code: 'NO_SUCH_EMOJI', id: 'e2785b66-dca3-4087-9cac-b93c541cc425', }, - duplicateName: { - message: 'Duplicate name.', - code: 'DUPLICATE_NAME', - id: 'f7a3462c-4e6e-4069-8421-b9bd4f4c3975', - }, }, res: { @@ -55,50 +42,41 @@ export const paramDef = { required: ['emojiId'], } as const; -// TODO: ロジックをサービスに切り出す +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const emoji = await Emojis.findOneBy({ id: ps.emojiId }); -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - private emojiEntityService: EmojiEntityService, - private customEmojiService: CustomEmojiService, - private driveService: DriveService, - ) { - super(meta, paramDef, async (ps, me) => { - const emoji = await this.emojisRepository.findOneBy({ id: ps.emojiId }); - if (emoji == null) { - throw new ApiError(meta.errors.noSuchEmoji); - } - - let driveFile: MiDriveFile; - - try { - // Create file - driveFile = await this.driveService.uploadFromUrl({ url: emoji.originalUrl, user: null, force: true }); - } catch (e) { - // TODO: need to return Drive Error - throw new ApiError(); - } - - // Duplication Check - const isDuplicate = await this.customEmojiService.checkDuplicate(emoji.name); - if (isDuplicate) throw new ApiError(meta.errors.duplicateName); - - const addedEmoji = await this.customEmojiService.add({ - driveFile, - name: emoji.name, - category: emoji.category, - aliases: emoji.aliases, - host: null, - license: emoji.license, - isSensitive: emoji.isSensitive, - localOnly: emoji.localOnly, - roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction, - }, me); - - return this.emojiEntityService.packDetailed(addedEmoji); - }); + if (emoji == null) { + throw new ApiError(meta.errors.noSuchEmoji); } -} + + let driveFile: DriveFile; + + try { + // Create file + driveFile = await uploadFromUrl({ url: emoji.originalUrl, user: null, force: true }); + } catch (e) { + throw new ApiError(); + } + + const copied = await Emojis.insert({ + id: genId(), + updatedAt: new Date(), + name: emoji.name, + host: null, + aliases: [], + originalUrl: driveFile.url, + publicUrl: driveFile.webpublicUrl ?? driveFile.url, + type: driveFile.webpublicType ?? driveFile.type, + }).then(x => Emojis.findOneByOrFail(x.identifiers[0])); + + await db.queryResultCache!.remove(['meta_emojis']); + + publishBroadcastStream('emojiAdded', { + emoji: await Emojis.pack(copied.id), + }); + + return { + id: copied.id, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts index a212b553a3..93a6c4e4e2 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts @@ -1,18 +1,15 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import define from '../../../define.js'; +import { Emojis } from '@/models/index.js'; +import { In } from 'typeorm'; +import { insertModerationLog } from '@/services/insert-moderation-log.js'; +import { ApiError } from '../../../error.js'; +import { db } from '@/db/postgre.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', - requireRolePolicy: 'canManageCustomEmojis', - kind: 'write:admin:emoji', + requireCredential: true, + requireModerator: true, } as const; export const paramDef = { @@ -25,13 +22,19 @@ export const paramDef = { required: ['ids'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private customEmojiService: CustomEmojiService, - ) { - super(meta, paramDef, async (ps, me) => { - await this.customEmojiService.deleteBulk(ps.ids, me); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const emojis = await Emojis.findBy({ + id: In(ps.ids), + }); + + for (const emoji of emojis) { + await Emojis.delete(emoji.id); + + await db.queryResultCache!.remove(['meta_emojis']); + + insertModerationLog(me, 'deleteEmoji', { + emoji: emoji, }); } -} +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts index 213540002c..67dbf28d85 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts @@ -1,18 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import define from '../../../define.js'; +import { Emojis } from '@/models/index.js'; +import { insertModerationLog } from '@/services/insert-moderation-log.js'; +import { ApiError } from '../../../error.js'; +import { db } from '@/db/postgre.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', - requireRolePolicy: 'canManageCustomEmojis', - kind: 'write:admin:emoji', + requireCredential: true, + requireModerator: true, errors: { noSuchEmoji: { @@ -31,13 +27,17 @@ export const paramDef = { required: ['id'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private customEmojiService: CustomEmojiService, - ) { - super(meta, paramDef, async (ps, me) => { - await this.customEmojiService.delete(ps.id, me); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const emoji = await Emojis.findOneBy({ id: ps.id }); + + if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); + + await Emojis.delete(emoji.id); + + await db.queryResultCache!.remove(['meta_emojis']); + + insertModerationLog(me, 'deleteEmoji', { + emoji: emoji, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts index dad5da1c4a..3f03dc2da4 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts @@ -1,16 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueueService } from '@/core/QueueService.js'; +import define from '../../../define.js'; +import { createImportCustomEmojisJob } from '@/queue/index.js'; +import ms from 'ms'; export const meta = { secure: true, - requireCredential: 'always', - requireRolePolicy: 'canManageCustomEmojis', + requireCredential: true, + requireModerator: true, } as const; export const paramDef = { @@ -21,13 +16,7 @@ export const paramDef = { required: ['fileId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private queueService: QueueService, - ) { - super(meta, paramDef, async (ps, me) => { - this.queueService.createImportCustomEmojisJob(me, ps.fileId); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + createImportCustomEmojisJob(user, ps.fileId); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts index 46d5542f71..d16689a280 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts @@ -1,23 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import define from '../../../define.js'; +import { Emojis } from '@/models/index.js'; +import { toPuny } from '@/misc/convert-host.js'; +import { makePaginationQuery } from '../../../common/make-pagination-query.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', - requireRolePolicy: 'canManageCustomEmojis', - kind: 'read:admin:emoji', + requireCredential: true, + requireModerator: true, res: { type: 'array', @@ -78,35 +68,24 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const q = makePaginationQuery(Emojis.createQueryBuilder('emoji'), ps.sinceId, ps.untilId); - private utilityService: UtilityService, - private queryService: QueryService, - private emojiEntityService: EmojiEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const q = this.queryService.makePaginationQuery(this.emojisRepository.createQueryBuilder('emoji'), ps.sinceId, ps.untilId); - - if (ps.host == null) { - q.andWhere('emoji.host IS NOT NULL'); - } else { - q.andWhere('emoji.host = :host', { host: this.utilityService.toPuny(ps.host) }); - } - - if (ps.query) { - q.andWhere('emoji.name like :query', { query: '%' + sqlLikeEscape(ps.query) + '%' }); - } - - const emojis = await q - .orderBy('emoji.id', 'DESC') - .limit(ps.limit) - .getMany(); - - return this.emojiEntityService.packDetailedMany(emojis); - }); + if (ps.host == null) { + q.andWhere(`emoji.host IS NOT NULL`); + } else { + q.andWhere(`emoji.host = :host`, { host: toPuny(ps.host) }); } -} + + if (ps.query) { + q.andWhere('emoji.name like :query', { query: '%' + ps.query + '%' }); + } + + const emojis = await q + .orderBy('emoji.id', 'DESC') + .take(ps.limit) + .getMany(); + + return Emojis.packMany(emojis); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts index f1c2c8b216..6192978fad 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -1,23 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/_.js'; -import type { MiEmoji } from '@/models/Emoji.js'; -import { QueryService } from '@/core/QueryService.js'; -import { DI } from '@/di-symbols.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -//import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import define from '../../../define.js'; +import { Emojis } from '@/models/index.js'; +import { makePaginationQuery } from '../../../common/make-pagination-query.js'; +import { Emoji } from '@/models/entities/emoji.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', - requireRolePolicy: 'canManageCustomEmojis', - kind: 'read:admin:emoji', + requireCredential: true, + requireModerator: true, res: { type: 'array', @@ -48,8 +38,8 @@ export const meta = { optional: false, nullable: true, }, host: { - type: 'string', - optional: false, nullable: true, + type: 'null', + optional: false, description: 'The local host is represented with `null`. The field exists for compatibility with other API endpoints that return files.', }, url: { @@ -72,44 +62,28 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const q = makePaginationQuery(Emojis.createQueryBuilder('emoji'), ps.sinceId, ps.untilId) + .andWhere(`emoji.host IS NULL`); - private emojiEntityService: EmojiEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const q = this.queryService.makePaginationQuery(this.emojisRepository.createQueryBuilder('emoji'), ps.sinceId, ps.untilId) - .andWhere('emoji.host IS NULL'); + let emojis: Emoji[]; - let emojis: MiEmoji[]; + if (ps.query) { + //q.andWhere('emoji.name ILIKE :q', { q: `%${ps.query}%` }); + //const emojis = await q.take(ps.limit).getMany(); - if (ps.query) { - //q.andWhere('emoji.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); - //const emojis = await q.limit(ps.limit).getMany(); + emojis = await q.getMany(); - emojis = await q.getMany(); - const queryarry = ps.query.match(/\:([a-z0-9_]*)\:/g); + emojis = emojis.filter(emoji => + emoji.name.includes(ps.query!) || + emoji.aliases.some(a => a.includes(ps.query!)) || + emoji.category?.includes(ps.query!)); - if (queryarry) { - emojis = emojis.filter(emoji => - queryarry.includes(`:${emoji.name}:`), - ); - } else { - emojis = emojis.filter(emoji => - emoji.name.includes(ps.query!) || - emoji.aliases.some(a => a.includes(ps.query!)) || - emoji.category?.includes(ps.query!)); - } - emojis.splice(ps.limit + 1); - } else { - emojis = await q.limit(ps.limit).getMany(); - } - - return this.emojiEntityService.packDetailedMany(emojis); - }); + emojis.splice(ps.limit + 1); + } else { + emojis = await q.take(ps.limit).getMany(); } -} + + return Emojis.packMany(emojis); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts index a1700dadbd..a4da40fffd 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts @@ -1,18 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import define from '../../../define.js'; +import { Emojis } from '@/models/index.js'; +import { In } from 'typeorm'; +import { ApiError } from '../../../error.js'; +import { db } from '@/db/postgre.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', - requireRolePolicy: 'canManageCustomEmojis', - kind: 'write:admin:emoji', + requireCredential: true, + requireModerator: true, } as const; export const paramDef = { @@ -28,13 +24,18 @@ export const paramDef = { required: ['ids', 'aliases'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private customEmojiService: CustomEmojiService, - ) { - super(meta, paramDef, async (ps, me) => { - await this.customEmojiService.removeAliasesBulk(ps.ids, ps.aliases); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const emojis = await Emojis.findBy({ + id: In(ps.ids), + }); + + for (const emoji of emojis) { + await Emojis.update(emoji.id, { + updatedAt: new Date(), + aliases: emoji.aliases.filter(x => !ps.aliases.includes(x)), }); } -} + + await db.queryResultCache!.remove(['meta_emojis']); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts index 551c7d3626..ae3b190f40 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts @@ -1,18 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import define from '../../../define.js'; +import { Emojis } from '@/models/index.js'; +import { In } from 'typeorm'; +import { ApiError } from '../../../error.js'; +import { db } from '@/db/postgre.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', - requireRolePolicy: 'canManageCustomEmojis', - kind: 'write:admin:emoji', + requireCredential: true, + requireModerator: true, } as const; export const paramDef = { @@ -28,13 +24,14 @@ export const paramDef = { required: ['ids', 'aliases'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private customEmojiService: CustomEmojiService, - ) { - super(meta, paramDef, async (ps, me) => { - await this.customEmojiService.setAliasesBulk(ps.ids, ps.aliases); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + await Emojis.update({ + id: In(ps.ids), + }, { + updatedAt: new Date(), + aliases: ps.aliases, + }); + + await db.queryResultCache!.remove(['meta_emojis']); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts index 8519eb0fcc..cff58d6170 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts @@ -1,18 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import define from '../../../define.js'; +import { Emojis } from '@/models/index.js'; +import { In } from 'typeorm'; +import { ApiError } from '../../../error.js'; +import { db } from '@/db/postgre.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', - requireRolePolicy: 'canManageCustomEmojis', - kind: 'write:admin:emoji', + requireCredential: true, + requireModerator: true, } as const; export const paramDef = { @@ -30,13 +26,14 @@ export const paramDef = { required: ['ids'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private customEmojiService: CustomEmojiService, - ) { - super(meta, paramDef, async (ps, me) => { - await this.customEmojiService.setCategoryBulk(ps.ids, ps.category ?? null); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + await Emojis.update({ + id: In(ps.ids), + }, { + updatedAt: new Date(), + category: ps.category, + }); + + await db.queryResultCache!.remove(['meta_emojis']); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts deleted file mode 100644 index 41cca196d6..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { CustomEmojiService } from '@/core/CustomEmojiService.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: 'always', - requireRolePolicy: 'canManageCustomEmojis', - kind: 'write:admin:emoji', -} as const; - -export const paramDef = { - type: 'object', - properties: { - ids: { type: 'array', items: { - type: 'string', format: 'misskey:id', - } }, - license: { - type: 'string', - nullable: true, - description: 'Use `null` to reset the license.', - }, - }, - required: ['ids'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private customEmojiService: CustomEmojiService, - ) { - super(meta, paramDef, async (ps, me) => { - await this.customEmojiService.setLicenseBulk(ps.ids, ps.license ?? null); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index ea836b47ee..5b547b3b79 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -1,21 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { CustomEmojiService } from '@/core/CustomEmojiService.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; +import { Emojis } from '@/models/index.js'; import { ApiError } from '../../../error.js'; +import { db } from '@/db/postgre.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', - requireRolePolicy: 'canManageCustomEmojis', - kind: 'write:admin:emoji', + requireCredential: true, + requireModerator: true, errors: { noSuchEmoji: { @@ -23,16 +15,6 @@ export const meta = { code: 'NO_SUCH_EMOJI', id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8', }, - noSuchFile: { - message: 'No such file.', - code: 'NO_SUCH_FILE', - id: '14fb9fd9-0731-4e2f-aeb9-f09e4740333d', - }, - sameNameEmojiExists: { - message: 'Emoji that have same name already exists.', - code: 'SAME_NAME_EMOJI_EXISTS', - id: '7180fe9d-1ee3-bff9-647d-fe9896d2ffb8', - }, }, } as const; @@ -40,8 +22,7 @@ export const paramDef = { type: 'object', properties: { id: { type: 'string', format: 'misskey:id' }, - name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, - fileId: { type: 'string', format: 'misskey:id' }, + name: { type: 'string' }, category: { type: 'string', nullable: true, @@ -50,51 +31,22 @@ export const paramDef = { aliases: { type: 'array', items: { type: 'string', } }, - license: { type: 'string', nullable: true }, - isSensitive: { type: 'boolean' }, - localOnly: { type: 'boolean' }, - roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { - type: 'string', - } }, }, required: ['id', 'name', 'aliases'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const emoji = await Emojis.findOneBy({ id: ps.id }); - private customEmojiService: CustomEmojiService, - ) { - super(meta, paramDef, async (ps, me) => { - let driveFile; + if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); - if (ps.fileId) { - driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); - if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); - } - const emoji = await this.customEmojiService.getEmojiById(ps.id); - if (emoji != null) { - if (ps.name !== emoji.name) { - const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); - if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists); - } - } else { - throw new ApiError(meta.errors.noSuchEmoji); - } + await Emojis.update(emoji.id, { + updatedAt: new Date(), + name: ps.name, + category: ps.category, + aliases: ps.aliases, + }); - await this.customEmojiService.update(ps.id, { - driveFile, - name: ps.name, - category: ps.category ?? null, - aliases: ps.aliases, - license: ps.license ?? null, - isSensitive: ps.isSensitive, - localOnly: ps.localOnly, - roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction, - }, me); - }); - } -} + await db.queryResultCache!.remove(['meta_emojis']); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts b/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts index 6dac7e083c..da54201473 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts @@ -1,20 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { DriveService } from '@/core/DriveService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; +import { deleteFile } from '@/services/drive/delete-file.js'; +import { DriveFiles } from '@/models/index.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:federation', } as const; export const paramDef = { @@ -25,22 +17,13 @@ export const paramDef = { required: ['host'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const files = await DriveFiles.findBy({ + userHost: ps.host, + }); - private driveService: DriveService, - ) { - super(meta, paramDef, async (ps, me) => { - const files = await this.driveFilesRepository.findBy({ - userHost: ps.host, - }); - - for (const file of files) { - this.driveService.deleteFile(file); - } - }); + for (const file of files) { + deleteFile(file); } -} +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts b/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts index b6018262af..cb2be5ab37 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts @@ -1,21 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { InstancesRepository } from '@/models/_.js'; -import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; +import { Instances } from '@/models/index.js'; +import { toPuny } from '@/misc/convert-host.js'; +import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:federation', } as const; export const paramDef = { @@ -26,23 +18,13 @@ export const paramDef = { required: ['host'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const instance = await Instances.findOneBy({ host: toPuny(ps.host) }); - private utilityService: UtilityService, - private fetchInstanceMetadataService: FetchInstanceMetadataService, - ) { - super(meta, paramDef, async (ps, me) => { - const instance = await this.instancesRepository.findOneBy({ host: this.utilityService.toPuny(ps.host) }); - - if (instance == null) { - throw new Error('instance not found'); - } - - this.fetchInstanceMetadataService.fetchInstanceMetadata(instance, true); - }); + if (instance == null) { + throw new Error('instance not found'); } -} + + fetchInstanceMetadata(instance, true); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts index 9d1e3da587..b7ee27db64 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts @@ -1,20 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { FollowingsRepository, UsersRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { QueueService } from '@/core/QueueService.js'; +import define from '../../../define.js'; +import deleteFollowing from '@/services/following/delete.js'; +import { Followings, Users } from '@/models/index.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:federation', } as const; export const paramDef = { @@ -25,28 +17,18 @@ export const paramDef = { required: ['host'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const followings = await Followings.findBy({ + followerHost: ps.host, + }); - @Inject(DI.notesRepository) - private followingsRepository: FollowingsRepository, + const pairs = await Promise.all(followings.map(f => Promise.all([ + Users.findOneByOrFail({ id: f.followerId }), + Users.findOneByOrFail({ id: f.followeeId }), + ]))); - private queueService: QueueService, - ) { - super(meta, paramDef, async (ps, me) => { - const followings = await this.followingsRepository.findBy({ - followerHost: ps.host, - }); - - const pairs = await Promise.all(followings.map(f => Promise.all([ - this.usersRepository.findOneByOrFail({ id: f.followerId }), - this.usersRepository.findOneByOrFail({ id: f.followeeId }), - ]).then(([from, to]) => [{ id: from.id }, { id: to.id }]))); - - this.queueService.createUnfollowJob(pairs.map(p => ({ from: p[0], to: p[1], silent: true }))); - }); + for (const pair of pairs) { + deleteFollowing(pair[0], pair[1]); } -} +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts index 06083a28f3..278131fb37 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts @@ -1,22 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { InstancesRepository } from '@/models/_.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { DI } from '@/di-symbols.js'; -import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; +import define from '../../../define.js'; +import { Instances } from '@/models/index.js'; +import { toPuny } from '@/misc/convert-host.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:federation', } as const; export const paramDef = { @@ -24,53 +14,19 @@ export const paramDef = { properties: { host: { type: 'string' }, isSuspended: { type: 'boolean' }, - isNSFW: { type: 'boolean' }, }, - required: ['host'], + required: ['host', 'isSuspended'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const instance = await Instances.findOneBy({ host: toPuny(ps.host) }); - private utilityService: UtilityService, - private federatedInstanceService: FederatedInstanceService, - private moderationLogService: ModerationLogService, - ) { - super(meta, paramDef, async (ps, me) => { - const instance = await this.instancesRepository.findOneBy({ host: this.utilityService.toPuny(ps.host) }); - - if (instance == null) { - throw new Error('instance not found'); - } - - if (ps.isSuspended != null) { - await this.federatedInstanceService.update(instance.id, { - isSuspended: ps.isSuspended, - }); - - if (instance.isSuspended !== ps.isSuspended) { - if (ps.isSuspended) { - this.moderationLogService.log(me, 'suspendRemoteInstance', { - id: instance.id, - host: instance.host, - }); - } else { - this.moderationLogService.log(me, 'unsuspendRemoteInstance', { - id: instance.id, - host: instance.host, - }); - } - } - } - - if (ps.isNSFW != null) { - await this.federatedInstanceService.update(instance.id, { - isNSFW: ps.isNSFW, - }); - } - }); + if (instance == null) { + throw new Error('instance not found'); } -} + + Instances.update({ host: toPuny(ps.host) }, { + isSuspended: ps.isSuspended, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts b/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts index 3ac08c419b..dd16473f30 100644 --- a/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts +++ b/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts @@ -1,29 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { db } from '@/db/postgre.js'; export const meta = { - requireCredential: 'always', - requireAdmin: true, - kind: 'read:admin:index-stats', + requireCredential: true, + requireModerator: true, tags: ['admin'], - res: { - type: 'array', - items: { - type: 'object', - properties: { - tablename: { type: 'string' }, - indexname: { type: 'string' }, - }, - }, - }, } as const; export const paramDef = { @@ -32,22 +14,15 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.db) - private db: DataSource, - ) { - super(meta, paramDef, async () => { - const stats = await this.db.query('SELECT * FROM pg_indexes;').then(recs => { - const res = [] as { tablename: string; indexname: string; }[]; - for (const rec of recs) { - res.push(rec); - } - return res; - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async () => { + const stats = await db.query(`SELECT * FROM pg_indexes;`).then(recs => { + const res = [] as { tablename: string; indexname: string; }[]; + for (const rec of recs) { + res.push(rec); + } + return res; + }); - return stats; - }); - } -} + return stats; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts b/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts index be31010f6f..aca2540fd5 100644 --- a/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts +++ b/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts @@ -1,17 +1,9 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; +import { db } from '@/db/postgre.js'; +import define from '../../define.js'; export const meta = { - requireCredential: 'always', - requireAdmin: true, - kind: 'read:admin:table-stats', + requireCredential: true, + requireModerator: true, tags: ['admin'], @@ -33,31 +25,25 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.db) - private db: DataSource, - ) { - super(meta, paramDef, async () => { - const sizes = await this.db.query(` +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async () => { + const sizes = await + db.query(` SELECT relname AS "table", reltuples as "count", pg_total_relation_size(C.oid) AS "size" FROM pg_class C LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) WHERE nspname NOT IN ('pg_catalog', 'information_schema') AND C.relkind <> 'i' AND nspname !~ '^pg_toast';`) - .then(recs => { - const res = {} as Record; - for (const rec of recs) { - res[rec.table] = { - count: parseInt(rec.count, 10), - size: parseInt(rec.size, 10), - }; - } - return res; - }); - - return sizes; + .then(recs => { + const res = {} as Record; + for (const rec of recs) { + res[rec.table] = { + count: parseInt(rec.count, 10), + size: parseInt(rec.size, 10), + }; + } + return res; }); - } -} + + return sizes; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts b/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts index dd67c6ec2a..e8b9cb3b09 100644 --- a/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts +++ b/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts @@ -1,39 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UserIpsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { IdService } from '@/core/IdService.js'; +import { UserIps } from '@/models/index.js'; +import define from '../../define.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', - requireModerator: true, - kind: 'read:admin:user-ips', - res: { - type: 'array', - optional: false, - nullable: false, - items: { - type: 'object', - optional: false, - nullable: false, - properties: { - ip: { type: 'string' }, - createdAt: { - type: 'string', - optional: false, - nullable: false, - format: 'date-time', - }, - }, - }, - }, + requireCredential: true, + requireAdmin: true, } as const; export const paramDef = { @@ -44,25 +16,16 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userIpsRepository) - private userIpsRepository: UserIpsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const ips = await UserIps.find({ + where: { userId: ps.userId }, + order: { createdAt: 'DESC' }, + take: 30, + }); - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const ips = await this.userIpsRepository.find({ - where: { userId: ps.userId }, - order: { id: 'DESC' }, - take: 30, - }); - - return ips.map(x => ({ - ip: x.ip, - createdAt: x.createdAt.toISOString(), - })); - }); - } -} + return ips.map(x => ({ + ip: x.ip, + createdAt: x.createdAt.toISOString(), + })); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/invite.ts b/packages/backend/src/server/api/endpoints/admin/invite.ts new file mode 100644 index 0000000000..7e950cf87b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/invite.ts @@ -0,0 +1,49 @@ +import rndstr from 'rndstr'; +import define from '../../define.js'; +import { RegistrationTickets } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + code: { + type: 'string', + optional: false, nullable: false, + example: '2ERUA5VR', + maxLength: 8, + minLength: 8, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async () => { + const code = rndstr({ + length: 8, + chars: '2-9A-HJ-NP-Z', // [0-9A-Z] w/o [01IO] (32 patterns) + }); + + await RegistrationTickets.insert({ + id: genId(), + createdAt: new Date(), + code, + }); + + return { + code, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/invite/create.ts b/packages/backend/src/server/api/endpoints/admin/invite/create.ts deleted file mode 100644 index 72ea73e806..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/invite/create.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistrationTicketsRepository } from '@/models/_.js'; -import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js'; -import { IdService } from '@/core/IdService.js'; -import { DI } from '@/di-symbols.js'; -import { generateInviteCode } from '@/misc/generate-invite-code.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { ApiError } from '../../../error.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: 'always', - requireModerator: true, - kind: 'write:admin:invite-codes', - - errors: { - invalidDateTime: { - message: 'Invalid date-time format', - code: 'INVALID_DATE_TIME', - id: 'f1380b15-3760-4c6c-a1db-5c3aaf1cbd49', - }, - }, - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'InviteCode', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - count: { type: 'integer', minimum: 1, maximum: 100, default: 1 }, - expiresAt: { type: 'string', nullable: true }, - }, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.registrationTicketsRepository) - private registrationTicketsRepository: RegistrationTicketsRepository, - - private inviteCodeEntityService: InviteCodeEntityService, - private idService: IdService, - private moderationLogService: ModerationLogService, - ) { - super(meta, paramDef, async (ps, me) => { - if (ps.expiresAt && isNaN(Date.parse(ps.expiresAt))) { - throw new ApiError(meta.errors.invalidDateTime); - } - - const ticketsPromises = []; - - for (let i = 0; i < ps.count; i++) { - ticketsPromises.push(this.registrationTicketsRepository.insert({ - id: this.idService.gen(), - expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null, - code: generateInviteCode(), - }).then(x => this.registrationTicketsRepository.findOneByOrFail(x.identifiers[0]))); - } - - const tickets = await Promise.all(ticketsPromises); - - this.moderationLogService.log(me, 'createInvitation', { - invitations: tickets, - }); - - return await this.inviteCodeEntityService.packMany(tickets, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/invite/list.ts b/packages/backend/src/server/api/endpoints/admin/invite/list.ts deleted file mode 100644 index e09aa8dfaf..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/invite/list.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistrationTicketsRepository } from '@/models/_.js'; -import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: 'always', - requireModerator: true, - kind: 'read:admin:invite-codes', - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'InviteCode', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, - offset: { type: 'integer', default: 0 }, - type: { type: 'string', enum: ['unused', 'used', 'expired', 'all'], default: 'all' }, - sort: { type: 'string', enum: ['+createdAt', '-createdAt', '+usedAt', '-usedAt'] }, - }, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.registrationTicketsRepository) - private registrationTicketsRepository: RegistrationTicketsRepository, - - private inviteCodeEntityService: InviteCodeEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.registrationTicketsRepository.createQueryBuilder('ticket') - .leftJoinAndSelect('ticket.createdBy', 'createdBy') - .leftJoinAndSelect('ticket.usedBy', 'usedBy'); - - switch (ps.type) { - case 'unused': query.andWhere('ticket.usedBy IS NULL'); break; - case 'used': query.andWhere('ticket.usedBy IS NOT NULL'); break; - case 'expired': query.andWhere('ticket.expiresAt < :now', { now: new Date() }); break; - } - - switch (ps.sort) { - case '+createdAt': query.orderBy('ticket.id', 'DESC'); break; - case '-createdAt': query.orderBy('ticket.id', 'ASC'); break; - case '+usedAt': query.orderBy('ticket.usedAt', 'DESC', 'NULLS LAST'); break; - case '-usedAt': query.orderBy('ticket.usedAt', 'ASC', 'NULLS FIRST'); break; - default: query.orderBy('ticket.id', 'DESC'); break; - } - - query.limit(ps.limit); - query.offset(ps.offset); - - const tickets = await query.getMany(); - - return await this.inviteCodeEntityService.packMany(tickets, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 945c56a51e..8746119687 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -1,42 +1,34 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { MetaService } from '@/core/MetaService.js'; -import type { Config } from '@/config.js'; -import { DI } from '@/di-symbols.js'; -import { DEFAULT_POLICIES } from '@/core/RoleService.js'; +import config from '@/config/index.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import define from '../../define.js'; export const meta = { tags: ['meta'], - requireCredential: 'always', + requireCredential: true, requireAdmin: true, - kind: 'read:admin:meta', res: { type: 'object', optional: false, nullable: false, properties: { + driveCapacityPerLocalUserMb: { + type: 'number', + optional: false, nullable: false, + }, + driveCapacityPerRemoteUserMb: { + type: 'number', + optional: false, nullable: false, + }, cacheRemoteFiles: { type: 'boolean', optional: false, nullable: false, }, - cacheRemoteSensitiveFiles: { - type: 'boolean', - optional: false, nullable: false, - }, emailRequiredForSignup: { type: 'boolean', optional: false, nullable: false, }, - approvalRequiredForSignup: { - type: 'boolean', - optional: false, nullable: false, - }, enableHcaptcha: { type: 'boolean', optional: false, nullable: false, @@ -53,55 +45,108 @@ export const meta = { type: 'string', optional: false, nullable: true, }, - enableTurnstile: { - type: 'boolean', - optional: false, nullable: false, - }, - turnstileSiteKey: { - type: 'string', - optional: false, nullable: true, - }, swPublickey: { type: 'string', optional: false, nullable: true, }, mascotImageUrl: { type: 'string', - optional: false, nullable: true, + optional: false, nullable: false, default: '/assets/ai.png', }, bannerUrl: { type: 'string', - optional: false, nullable: true, + optional: false, nullable: false, }, - serverErrorImageUrl: { + errorImageUrl: { type: 'string', - optional: false, nullable: true, - }, - infoImageUrl: { - type: 'string', - optional: false, nullable: true, - }, - notFoundImageUrl: { - type: 'string', - optional: false, nullable: true, + optional: false, nullable: false, + default: 'https://xn--931a.moe/aiart/yubitun.png', }, iconUrl: { type: 'string', optional: false, nullable: true, }, - app192IconUrl: { - type: 'string', - optional: false, nullable: true, + maxNoteTextLength: { + type: 'number', + optional: false, nullable: false, }, - app512IconUrl: { - type: 'string', - optional: false, nullable: true, + emojis: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + aliases: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, + category: { + type: 'string', + optional: false, nullable: true, + }, + host: { + type: 'string', + optional: false, nullable: true, + }, + url: { + type: 'string', + optional: false, nullable: false, + format: 'url', + }, + }, + }, + }, + ads: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + place: { + type: 'string', + optional: false, nullable: false, + }, + url: { + type: 'string', + optional: false, nullable: false, + format: 'url', + }, + imageUrl: { + type: 'string', + optional: false, nullable: false, + format: 'url', + }, + }, + }, }, enableEmail: { type: 'boolean', optional: false, nullable: false, }, + enableTwitterIntegration: { + type: 'boolean', + optional: false, nullable: false, + }, + enableGithubIntegration: { + type: 'boolean', + optional: false, nullable: false, + }, + enableDiscordIntegration: { + type: 'boolean', + optional: false, nullable: false, + }, enableServiceWorker: { type: 'boolean', optional: false, nullable: false, @@ -110,56 +155,15 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, - silencedHosts: { - type: 'array', - optional: true, - nullable: false, - items: { - type: 'string', - optional: false, - nullable: false, - }, + proxyAccountName: { + type: 'string', + optional: false, nullable: true, + }, + userStarForReactionFallback: { + type: 'boolean', + optional: true, nullable: false, }, pinnedUsers: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - }, - }, - hiddenTags: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - }, - }, - blockedHosts: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - }, - }, - allowedHosts: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - }, - }, - allowlistMode: { - type: 'boolean', - optional: false, nullable: false, - }, - sensitiveWords: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - }, - }, - bannedEmailDomains: { type: 'array', optional: true, nullable: false, items: { @@ -167,284 +171,162 @@ export const meta = { optional: false, nullable: false, }, }, - preservedUsernames: { + hiddenTags: { type: 'array', - optional: false, nullable: false, + optional: true, nullable: false, items: { type: 'string', + optional: false, nullable: false, }, }, - bubbleInstances: { + blockedHosts: { type: 'array', - optional: false, nullable: false, + optional: true, nullable: false, items: { type: 'string', + optional: false, nullable: false, }, }, hcaptchaSecretKey: { type: 'string', - optional: false, nullable: true, + optional: true, nullable: true, }, recaptchaSecretKey: { type: 'string', - optional: false, nullable: true, - }, - turnstileSecretKey: { - type: 'string', - optional: false, nullable: true, + optional: true, nullable: true, }, sensitiveMediaDetection: { type: 'string', - optional: false, nullable: false, + optional: true, nullable: false, }, sensitiveMediaDetectionSensitivity: { type: 'string', - optional: false, nullable: false, + optional: true, nullable: false, }, setSensitiveFlagAutomatically: { type: 'boolean', - optional: false, nullable: false, + optional: true, nullable: false, }, enableSensitiveMediaDetectionForVideos: { type: 'boolean', - optional: false, nullable: false, - }, - enableBotTrending: { - type: 'boolean', - optional: false, nullable: false, + optional: true, nullable: false, }, proxyAccountId: { type: 'string', - optional: false, nullable: true, + optional: true, nullable: true, format: 'id', }, + twitterConsumerKey: { + type: 'string', + optional: true, nullable: true, + }, + twitterConsumerSecret: { + type: 'string', + optional: true, nullable: true, + }, + githubClientId: { + type: 'string', + optional: true, nullable: true, + }, + githubClientSecret: { + type: 'string', + optional: true, nullable: true, + }, + discordClientId: { + type: 'string', + optional: true, nullable: true, + }, + discordClientSecret: { + type: 'string', + optional: true, nullable: true, + }, + summaryProxy: { + type: 'string', + optional: true, nullable: true, + }, email: { type: 'string', - optional: false, nullable: true, + optional: true, nullable: true, }, smtpSecure: { type: 'boolean', - optional: false, nullable: false, + optional: true, nullable: false, }, smtpHost: { type: 'string', - optional: false, nullable: true, + optional: true, nullable: true, }, smtpPort: { - type: 'number', - optional: false, nullable: true, + type: 'string', + optional: true, nullable: true, }, smtpUser: { type: 'string', - optional: false, nullable: true, + optional: true, nullable: true, }, smtpPass: { type: 'string', - optional: false, nullable: true, + optional: true, nullable: true, }, swPrivateKey: { type: 'string', - optional: false, nullable: true, + optional: true, nullable: true, }, useObjectStorage: { type: 'boolean', - optional: false, nullable: false, + optional: true, nullable: false, }, objectStorageBaseUrl: { type: 'string', - optional: false, nullable: true, + optional: true, nullable: true, }, objectStorageBucket: { type: 'string', - optional: false, nullable: true, + optional: true, nullable: true, }, objectStoragePrefix: { type: 'string', - optional: false, nullable: true, + optional: true, nullable: true, }, objectStorageEndpoint: { type: 'string', - optional: false, nullable: true, + optional: true, nullable: true, }, objectStorageRegion: { type: 'string', - optional: false, nullable: true, + optional: true, nullable: true, }, objectStoragePort: { type: 'number', - optional: false, nullable: true, + optional: true, nullable: true, }, objectStorageAccessKey: { type: 'string', - optional: false, nullable: true, + optional: true, nullable: true, }, objectStorageSecretKey: { type: 'string', - optional: false, nullable: true, + optional: true, nullable: true, }, objectStorageUseSSL: { type: 'boolean', - optional: false, nullable: false, + optional: true, nullable: false, }, objectStorageUseProxy: { type: 'boolean', - optional: false, nullable: false, + optional: true, nullable: false, }, objectStorageSetPublicRead: { type: 'boolean', - optional: false, nullable: false, + optional: true, nullable: false, }, enableIpLogging: { type: 'boolean', - optional: false, nullable: false, + optional: true, nullable: false, }, enableActiveEmailValidation: { type: 'boolean', - optional: false, nullable: false, - }, - enableVerifymailApi: { - type: 'boolean', - optional: false, nullable: false, - }, - verifymailAuthKey: { - type: 'string', - optional: false, nullable: true, - }, - enableChartsForRemoteUser: { - type: 'boolean', - optional: false, nullable: false, - }, - enableChartsForFederatedInstances: { - type: 'boolean', - optional: false, nullable: false, - }, - enableServerMachineStats: { - type: 'boolean', - optional: false, nullable: false, - }, - enableAchievements: { - type: 'boolean', - optional: false, nullable: false, - }, - enableIdenticonGeneration: { - type: 'boolean', - optional: false, nullable: false, - }, - manifestJsonOverride: { - type: 'string', - optional: false, nullable: false, - }, - policies: { - type: 'object', - optional: false, nullable: false, - }, - enableFanoutTimeline: { - type: 'boolean', - optional: false, nullable: false, - }, - enableFanoutTimelineDbFallback: { - type: 'boolean', - optional: false, nullable: false, - }, - perLocalUserUserTimelineCacheMax: { - type: 'number', - optional: false, nullable: false, - }, - perRemoteUserUserTimelineCacheMax: { - type: 'number', - optional: false, nullable: false, - }, - perUserHomeTimelineCacheMax: { - type: 'number', - optional: false, nullable: false, - }, - perUserListTimelineCacheMax: { - type: 'number', - optional: false, nullable: false, - }, - notesPerOneAd: { - type: 'number', - optional: false, nullable: false, - }, - backgroundImageUrl: { - type: 'string', - optional: false, nullable: true, - }, - deeplAuthKey: { - type: 'string', - optional: false, nullable: true, - }, - deeplIsPro: { - type: 'boolean', - optional: false, nullable: false, - }, - defaultDarkTheme: { - type: 'string', - optional: false, nullable: true, - }, - defaultLightTheme: { - type: 'string', - optional: false, nullable: true, - }, - description: { - type: 'string', - optional: false, nullable: true, - }, - disableRegistration: { - type: 'boolean', - optional: false, nullable: false, - }, - impressumUrl: { - type: 'string', - optional: false, nullable: true, - }, - maintainerEmail: { - type: 'string', - optional: false, nullable: true, - }, - maintainerName: { - type: 'string', - optional: false, nullable: true, - }, - name: { - type: 'string', - optional: false, nullable: true, - }, - shortName: { - type: 'string', - optional: false, nullable: true, - }, - objectStorageS3ForcePathStyle: { - type: 'boolean', - optional: false, nullable: false, - }, - privacyPolicyUrl: { - type: 'string', - optional: false, nullable: true, - }, - repositoryUrl: { - type: 'string', - optional: false, nullable: false, - }, - summalyProxy: { - type: 'string', - optional: false, nullable: true, - }, - themeColor: { - type: 'string', - optional: false, nullable: true, - }, - tosUrl: { - type: 'string', - optional: false, nullable: true, - }, - uri: { - type: 'string', - optional: false, nullable: false, - }, - version: { - type: 'string', - optional: false, nullable: false, + optional: true, nullable: false, }, }, }, @@ -457,121 +339,92 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.config) - private config: Config, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const instance = await fetchMeta(true); - private metaService: MetaService, - ) { - super(meta, paramDef, async () => { - const instance = await this.metaService.fetch(true); - - return { - maintainerName: instance.maintainerName, - maintainerEmail: instance.maintainerEmail, - version: this.config.version, - name: instance.name, - shortName: instance.shortName, - uri: this.config.url, - description: instance.description, - langs: instance.langs, - tosUrl: instance.termsOfServiceUrl, - repositoryUrl: instance.repositoryUrl, - feedbackUrl: instance.feedbackUrl, - impressumUrl: instance.impressumUrl, - privacyPolicyUrl: instance.privacyPolicyUrl, - disableRegistration: instance.disableRegistration, - emailRequiredForSignup: instance.emailRequiredForSignup, - approvalRequiredForSignup: instance.approvalRequiredForSignup, - enableHcaptcha: instance.enableHcaptcha, - hcaptchaSiteKey: instance.hcaptchaSiteKey, - enableRecaptcha: instance.enableRecaptcha, - recaptchaSiteKey: instance.recaptchaSiteKey, - enableTurnstile: instance.enableTurnstile, - turnstileSiteKey: instance.turnstileSiteKey, - swPublickey: instance.swPublicKey, - themeColor: instance.themeColor, - mascotImageUrl: instance.mascotImageUrl, - bannerUrl: instance.bannerUrl, - serverErrorImageUrl: instance.serverErrorImageUrl, - notFoundImageUrl: instance.notFoundImageUrl, - infoImageUrl: instance.infoImageUrl, - iconUrl: instance.iconUrl, - app192IconUrl: instance.app192IconUrl, - app512IconUrl: instance.app512IconUrl, - backgroundImageUrl: instance.backgroundImageUrl, - logoImageUrl: instance.logoImageUrl, - defaultLightTheme: instance.defaultLightTheme, - defaultDarkTheme: instance.defaultDarkTheme, - defaultLike: instance.defaultLike, - enableEmail: instance.enableEmail, - enableServiceWorker: instance.enableServiceWorker, - translatorAvailable: instance.deeplAuthKey != null, - cacheRemoteFiles: instance.cacheRemoteFiles, - cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles, - pinnedUsers: instance.pinnedUsers, - hiddenTags: instance.hiddenTags, - blockedHosts: instance.blockedHosts, - allowedHosts: instance.allowedHosts, - allowlistMode: instance.allowlistMode, - silencedHosts: instance.silencedHosts, - sensitiveWords: instance.sensitiveWords, - preservedUsernames: instance.preservedUsernames, - bubbleInstances: instance.bubbleInstances, - hcaptchaSecretKey: instance.hcaptchaSecretKey, - recaptchaSecretKey: instance.recaptchaSecretKey, - turnstileSecretKey: instance.turnstileSecretKey, - sensitiveMediaDetection: instance.sensitiveMediaDetection, - sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity, - setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically, - enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, - enableBotTrending: instance.enableBotTrending, - proxyAccountId: instance.proxyAccountId, - summalyProxy: instance.summalyProxy, - email: instance.email, - smtpSecure: instance.smtpSecure, - smtpHost: instance.smtpHost, - smtpPort: instance.smtpPort, - smtpUser: instance.smtpUser, - smtpPass: instance.smtpPass, - swPrivateKey: instance.swPrivateKey, - useObjectStorage: instance.useObjectStorage, - objectStorageBaseUrl: instance.objectStorageBaseUrl, - objectStorageBucket: instance.objectStorageBucket, - objectStoragePrefix: instance.objectStoragePrefix, - objectStorageEndpoint: instance.objectStorageEndpoint, - objectStorageRegion: instance.objectStorageRegion, - objectStoragePort: instance.objectStoragePort, - objectStorageAccessKey: instance.objectStorageAccessKey, - objectStorageSecretKey: instance.objectStorageSecretKey, - objectStorageUseSSL: instance.objectStorageUseSSL, - objectStorageUseProxy: instance.objectStorageUseProxy, - objectStorageSetPublicRead: instance.objectStorageSetPublicRead, - objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle, - deeplAuthKey: instance.deeplAuthKey, - deeplIsPro: instance.deeplIsPro, - enableIpLogging: instance.enableIpLogging, - enableActiveEmailValidation: instance.enableActiveEmailValidation, - enableVerifymailApi: instance.enableVerifymailApi, - verifymailAuthKey: instance.verifymailAuthKey, - enableChartsForRemoteUser: instance.enableChartsForRemoteUser, - enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances, - enableServerMachineStats: instance.enableServerMachineStats, - enableAchievements: instance.enableAchievements, - enableIdenticonGeneration: instance.enableIdenticonGeneration, - bannedEmailDomains: instance.bannedEmailDomains, - policies: { ...DEFAULT_POLICIES, ...instance.policies }, - manifestJsonOverride: instance.manifestJsonOverride, - enableFanoutTimeline: instance.enableFanoutTimeline, - enableFanoutTimelineDbFallback: instance.enableFanoutTimelineDbFallback, - perLocalUserUserTimelineCacheMax: instance.perLocalUserUserTimelineCacheMax, - perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax, - perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax, - perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax, - notesPerOneAd: instance.notesPerOneAd, - }; - }); - } -} + return { + maintainerName: instance.maintainerName, + maintainerEmail: instance.maintainerEmail, + version: config.version, + name: instance.name, + uri: config.url, + description: instance.description, + langs: instance.langs, + tosUrl: instance.ToSUrl, + repositoryUrl: instance.repositoryUrl, + feedbackUrl: instance.feedbackUrl, + disableRegistration: instance.disableRegistration, + disableLocalTimeline: instance.disableLocalTimeline, + disableGlobalTimeline: instance.disableGlobalTimeline, + driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, + driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, + emailRequiredForSignup: instance.emailRequiredForSignup, + enableHcaptcha: instance.enableHcaptcha, + hcaptchaSiteKey: instance.hcaptchaSiteKey, + enableRecaptcha: instance.enableRecaptcha, + recaptchaSiteKey: instance.recaptchaSiteKey, + swPublickey: instance.swPublicKey, + themeColor: instance.themeColor, + mascotImageUrl: instance.mascotImageUrl, + bannerUrl: instance.bannerUrl, + errorImageUrl: instance.errorImageUrl, + iconUrl: instance.iconUrl, + backgroundImageUrl: instance.backgroundImageUrl, + logoImageUrl: instance.logoImageUrl, + maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため + defaultLightTheme: instance.defaultLightTheme, + defaultDarkTheme: instance.defaultDarkTheme, + enableEmail: instance.enableEmail, + enableTwitterIntegration: instance.enableTwitterIntegration, + enableGithubIntegration: instance.enableGithubIntegration, + enableDiscordIntegration: instance.enableDiscordIntegration, + enableServiceWorker: instance.enableServiceWorker, + translatorAvailable: instance.deeplAuthKey != null, + pinnedPages: instance.pinnedPages, + pinnedClipId: instance.pinnedClipId, + cacheRemoteFiles: instance.cacheRemoteFiles, + useStarForReactionFallback: instance.useStarForReactionFallback, + pinnedUsers: instance.pinnedUsers, + hiddenTags: instance.hiddenTags, + blockedHosts: instance.blockedHosts, + hcaptchaSecretKey: instance.hcaptchaSecretKey, + recaptchaSecretKey: instance.recaptchaSecretKey, + sensitiveMediaDetection: instance.sensitiveMediaDetection, + sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity, + setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically, + enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, + proxyAccountId: instance.proxyAccountId, + twitterConsumerKey: instance.twitterConsumerKey, + twitterConsumerSecret: instance.twitterConsumerSecret, + githubClientId: instance.githubClientId, + githubClientSecret: instance.githubClientSecret, + discordClientId: instance.discordClientId, + discordClientSecret: instance.discordClientSecret, + summalyProxy: instance.summalyProxy, + email: instance.email, + smtpSecure: instance.smtpSecure, + smtpHost: instance.smtpHost, + smtpPort: instance.smtpPort, + smtpUser: instance.smtpUser, + smtpPass: instance.smtpPass, + swPrivateKey: instance.swPrivateKey, + useObjectStorage: instance.useObjectStorage, + objectStorageBaseUrl: instance.objectStorageBaseUrl, + objectStorageBucket: instance.objectStorageBucket, + objectStoragePrefix: instance.objectStoragePrefix, + objectStorageEndpoint: instance.objectStorageEndpoint, + objectStorageRegion: instance.objectStorageRegion, + objectStoragePort: instance.objectStoragePort, + objectStorageAccessKey: instance.objectStorageAccessKey, + objectStorageSecretKey: instance.objectStorageSecretKey, + objectStorageUseSSL: instance.objectStorageUseSSL, + objectStorageUseProxy: instance.objectStorageUseProxy, + objectStorageSetPublicRead: instance.objectStorageSetPublicRead, + objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle, + deeplAuthKey: instance.deeplAuthKey, + deeplIsPro: instance.deeplIsPro, + enableIpLogging: instance.enableIpLogging, + enableActiveEmailValidation: instance.enableActiveEmailValidation, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/moderators/add.ts b/packages/backend/src/server/api/endpoints/admin/moderators/add.ts new file mode 100644 index 0000000000..7b209c2d99 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/moderators/add.ts @@ -0,0 +1,37 @@ +import define from '../../../define.js'; +import { Users } from '@/models/index.js'; +import { publishInternalEvent } from '@/services/stream.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const user = await Users.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error('user not found'); + } + + if (user.isAdmin) { + throw new Error('cannot mark as moderator if admin user'); + } + + await Users.update(user.id, { + isModerator: true, + }); + + publishInternalEvent('userChangeModeratorState', { id: user.id, isModerator: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts b/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts new file mode 100644 index 0000000000..a01e9f3c69 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts @@ -0,0 +1,33 @@ +import define from '../../../define.js'; +import { Users } from '@/models/index.js'; +import { publishInternalEvent } from '@/services/stream.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const user = await Users.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error('user not found'); + } + + await Users.update(user.id, { + isModerator: false, + }); + + publishInternalEvent('userChangeModeratorState', { id: user.id, isModerator: false }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts b/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts deleted file mode 100644 index e817d1f522..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: 'always', - requireModerator: true, - kind: 'write:admin:nsfw-user', -} as const; - -export const paramDef = { - type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new Error('user not found'); - } - - await this.userProfilesRepository.update(user.id, { - alwaysMarkNsfw: true, - }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/promo/create.ts b/packages/backend/src/server/api/endpoints/admin/promo/create.ts index 505e76c415..68a17867b2 100644 --- a/packages/backend/src/server/api/endpoints/admin/promo/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/promo/create.ts @@ -1,21 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { PromoNotesRepository } from '@/models/_.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { getNote } from '../../../common/getters.js'; +import { PromoNotes } from '@/models/index.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:promo', errors: { noSuchNote: { @@ -41,31 +33,22 @@ export const paramDef = { required: ['noteId', 'expiresAt'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.promoNotesRepository) - private promoNotesRepository: PromoNotesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); - private getterService: GetterService, - ) { - super(meta, paramDef, async (ps, me) => { - const note = await this.getterService.getNote(ps.noteId).catch(e => { - if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw e; - }); + const exist = await PromoNotes.findOneBy({ noteId: note.id }); - const exist = await this.promoNotesRepository.exist({ where: { noteId: note.id } }); - - if (exist) { - throw new ApiError(meta.errors.alreadyPromoted); - } - - await this.promoNotesRepository.insert({ - noteId: note.id, - expiresAt: new Date(ps.expiresAt), - userId: note.userId, - }); - }); + if (exist != null) { + throw new ApiError(meta.errors.alreadyPromoted); } -} + + await PromoNotes.insert({ + noteId: note.id, + expiresAt: new Date(ps.expiresAt), + userId: note.userId, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts index 2c0915a7a1..8f015c280a 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts @@ -1,19 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { QueueService } from '@/core/QueueService.js'; +import define from '../../../define.js'; +import { destroy } from '@/queue/index.js'; +import { insertModerationLog } from '@/services/insert-moderation-log.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:queue', } as const; export const paramDef = { @@ -22,16 +15,9 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private moderationLogService: ModerationLogService, - private queueService: QueueService, - ) { - super(meta, paramDef, async (ps, me) => { - this.queueService.destroy(); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + destroy(); - this.moderationLogService.log(me, 'clearQueue'); - }); - } -} + insertModerationLog(me, 'clearQueue'); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts index 29bd371f37..70f7d77de4 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts @@ -1,19 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - +import { deliverQueue } from '@/queue/queues.js'; import { URL } from 'node:url'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DeliverQueue } from '@/core/QueueModule.js'; +import define from '../../../define.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'read:admin:queue', res: { type: 'array', @@ -45,28 +38,22 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject('queue:deliver') public deliverQueue: DeliverQueue, - ) { - super(meta, paramDef, async (ps, me) => { - const jobs = await this.deliverQueue.getJobs(['delayed']); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const jobs = await deliverQueue.getJobs(['delayed']); - const res = [] as [string, number][]; + const res = [] as [string, number][]; - for (const job of jobs) { - const host = new URL(job.data.to).host; - if (res.find(x => x[0] === host)) { - res.find(x => x[0] === host)![1]++; - } else { - res.push([host, 1]); - } - } - - res.sort((a, b) => b[1] - a[1]); - - return res; - }); + for (const job of jobs) { + const host = new URL(job.data.to).host; + if (res.find(x => x[0] === host)) { + res.find(x => x[0] === host)![1]++; + } else { + res.push([host, 1]); + } } -} + + res.sort((a, b) => b[1] - a[1]); + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts index 58c943b753..2235ce8f97 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts @@ -1,19 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { URL } from 'node:url'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { InboxQueue } from '@/core/QueueModule.js'; +import define from '../../../define.js'; +import { inboxQueue } from '@/queue/queues.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'read:admin:queue', res: { type: 'array', @@ -45,28 +38,22 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject('queue:inbox') public inboxQueue: InboxQueue, - ) { - super(meta, paramDef, async (ps, me) => { - const jobs = await this.inboxQueue.getJobs(['delayed']); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const jobs = await inboxQueue.getJobs(['delayed']); - const res = [] as [string, number][]; + const res = [] as [string, number][]; - for (const job of jobs) { - const host = new URL(job.data.signature.keyId).host; - if (res.find(x => x[0] === host)) { - res.find(x => x[0] === host)![1]++; - } else { - res.push([host, 1]); - } - } - - res.sort((a, b) => b[1] - a[1]); - - return res; - }); + for (const job of jobs) { + const host = new URL(job.data.signature.keyId).host; + if (res.find(x => x[0] === host)) { + res.find(x => x[0] === host)![1]++; + } else { + res.push([host, 1]); + } } -} + + res.sort((a, b) => b[1] - a[1]); + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/promote.ts b/packages/backend/src/server/api/endpoints/admin/queue/promote.ts deleted file mode 100644 index ab6c416054..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/queue/promote.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { QueueService } from '@/core/QueueService.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: 'always', - requireModerator: true, - kind: 'write:admin:queue', -} as const; - -export const paramDef = { - type: 'object', - properties: { - type: { type: 'string', enum: ['deliver', 'inbox'] }, - }, - required: ['type'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private moderationLogService: ModerationLogService, - private queueService: QueueService, - ) { - super(meta, paramDef, async (ps, me) => { - let delayedQueues; - - switch (ps.type) { - case 'deliver': - delayedQueues = await this.queueService.deliverQueue.getDelayed(); - for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) { - const queue = delayedQueues[queueIndex]; - try { - await queue.promote(); - } catch (e) { - if (e instanceof Error) { - if (e.message.indexOf('not in a delayed state') !== -1) { - throw e; - } - } else { - throw e; - } - } - } - break; - - case 'inbox': - delayedQueues = await this.queueService.inboxQueue.getDelayed(); - for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) { - const queue = delayedQueues[queueIndex]; - try { - await queue.promote(); - } catch (e) { - if (e instanceof Error) { - if (e.message.indexOf('not in a delayed state') !== -1) { - throw e; - } - } else { - throw e; - } - } - } - break; - } - - this.moderationLogService.log(me, 'promoteQueue'); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts index 1591a84448..988b5a5e35 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts @@ -1,18 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from '@/core/QueueModule.js'; +import { deliverQueue, inboxQueue, dbQueue, objectStorageQueue } from '@/queue/queues.js'; +import define from '../../../define.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'read:admin:emoji', res: { type: 'object', @@ -44,29 +37,17 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject('queue:system') public systemQueue: SystemQueue, - @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, - @Inject('queue:deliver') public deliverQueue: DeliverQueue, - @Inject('queue:inbox') public inboxQueue: InboxQueue, - @Inject('queue:db') public dbQueue: DbQueue, - @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, - @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, - ) { - super(meta, paramDef, async (ps, me) => { - const deliverJobCounts = await this.deliverQueue.getJobCounts(); - const inboxJobCounts = await this.inboxQueue.getJobCounts(); - const dbJobCounts = await this.dbQueue.getJobCounts(); - const objectStorageJobCounts = await this.objectStorageQueue.getJobCounts(); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const deliverJobCounts = await deliverQueue.getJobCounts(); + const inboxJobCounts = await inboxQueue.getJobCounts(); + const dbJobCounts = await dbQueue.getJobCounts(); + const objectStorageJobCounts = await objectStorageQueue.getJobCounts(); - return { - deliver: deliverJobCounts, - inbox: inboxJobCounts, - db: dbJobCounts, - objectStorage: objectStorageJobCounts, - }; - }); - } -} + return { + deliver: deliverJobCounts, + inbox: inboxJobCounts, + db: dbJobCounts, + objectStorage: objectStorageJobCounts, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/relays/add.ts b/packages/backend/src/server/api/endpoints/admin/relays/add.ts index 1a80de2eaf..348e9baca1 100644 --- a/packages/backend/src/server/api/endpoints/admin/relays/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/relays/add.ts @@ -1,20 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { URL } from 'node:url'; -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { RelayService } from '@/core/RelayService.js'; +import define from '../../../define.js'; +import { addRelay } from '@/services/relay.js'; import { ApiError } from '../../../error.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:relays', errors: { invalidUrl: { @@ -60,19 +53,13 @@ export const paramDef = { required: ['inbox'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private relayService: RelayService, - ) { - super(meta, paramDef, async (ps, me) => { - try { - if (new URL(ps.inbox).protocol !== 'https:') throw new Error('https only'); - } catch { - throw new ApiError(meta.errors.invalidUrl); - } - - return await this.relayService.addRelay(ps.inbox); - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + try { + if (new URL(ps.inbox).protocol !== 'https:') throw 'https only'; + } catch { + throw new ApiError(meta.errors.invalidUrl); } -} + + return await addRelay(ps.inbox); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/relays/list.ts b/packages/backend/src/server/api/endpoints/admin/relays/list.ts index fd9ffbb6b8..89ec651e61 100644 --- a/packages/backend/src/server/api/endpoints/admin/relays/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/relays/list.ts @@ -1,18 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { RelayService } from '@/core/RelayService.js'; +import define from '../../../define.js'; +import { listRelay } from '@/services/relay.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'read:admin:relays', res: { type: 'array', @@ -52,13 +45,7 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private relayService: RelayService, - ) { - super(meta, paramDef, async (ps, me) => { - return await this.relayService.listRelay(); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + return await listRelay(); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/relays/remove.ts b/packages/backend/src/server/api/endpoints/admin/relays/remove.ts index b3acac8d4d..b59cf72c58 100644 --- a/packages/backend/src/server/api/endpoints/admin/relays/remove.ts +++ b/packages/backend/src/server/api/endpoints/admin/relays/remove.ts @@ -1,18 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { RelayService } from '@/core/RelayService.js'; +import define from '../../../define.js'; +import { removeRelay } from '@/services/relay.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:relays', } as const; export const paramDef = { @@ -23,13 +16,7 @@ export const paramDef = { required: ['inbox'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private relayService: RelayService, - ) { - super(meta, paramDef, async (ps, me) => { - return await this.relayService.removeRelay(ps.inbox); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + return await removeRelay(ps.inbox); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/reset-password.ts index ea4f502224..be4c2dceed 100644 --- a/packages/backend/src/server/api/endpoints/admin/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts @@ -1,23 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -//import bcrypt from 'bcryptjs'; -import * as argon2 from 'argon2'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { secureRndstr } from '@/misc/secure-rndstr.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; +import define from '../../define.js'; +import bcrypt from 'bcryptjs'; +import rndstr from 'rndstr'; +import { Users, UserProfiles } from '@/models/index.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:reset-password', res: { type: 'object', @@ -41,48 +31,30 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const user = await Users.findOneBy({ id: ps.userId }); - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - private moderationLogService: ModerationLogService, - ) { - super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new Error('user not found'); - } - - if (user.isRoot) { - throw new Error('cannot reset password of root'); - } - - const passwd = secureRndstr(8); - - // Generate hash of password - const hash = await argon2.hash(passwd); - - await this.userProfilesRepository.update({ - userId: user.id, - }, { - password: hash, - }); - - this.moderationLogService.log(me, 'resetPassword', { - userId: user.id, - userUsername: user.username, - userHost: user.host, - }); - - return { - password: passwd, - }; - }); + if (user == null) { + throw new Error('user not found'); } -} + + if (user.isAdmin) { + throw new Error('cannot reset password of admin'); + } + + const passwd = rndstr('a-zA-Z0-9', 8); + + // Generate hash of password + const hash = bcrypt.hashSync(passwd); + + await UserProfiles.update({ + userId: user.id, + }, { + password: hash, + }); + + return { + password: passwd, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts index bb0708f960..3edae4a85f 100644 --- a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts +++ b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts @@ -1,23 +1,15 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, AbuseUserReportsRepository } from '@/models/_.js'; -import { InstanceActorService } from '@/core/InstanceActorService.js'; -import { QueueService } from '@/core/QueueService.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { DI } from '@/di-symbols.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; +import define from '../../define.js'; +import { AbuseUserReports, Users } from '@/models/index.js'; +import { getInstanceActor } from '@/services/instance-actor.js'; +import { deliver } from '@/queue/index.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import { renderFlag } from '@/remote/activitypub/renderer/flag.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:resolve-abuse-user-report', } as const; export const paramDef = { @@ -29,47 +21,24 @@ export const paramDef = { required: ['reportId'], } as const; -// TODO: ロジックをサービスに切り出す +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const report = await AbuseUserReports.findOneByOrFail({ id: ps.reportId }); -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.abuseUserReportsRepository) - private abuseUserReportsRepository: AbuseUserReportsRepository, - - private queueService: QueueService, - private instanceActorService: InstanceActorService, - private apRendererService: ApRendererService, - private moderationLogService: ModerationLogService, - ) { - super(meta, paramDef, async (ps, me) => { - const report = await this.abuseUserReportsRepository.findOneBy({ id: ps.reportId }); - - if (report == null) { - throw new Error('report not found'); - } - - if (ps.forward && report.targetUserHost != null) { - const actor = await this.instanceActorService.getInstanceActor(); - const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId }); - - this.queueService.deliver(actor, this.apRendererService.addContext(this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment)), targetUser.inbox, false); - } - - await this.abuseUserReportsRepository.update(report.id, { - resolved: true, - assigneeId: me.id, - forwarded: ps.forward && report.targetUserHost != null, - }); - - this.moderationLogService.log(me, 'resolveAbuseReport', { - reportId: report.id, - report: report, - forwarded: ps.forward && report.targetUserHost != null, - }); - }); + if (report == null) { + throw new Error('report not found'); } -} + + if (ps.forward && report.targetUserHost != null) { + const actor = await getInstanceActor(); + const targetUser = await Users.findOneByOrFail({ id: report.targetUserId }); + + deliver(actor, renderActivity(renderFlag(actor, [targetUser.uri!], report.comment)), targetUser.inbox); + } + + await AbuseUserReports.update(report.id, { + resolved: true, + assigneeId: me.id, + forwarded: ps.forward && report.targetUserHost != null, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/roles/assign.ts b/packages/backend/src/server/api/endpoints/admin/roles/assign.ts deleted file mode 100644 index 138f3d9710..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/roles/assign.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RolesRepository, UsersRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '@/server/api/error.js'; -import { RoleService } from '@/core/RoleService.js'; - -export const meta = { - tags: ['admin', 'role'], - - requireCredential: 'always', - requireModerator: true, - kind: 'write:admin:roles', - - errors: { - noSuchRole: { - message: 'No such role.', - code: 'NO_SUCH_ROLE', - id: '6503c040-6af4-4ed9-bf07-f2dd16678eab', - }, - - noSuchUser: { - message: 'No such user.', - code: 'NO_SUCH_USER', - id: '558ea170-f653-4700-94d0-5a818371d0df', - }, - - accessDenied: { - message: 'Only administrators can edit members of the role.', - code: 'ACCESS_DENIED', - id: '25b5bc31-dc79-4ebd-9bd2-c84978fd052c', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - roleId: { type: 'string', format: 'misskey:id' }, - userId: { type: 'string', format: 'misskey:id' }, - expiresAt: { - type: 'integer', - nullable: true, - }, - }, - required: [ - 'roleId', - 'userId', - ], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.rolesRepository) - private rolesRepository: RolesRepository, - - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); - if (role == null) { - throw new ApiError(meta.errors.noSuchRole); - } - - if (!role.canEditMembersByModerator && !(await this.roleService.isAdministrator(me))) { - throw new ApiError(meta.errors.accessDenied); - } - - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - if (user == null) { - throw new ApiError(meta.errors.noSuchUser); - } - - if (ps.expiresAt && ps.expiresAt <= Date.now()) { - return; - } - - await this.roleService.assign(user.id, role.id, ps.expiresAt ? new Date(ps.expiresAt) : null, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/roles/create.ts b/packages/backend/src/server/api/endpoints/admin/roles/create.ts deleted file mode 100644 index 4349b7bc9a..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/roles/create.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; -import { RoleService } from '@/core/RoleService.js'; - -export const meta = { - tags: ['admin', 'role'], - - requireCredential: 'always', - requireAdmin: true, - kind: 'write:admin:roles', - - res: { - type: 'object', - optional: false, nullable: false, - ref: 'Role', - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - name: { type: 'string' }, - description: { type: 'string' }, - color: { type: 'string', nullable: true }, - iconUrl: { type: 'string', nullable: true }, - target: { type: 'string', enum: ['manual', 'conditional'] }, - condFormula: { type: 'object' }, - isPublic: { type: 'boolean' }, - isModerator: { type: 'boolean' }, - isAdministrator: { type: 'boolean' }, - isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility - asBadge: { type: 'boolean' }, - canEditMembersByModerator: { type: 'boolean' }, - displayOrder: { type: 'number' }, - policies: { - type: 'object', - }, - }, - required: [ - 'name', - 'description', - 'color', - 'iconUrl', - 'target', - 'condFormula', - 'isPublic', - 'isModerator', - 'isAdministrator', - 'asBadge', - 'canEditMembersByModerator', - 'displayOrder', - 'policies', - ], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private roleEntityService: RoleEntityService, - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const created = await this.roleService.create(ps, me); - - return await this.roleEntityService.pack(created, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/roles/delete.ts b/packages/backend/src/server/api/endpoints/admin/roles/delete.ts deleted file mode 100644 index 72a7ccb81c..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/roles/delete.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RolesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '@/server/api/error.js'; -import { RoleService } from '@/core/RoleService.js'; - -export const meta = { - tags: ['admin', 'role'], - - requireCredential: 'always', - requireAdmin: true, - kind: 'write:admin:roles', - - errors: { - noSuchRole: { - message: 'No such role.', - code: 'NO_SUCH_ROLE', - id: 'de0d6ecd-8e0a-4253-88ff-74bc89ae3d45', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - roleId: { type: 'string', format: 'misskey:id' }, - }, - required: [ - 'roleId', - ], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.rolesRepository) - private rolesRepository: RolesRepository, - - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); - if (role == null) { - throw new ApiError(meta.errors.noSuchRole); - } - await this.roleService.delete(role, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/roles/list.ts b/packages/backend/src/server/api/endpoints/admin/roles/list.ts deleted file mode 100644 index b394047a2c..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/roles/list.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RolesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; - -export const meta = { - tags: ['admin', 'role'], - - requireCredential: 'always', - requireModerator: true, - kind: 'read:admin:roles', - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'Role', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - }, - required: [ - ], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.rolesRepository) - private rolesRepository: RolesRepository, - - private roleEntityService: RoleEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const roles = await this.rolesRepository.find({ - order: { lastUsedAt: 'DESC' }, - }); - return await this.roleEntityService.packMany(roles, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/roles/show.ts b/packages/backend/src/server/api/endpoints/admin/roles/show.ts deleted file mode 100644 index 3eace5ebc9..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/roles/show.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RolesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '@/server/api/error.js'; -import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; - -export const meta = { - tags: ['admin', 'role'], - - requireCredential: 'always', - requireModerator: true, - kind: 'read:admin:roles', - - errors: { - noSuchRole: { - message: 'No such role.', - code: 'NO_SUCH_ROLE', - id: '07dc7d34-c0d8-49b7-96c6-db3ce64ee0b3', - }, - }, - - res: { - type: 'object', - optional: false, nullable: false, - ref: 'Role', - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - roleId: { type: 'string', format: 'misskey:id' }, - }, - required: [ - 'roleId', - ], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.rolesRepository) - private rolesRepository: RolesRepository, - - private roleEntityService: RoleEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); - if (role == null) { - throw new ApiError(meta.errors.noSuchRole); - } - return await this.roleEntityService.pack(role, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts b/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts deleted file mode 100644 index dd75893e3c..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RolesRepository, UsersRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '@/server/api/error.js'; -import { RoleService } from '@/core/RoleService.js'; - -export const meta = { - tags: ['admin', 'role'], - - requireCredential: 'always', - requireModerator: true, - kind: 'write:admin:roles', - - errors: { - noSuchRole: { - message: 'No such role.', - code: 'NO_SUCH_ROLE', - id: '6e519036-a70d-4c76-b679-bc8fb18194e2', - }, - - noSuchUser: { - message: 'No such user.', - code: 'NO_SUCH_USER', - id: '2b730f78-1179-461b-88ad-d24c9af1a5ce', - }, - - notAssigned: { - message: 'Not assigned.', - code: 'NOT_ASSIGNED', - id: 'b9060ac7-5c94-4da4-9f55-2047c953df44', - }, - - accessDenied: { - message: 'Only administrators can edit members of the role.', - code: 'ACCESS_DENIED', - id: '24636eee-e8c1-493e-94b2-e16ad401e262', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - roleId: { type: 'string', format: 'misskey:id' }, - userId: { type: 'string', format: 'misskey:id' }, - }, - required: [ - 'roleId', - 'userId', - ], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.rolesRepository) - private rolesRepository: RolesRepository, - - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); - if (role == null) { - throw new ApiError(meta.errors.noSuchRole); - } - - if (!role.canEditMembersByModerator && !(await this.roleService.isAdministrator(me))) { - throw new ApiError(meta.errors.accessDenied); - } - - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - if (user == null) { - throw new ApiError(meta.errors.noSuchUser); - } - - await this.roleService.unassign(user.id, role.id, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts b/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts deleted file mode 100644 index 1ed7237c7b..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { MetaService } from '@/core/MetaService.js'; - -export const meta = { - tags: ['admin', 'role'], - - requireCredential: 'always', - requireAdmin: true, - kind: 'write:admin:roles', -} as const; - -export const paramDef = { - type: 'object', - properties: { - policies: { - type: 'object', - }, - }, - required: [ - 'policies', - ], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private metaService: MetaService, - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps) => { - await this.metaService.update({ - policies: ps.policies, - }); - this.globalEventService.publishInternalEvent('policiesUpdated', ps.policies); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update.ts b/packages/backend/src/server/api/endpoints/admin/roles/update.ts deleted file mode 100644 index 80f8397193..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RolesRepository } from '@/models/_.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '@/server/api/error.js'; -import { RoleService } from '@/core/RoleService.js'; - -export const meta = { - tags: ['admin', 'role'], - - requireCredential: 'always', - requireAdmin: true, - kind: 'write:admin:roles', - - errors: { - noSuchRole: { - message: 'No such role.', - code: 'NO_SUCH_ROLE', - id: 'cd23ef55-09ad-428a-ac61-95a45e124b32', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - roleId: { type: 'string', format: 'misskey:id' }, - name: { type: 'string' }, - description: { type: 'string' }, - color: { type: 'string', nullable: true }, - iconUrl: { type: 'string', nullable: true }, - target: { type: 'string', enum: ['manual', 'conditional'] }, - condFormula: { type: 'object' }, - isPublic: { type: 'boolean' }, - isModerator: { type: 'boolean' }, - isAdministrator: { type: 'boolean' }, - isExplorable: { type: 'boolean' }, - asBadge: { type: 'boolean' }, - canEditMembersByModerator: { type: 'boolean' }, - displayOrder: { type: 'number' }, - policies: { - type: 'object', - }, - }, - required: [ - 'roleId', - 'name', - 'description', - 'color', - 'iconUrl', - 'target', - 'condFormula', - 'isPublic', - 'isModerator', - 'isAdministrator', - 'asBadge', - 'canEditMembersByModerator', - 'displayOrder', - 'policies', - ], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.rolesRepository) - private rolesRepository: RolesRepository, - - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); - if (role == null) { - throw new ApiError(meta.errors.noSuchRole); - } - - await this.roleService.update(role, { - name: ps.name, - description: ps.description, - color: ps.color, - iconUrl: ps.iconUrl, - target: ps.target, - condFormula: ps.condFormula, - isPublic: ps.isPublic, - isModerator: ps.isModerator, - isAdministrator: ps.isAdministrator, - isExplorable: ps.isExplorable, - asBadge: ps.asBadge, - canEditMembersByModerator: ps.canEditMembersByModerator, - displayOrder: ps.displayOrder, - policies: ps.policies, - }, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/roles/users.ts b/packages/backend/src/server/api/endpoints/admin/roles/users.ts deleted file mode 100644 index 9785f15674..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/roles/users.ts +++ /dev/null @@ -1,100 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Brackets } from 'typeorm'; -import type { RoleAssignmentsRepository, RolesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { DI } from '@/di-symbols.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { IdService } from '@/core/IdService.js'; -import { ApiError } from '../../../error.js'; - -export const meta = { - tags: ['admin', 'role', 'users'], - - requireCredential: 'never', - requireAdmin: true, - kind: 'read:admin:roles', - - errors: { - noSuchRole: { - message: 'No such role.', - code: 'NO_SUCH_ROLE', - id: '224eff5e-2488-4b18-b3e7-f50d94421648', - }, - }, - - res: { - type: 'array', - items: { - type: 'object', - properties: { - id: { type: 'string', format: 'misskey:id' }, - createdAt: { type: 'string', format: 'date-time' }, - user: { ref: 'UserDetailed' }, - expiresAt: { type: 'string', format: 'date-time', nullable: true }, - }, - required: ['id', 'createdAt', 'user'], - }, - } -} as const; - -export const paramDef = { - type: 'object', - properties: { - roleId: { type: 'string', format: 'misskey:id' }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - }, - required: ['roleId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.rolesRepository) - private rolesRepository: RolesRepository, - - @Inject(DI.roleAssignmentsRepository) - private roleAssignmentsRepository: RoleAssignmentsRepository, - - private queryService: QueryService, - private userEntityService: UserEntityService, - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const role = await this.rolesRepository.findOneBy({ - id: ps.roleId, - }); - - if (role == null) { - throw new ApiError(meta.errors.noSuchRole); - } - - const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId) - .andWhere('assign.roleId = :roleId', { roleId: role.id }) - .andWhere(new Brackets(qb => { - qb - .where('assign.expiresAt IS NULL') - .orWhere('assign.expiresAt > :now', { now: new Date() }); - })) - .innerJoinAndSelect('assign.user', 'user'); - - const assigns = await query - .limit(ps.limit) - .getMany(); - - return await Promise.all(assigns.map(async assign => ({ - id: assign.id, - createdAt: this.idService.parse(assign.id).date.toISOString(), - user: await this.userEntityService.pack(assign.user!, me, { detail: true }), - expiresAt: assign.expiresAt?.toISOString() ?? null, - }))); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/send-email.ts b/packages/backend/src/server/api/endpoints/admin/send-email.ts index c3629d1c70..bbdd66e4c9 100644 --- a/packages/backend/src/server/api/endpoints/admin/send-email.ts +++ b/packages/backend/src/server/api/endpoints/admin/send-email.ts @@ -1,18 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { EmailService } from '@/core/EmailService.js'; +import define from '../../define.js'; +import { sendEmail } from '@/services/send-email.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:send-email', } as const; export const paramDef = { @@ -25,13 +18,7 @@ export const paramDef = { required: ['to', 'subject', 'text'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private emailService: EmailService, - ) { - super(meta, paramDef, async (ps, me) => { - await this.emailService.sendEmail(ps.to, ps.subject, ps.text, ps.text); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + await sendEmail(ps.to, ps.subject, ps.text, ps.text); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/server-info.ts b/packages/backend/src/server/api/endpoints/admin/server-info.ts index e33ebd4033..85c6fb82e7 100644 --- a/packages/backend/src/server/api/endpoints/admin/server-info.ts +++ b/packages/backend/src/server/api/endpoints/admin/server-info.ts @@ -1,20 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import * as os from 'node:os'; import si from 'systeminformation'; -import { Inject, Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import * as Redis from 'ioredis'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { redisClient } from '../../../../db/redis.js'; +import { db } from '@/db/postgre.js'; export const meta = { - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'read:admin:server-info', tags: ['admin', 'meta'], @@ -101,46 +93,35 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.db) - private db: DataSource, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async () => { + const memStats = await si.mem(); + const fsStats = await si.fsSize(); + const netInterface = await si.networkInterfaceDefault(); - @Inject(DI.redis) - private redisClient: Redis.Redis, + const redisServerInfo = await redisClient.info('Server'); + const m = redisServerInfo.match(new RegExp('^redis_version:(.*)', 'm')); + const redis_version = m?.[1]; - ) { - super(meta, paramDef, async () => { - const memStats = await si.mem(); - const fsStats = await si.fsSize(); - const netInterface = await si.networkInterfaceDefault(); - - const redisServerInfo = await this.redisClient.info('Server'); - const m = redisServerInfo.match(new RegExp('^redis_version:(.*)', 'm')); - const redis_version = m?.[1]; - - return { - machine: os.hostname(), - os: os.platform(), - node: process.version, - psql: await this.db.query('SHOW server_version').then(x => x[0].server_version), - redis: redis_version, - cpu: { - model: os.cpus()[0].model, - cores: os.cpus().length, - }, - mem: { - total: memStats.total, - }, - fs: { - total: fsStats[0].size, - used: fsStats[0].used, - }, - net: { - interface: netInterface, - }, - }; - }); - } -} + return { + machine: os.hostname(), + os: os.platform(), + node: process.version, + psql: await db.query('SHOW server_version').then(x => x[0].server_version), + redis: redis_version, + cpu: { + model: os.cpus()[0].model, + cores: os.cpus().length, + }, + mem: { + total: memStats.total, + }, + fs: { + total: fsStats[0].size, + used: fsStats[0].used, + }, + net: { + interface: netInterface, + }, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts b/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts index 6eb18151b1..3545536aa2 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts @@ -1,21 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ModerationLogsRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { DI } from '@/di-symbols.js'; -import { ModerationLogEntityService } from '@/core/entities/ModerationLogEntityService.js'; +import define from '../../define.js'; +import { ModerationLogs } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', - requireAdmin: true, - kind: 'read:admin:show-moderation-log', + requireCredential: true, + requireModerator: true, res: { type: 'array', @@ -63,35 +54,15 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, - type: { type: 'string', nullable: true }, - userId: { type: 'string', format: 'misskey:id', nullable: true }, }, required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.moderationLogsRepository) - private moderationLogsRepository: ModerationLogsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const query = makePaginationQuery(ModerationLogs.createQueryBuilder('report'), ps.sinceId, ps.untilId); - private moderationLogEntityService: ModerationLogEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.moderationLogsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId); + const reports = await query.take(ps.limit).getMany(); - if (ps.type != null) { - query.andWhere('report.type = :type', { type: ps.type }); - } - - if (ps.userId != null) { - query.andWhere('report.userId = :userId', { userId: ps.userId }); - } - - const reports = await query.limit(ps.limit).getMany(); - - return await this.moderationLogEntityService.packMany(reports); - }); - } -} + return await ModerationLogs.packMany(reports); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index b7738359a7..0d866b3113 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -1,22 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, SigninsRepository, UserProfilesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; -import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; -import { IdService } from '@/core/IdService.js'; +import { Signins, UserProfiles, Users } from '@/models/index.js'; +import define from '../../define.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'read:admin:show-user', res: { type: 'object', @@ -32,76 +21,56 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const [user, profile] = await Promise.all([ + Users.findOneBy({ id: ps.userId }), + UserProfiles.findOneBy({ userId: ps.userId }), + ]); - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.signinsRepository) - private signinsRepository: SigninsRepository, - - private roleService: RoleService, - private roleEntityService: RoleEntityService, - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const [user, profile] = await Promise.all([ - this.usersRepository.findOneBy({ id: ps.userId }), - this.userProfilesRepository.findOneBy({ userId: ps.userId }), - ]); - - if (user == null || profile == null) { - throw new Error('user not found'); - } - - const isModerator = await this.roleService.isModerator(user); - const isSilenced = user.isSilenced || !(await this.roleService.getUserPolicies(user.id)).canPublicNote; - - const _me = await this.usersRepository.findOneByOrFail({ id: me.id }); - if (!await this.roleService.isAdministrator(_me) && await this.roleService.isAdministrator(user)) { - throw new Error('cannot show info of admin'); - } - - const signins = await this.signinsRepository.findBy({ userId: user.id }); - - const roleAssigns = await this.roleService.getUserAssigns(user.id); - const roles = await this.roleService.getUserRoles(user.id); - - return { - email: profile.email, - emailVerified: profile.emailVerified, - approved: user.approved, - signupReason: user.signupReason, - autoAcceptFollowed: profile.autoAcceptFollowed, - noCrawle: profile.noCrawle, - preventAiLearning: profile.preventAiLearning, - alwaysMarkNsfw: profile.alwaysMarkNsfw, - autoSensitive: profile.autoSensitive, - carefulBot: profile.carefulBot, - injectFeaturedNote: profile.injectFeaturedNote, - receiveAnnouncementEmail: profile.receiveAnnouncementEmail, - mutedWords: profile.mutedWords, - mutedInstances: profile.mutedInstances, - notificationRecieveConfig: profile.notificationRecieveConfig, - isModerator: isModerator, - isSilenced: isSilenced, - isSuspended: user.isSuspended, - isHibernated: user.isHibernated, - lastActiveDate: user.lastActiveDate, - moderationNote: profile.moderationNote ?? '', - signins, - policies: await this.roleService.getUserPolicies(user.id), - roles: await this.roleEntityService.packMany(roles, me), - roleAssigns: roleAssigns.map(a => ({ - createdAt: this.idService.parse(a.id).date.toISOString(), - expiresAt: a.expiresAt ? a.expiresAt.toISOString() : null, - roleId: a.roleId, - })), - }; - }); + if (user == null || profile == null) { + throw new Error('user not found'); } -} + + const _me = await Users.findOneByOrFail({ id: me.id }); + if ((_me.isModerator && !_me.isAdmin) && user.isAdmin) { + throw new Error('cannot show info of admin'); + } + + if (!_me.isAdmin) { + return { + isModerator: user.isModerator, + isSilenced: user.isSilenced, + isSuspended: user.isSuspended, + }; + } + + const maskedKeys = ['accessToken', 'accessTokenSecret', 'refreshToken']; + Object.keys(profile.integrations).forEach(integration => { + maskedKeys.forEach(key => profile.integrations[integration][key] = ''); + }); + + const signins = await Signins.findBy({ userId: user.id }); + + return { + email: profile.email, + emailVerified: profile.emailVerified, + autoAcceptFollowed: profile.autoAcceptFollowed, + noCrawle: profile.noCrawle, + alwaysMarkNsfw: profile.alwaysMarkNsfw, + autoSensitive: profile.autoSensitive, + carefulBot: profile.carefulBot, + injectFeaturedNote: profile.injectFeaturedNote, + receiveAnnouncementEmail: profile.receiveAnnouncementEmail, + integrations: profile.integrations, + mutedWords: profile.mutedWords, + mutedInstances: profile.mutedInstances, + mutingNotificationTypes: profile.mutingNotificationTypes, + isModerator: user.isModerator, + isSilenced: user.isSilenced, + isSuspended: user.isSuspended, + lastActiveDate: user.lastActiveDate, + moderationNote: profile.moderationNote, + signins, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts index 37194bf041..8e09e72d5b 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-users.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts @@ -1,22 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; -import { RoleService } from '@/core/RoleService.js'; +import { Users } from '@/models/index.js'; +import define from '../../define.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'read:admin:show-users', res: { type: 'array', @@ -34,8 +23,8 @@ export const paramDef = { properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, offset: { type: 'integer', default: 0 }, - sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt', '+lastActiveDate', '-lastActiveDate'] }, - state: { type: 'string', enum: ['all', 'alive', 'available', 'admin', 'moderator', 'adminOrModerator', 'suspended', 'approved'], default: 'all' }, + sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] }, + state: { type: 'string', enum: ['all', 'alive', 'available', 'admin', 'moderator', 'adminOrModerator', 'silenced', 'suspended'], default: 'all' }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' }, username: { type: 'string', nullable: true, default: null }, hostname: { @@ -48,74 +37,47 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = Users.createQueryBuilder('user'); - private userEntityService: UserEntityService, - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.usersRepository.createQueryBuilder('user'); - - switch (ps.state) { - case 'available': query.where('user.isSuspended = FALSE'); break; - case 'alive': query.where('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break; - case 'suspended': query.where('user.isSuspended = TRUE'); break; - case 'approved': query.where('user.approved = FALSE'); break; - case 'admin': { - const adminIds = await this.roleService.getAdministratorIds(); - if (adminIds.length === 0) return []; - query.where('user.id IN (:...adminIds)', { adminIds: adminIds }); - break; - } - case 'moderator': { - const moderatorIds = await this.roleService.getModeratorIds(false); - if (moderatorIds.length === 0) return []; - query.where('user.id IN (:...moderatorIds)', { moderatorIds: moderatorIds }); - break; - } - case 'adminOrModerator': { - const adminOrModeratorIds = await this.roleService.getModeratorIds(); - if (adminOrModeratorIds.length === 0) return []; - query.where('user.id IN (:...adminOrModeratorIds)', { adminOrModeratorIds: adminOrModeratorIds }); - break; - } - } - - switch (ps.origin) { - case 'local': query.andWhere('user.host IS NULL'); break; - case 'remote': query.andWhere('user.host IS NOT NULL'); break; - } - - if (ps.username) { - query.andWhere('user.usernameLower like :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }); - } - - if (ps.hostname) { - query.andWhere('user.host = :hostname', { hostname: ps.hostname.toLowerCase() }); - } - - switch (ps.sort) { - case '+follower': query.orderBy('user.followersCount', 'DESC'); break; - case '-follower': query.orderBy('user.followersCount', 'ASC'); break; - case '+createdAt': query.orderBy('user.id', 'DESC'); break; - case '-createdAt': query.orderBy('user.id', 'ASC'); break; - case '+updatedAt': query.orderBy('user.updatedAt', 'DESC', 'NULLS LAST'); break; - case '-updatedAt': query.orderBy('user.updatedAt', 'ASC', 'NULLS FIRST'); break; - case '+lastActiveDate': query.orderBy('user.lastActiveDate', 'DESC', 'NULLS LAST'); break; - case '-lastActiveDate': query.orderBy('user.lastActiveDate', 'ASC', 'NULLS FIRST'); break; - default: query.orderBy('user.id', 'ASC'); break; - } - - query.limit(ps.limit); - query.offset(ps.offset); - - const users = await query.getMany(); - - return await this.userEntityService.packMany(users, me, { detail: true }); - }); + switch (ps.state) { + case 'available': query.where('user.isSuspended = FALSE'); break; + case 'admin': query.where('user.isAdmin = TRUE'); break; + case 'moderator': query.where('user.isModerator = TRUE'); break; + case 'adminOrModerator': query.where('user.isAdmin = TRUE OR user.isModerator = TRUE'); break; + case 'alive': query.where('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break; + case 'silenced': query.where('user.isSilenced = TRUE'); break; + case 'suspended': query.where('user.isSuspended = TRUE'); break; } -} + + switch (ps.origin) { + case 'local': query.andWhere('user.host IS NULL'); break; + case 'remote': query.andWhere('user.host IS NOT NULL'); break; + } + + if (ps.username) { + query.andWhere('user.usernameLower like :username', { username: ps.username.toLowerCase() + '%' }); + } + + if (ps.hostname) { + query.andWhere('user.host = :hostname', { hostname: ps.hostname.toLowerCase() }); + } + + switch (ps.sort) { + case '+follower': query.orderBy('user.followersCount', 'DESC'); break; + case '-follower': query.orderBy('user.followersCount', 'ASC'); break; + case '+createdAt': query.orderBy('user.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('user.createdAt', 'ASC'); break; + case '+updatedAt': query.orderBy('user.updatedAt', 'DESC', 'NULLS LAST'); break; + case '-updatedAt': query.orderBy('user.updatedAt', 'ASC', 'NULLS FIRST'); break; + default: query.orderBy('user.id', 'ASC'); break; + } + + query.take(ps.limit); + query.skip(ps.offset); + + const users = await query.getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/silence-user.ts b/packages/backend/src/server/api/endpoints/admin/silence-user.ts index ab1d1d1a6b..17b9f3b5a0 100644 --- a/packages/backend/src/server/api/endpoints/admin/silence-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/silence-user.ts @@ -1,15 +1,13 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; +import define from '../../define.js'; +import { Users } from '@/models/index.js'; +import { insertModerationLog } from '@/services/insert-moderation-log.js'; +import { publishInternalEvent } from '@/services/stream.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:silence-user', } as const; export const paramDef = { @@ -20,28 +18,25 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy({ id: ps.userId }); - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new Error('user not found'); - } - - if (await this.roleService.isModerator(user)) { - throw new Error('cannot silence moderator account'); - } - - await this.usersRepository.update(user.id, { - isSilenced: true, - }); - }); + if (user == null) { + throw new Error('user not found'); } -} + + if (user.isAdmin) { + throw new Error('cannot silence admin'); + } + + await Users.update(user.id, { + isSilenced: true, + }); + + publishInternalEvent('userChangeSilencedState', { id: user.id, isSilenced: true }); + + insertModerationLog(me, 'silence', { + targetId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts index ec24745c52..ed513eda08 100644 --- a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts @@ -1,27 +1,16 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { IsNull, Not } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, FollowingsRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; -import type { RelationshipJobData } from '@/queue/types.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { UserSuspendService } from '@/core/UserSuspendService.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; -import { RoleService } from '@/core/RoleService.js'; -import { QueueService } from '@/core/QueueService.js'; +import define from '../../define.js'; +import deleteFollowing from '@/services/following/delete.js'; +import { Users, Followings, Notifications } from '@/models/index.js'; +import { User } from '@/models/entities/user.js'; +import { insertModerationLog } from '@/services/insert-moderation-log.js'; +import { doPostSuspend } from '@/services/suspend-user.js'; +import { publishUserEvent } from '@/services/stream.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:suspend-user', } as const; export const paramDef = { @@ -32,67 +21,65 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy({ id: ps.userId }); - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - private userSuspendService: UserSuspendService, - private roleService: RoleService, - private moderationLogService: ModerationLogService, - private queueService: QueueService, - ) { - super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new Error('user not found'); - } - - if (await this.roleService.isModerator(user)) { - throw new Error('cannot suspend moderator account'); - } - - await this.usersRepository.update(user.id, { - isSuspended: true, - }); - - this.moderationLogService.log(me, 'suspend', { - userId: user.id, - userUsername: user.username, - userHost: user.host, - }); - - (async () => { - await this.userSuspendService.doPostSuspend(user).catch(e => {}); - await this.unFollowAll(user).catch(e => {}); - })(); - }); + if (user == null) { + throw new Error('user not found'); } - @bindThis - private async unFollowAll(follower: MiUser) { - const followings = await this.followingsRepository.find({ - where: { - followerId: follower.id, - followeeId: Not(IsNull()), - }, + if (user.isAdmin) { + throw new Error('cannot suspend admin'); + } + + if (user.isModerator) { + throw new Error('cannot suspend moderator'); + } + + await Users.update(user.id, { + isSuspended: true, + }); + + insertModerationLog(me, 'suspend', { + targetId: user.id, + }); + + // Terminate streaming + if (Users.isLocalUser(user)) { + publishUserEvent(user.id, 'terminate', {}); + } + + (async () => { + await doPostSuspend(user).catch(e => {}); + await unFollowAll(user).catch(e => {}); + await readAllNotify(user).catch(e => {}); + })(); +}); + +async function unFollowAll(follower: User) { + const followings = await Followings.findBy({ + followerId: follower.id, + }); + + for (const following of followings) { + const followee = await Users.findOneBy({ + id: following.followeeId, }); - const jobs: RelationshipJobData[] = []; - for (const following of followings) { - if (following.followeeId && following.followerId) { - jobs.push({ - from: { id: following.followerId }, - to: { id: following.followeeId }, - silent: true, - }); - } + if (followee == null) { + throw `Cant find followee ${following.followeeId}`; } - this.queueService.createUnfollowJob(jobs); + + await deleteFollowing(follower, followee, true); } } + +async function readAllNotify(notifier: User) { + await Notifications.update({ + notifierId: notifier.id, + isRead: false, + }, { + isRead: true, + }); +} diff --git a/packages/backend/src/server/api/endpoints/admin/unnsfw-user.ts b/packages/backend/src/server/api/endpoints/admin/unnsfw-user.ts deleted file mode 100644 index 1b3fd60f57..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/unnsfw-user.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: 'always', - requireModerator: true, - kind: 'write:admin:unnsfw-user', -} as const; - -export const paramDef = { - type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new Error('user not found'); - } - - await this.userProfilesRepository.update(user.id, { - alwaysMarkNsfw: false, - }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/unset-user-avatar.ts b/packages/backend/src/server/api/endpoints/admin/unset-user-avatar.ts deleted file mode 100644 index 7294ea0c97..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/unset-user-avatar.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: 'always', - requireModerator: true, - kind: 'write:admin:unset-user-avatar', -} as const; - -export const paramDef = { - type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], -} as const; - -// eslint-disable-next-line import/no-default-export -@Injectable() -export default class extends Endpoint { - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private moderationLogService: ModerationLogService, - ) { - super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new Error('user not found'); - } - - if (user.avatarId == null) return; - - await this.usersRepository.update(user.id, { - avatar: null, - avatarId: null, - avatarUrl: null, - avatarBlurhash: null, - }); - - this.moderationLogService.log(me, 'unsetUserAvatar', { - userId: user.id, - userUsername: user.username, - userHost: user.host, - fileId: user.avatarId, - }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/unset-user-banner.ts b/packages/backend/src/server/api/endpoints/admin/unset-user-banner.ts deleted file mode 100644 index 845d642f1d..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/unset-user-banner.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: 'always', - requireModerator: true, - kind: 'write:admin:unset-user-banner', -} as const; - -export const paramDef = { - type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], -} as const; - -// eslint-disable-next-line import/no-default-export -@Injectable() -export default class extends Endpoint { - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private moderationLogService: ModerationLogService, - ) { - super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new Error('user not found'); - } - - if (user.bannerId == null) return; - - await this.usersRepository.update(user.id, { - banner: null, - bannerId: null, - bannerUrl: null, - bannerBlurhash: null, - }); - - this.moderationLogService.log(me, 'unsetUserBanner', { - userId: user.id, - userUsername: user.username, - userHost: user.host, - fileId: user.bannerId, - }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts index 2b97a191cc..a4b373f5c7 100644 --- a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts @@ -1,14 +1,13 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { Users } from '@/models/index.js'; +import { insertModerationLog } from '@/services/insert-moderation-log.js'; +import { publishInternalEvent } from '@/services/stream.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:unsilence-user', } as const; export const paramDef = { @@ -19,22 +18,21 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy({ id: ps.userId }); - if (user == null) { - throw new Error('user not found'); - } - - await this.usersRepository.update(user.id, { - isSilenced: false, - }); - }); + if (user == null) { + throw new Error('user not found'); } -} + + await Users.update(user.id, { + isSilenced: false, + }); + + publishInternalEvent('userChangeSilencedState', { id: user.id, isSilenced: false }); + + insertModerationLog(me, 'unsilence', { + targetId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts b/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts index 839fe5a38d..5cf26251be 100644 --- a/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts @@ -1,21 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository } from '@/models/_.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { UserSuspendService } from '@/core/UserSuspendService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { Users } from '@/models/index.js'; +import { insertModerationLog } from '@/services/insert-moderation-log.js'; +import { doPostUnsuspend } from '@/services/unsuspend-user.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:unsuspend-user', } as const; export const paramDef = { @@ -26,33 +18,21 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy({ id: ps.userId }); - private userSuspendService: UserSuspendService, - private moderationLogService: ModerationLogService, - ) { - super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new Error('user not found'); - } - - await this.usersRepository.update(user.id, { - isSuspended: false, - }); - - this.moderationLogService.log(me, 'unsuspend', { - userId: user.id, - userUsername: user.username, - userHost: user.host, - }); - - this.userSuspendService.doPostUnsuspend(user); - }); + if (user == null) { + throw new Error('user not found'); } -} + + await Users.update(user.id, { + isSuspended: false, + }); + + insertModerationLog(me, 'unsuspend', { + targetId: user.id, + }); + + doPostUnsuspend(user); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index f7d9c0a17f..f14aa41050 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -1,98 +1,79 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import type { MiMeta } from '@/models/Meta.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { MetaService } from '@/core/MetaService.js'; +import { Meta } from '@/models/entities/meta.js'; +import { insertModerationLog } from '@/services/insert-moderation-log.js'; +import { DB_MAX_NOTE_TEXT_LENGTH } from '@/misc/hard-limits.js'; +import { db } from '@/db/postgre.js'; +import define from '../../define.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireAdmin: true, - kind: 'write:admin:meta', } as const; export const paramDef = { type: 'object', properties: { disableRegistration: { type: 'boolean', nullable: true }, - pinnedUsers: { - type: 'array', nullable: true, items: { - type: 'string', - }, - }, - hiddenTags: { - type: 'array', nullable: true, items: { - type: 'string', - }, - }, - blockedHosts: { - type: 'array', nullable: true, items: { - type: 'string', - }, - }, - allowedHosts: { - type: 'array', nullable: true, items: { - type: 'string', - }, - }, - allowlistMode: { type: 'boolean', nullable: true }, - sensitiveWords: { - type: 'array', nullable: true, items: { - type: 'string', - }, - }, + disableLocalTimeline: { type: 'boolean', nullable: true }, + disableGlobalTimeline: { type: 'boolean', nullable: true }, + useStarForReactionFallback: { type: 'boolean', nullable: true }, + pinnedUsers: { type: 'array', nullable: true, items: { + type: 'string', + } }, + hiddenTags: { type: 'array', nullable: true, items: { + type: 'string', + } }, + blockedHosts: { type: 'array', nullable: true, items: { + type: 'string', + } }, themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' }, mascotImageUrl: { type: 'string', nullable: true }, bannerUrl: { type: 'string', nullable: true }, - serverErrorImageUrl: { type: 'string', nullable: true }, - infoImageUrl: { type: 'string', nullable: true }, - notFoundImageUrl: { type: 'string', nullable: true }, + errorImageUrl: { type: 'string', nullable: true }, iconUrl: { type: 'string', nullable: true }, - app192IconUrl: { type: 'string', nullable: true }, - app512IconUrl: { type: 'string', nullable: true }, backgroundImageUrl: { type: 'string', nullable: true }, logoImageUrl: { type: 'string', nullable: true }, name: { type: 'string', nullable: true }, - shortName: { type: 'string', nullable: true }, description: { type: 'string', nullable: true }, defaultLightTheme: { type: 'string', nullable: true }, defaultDarkTheme: { type: 'string', nullable: true }, - defaultLike: { type: 'string', nullable: true }, + localDriveCapacityMb: { type: 'integer' }, + remoteDriveCapacityMb: { type: 'integer' }, cacheRemoteFiles: { type: 'boolean' }, - cacheRemoteSensitiveFiles: { type: 'boolean' }, emailRequiredForSignup: { type: 'boolean' }, - approvalRequiredForSignup: { type: 'boolean' }, enableHcaptcha: { type: 'boolean' }, hcaptchaSiteKey: { type: 'string', nullable: true }, hcaptchaSecretKey: { type: 'string', nullable: true }, enableRecaptcha: { type: 'boolean' }, recaptchaSiteKey: { type: 'string', nullable: true }, recaptchaSecretKey: { type: 'string', nullable: true }, - enableTurnstile: { type: 'boolean' }, - turnstileSiteKey: { type: 'string', nullable: true }, - turnstileSecretKey: { type: 'string', nullable: true }, sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] }, sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] }, setSensitiveFlagAutomatically: { type: 'boolean' }, enableSensitiveMediaDetectionForVideos: { type: 'boolean' }, - enableBotTrending: { type: 'boolean' }, proxyAccountId: { type: 'string', format: 'misskey:id', nullable: true }, maintainerName: { type: 'string', nullable: true }, maintainerEmail: { type: 'string', nullable: true }, - langs: { - type: 'array', items: { - type: 'string', - }, - }, + pinnedPages: { type: 'array', items: { + type: 'string', + } }, + pinnedClipId: { type: 'string', format: 'misskey:id', nullable: true }, + langs: { type: 'array', items: { + type: 'string', + } }, summalyProxy: { type: 'string', nullable: true }, deeplAuthKey: { type: 'string', nullable: true }, deeplIsPro: { type: 'boolean' }, + enableTwitterIntegration: { type: 'boolean' }, + twitterConsumerKey: { type: 'string', nullable: true }, + twitterConsumerSecret: { type: 'string', nullable: true }, + enableGithubIntegration: { type: 'boolean' }, + githubClientId: { type: 'string', nullable: true }, + githubClientSecret: { type: 'string', nullable: true }, + enableDiscordIntegration: { type: 'boolean' }, + discordClientId: { type: 'string', nullable: true }, + discordClientSecret: { type: 'string', nullable: true }, enableEmail: { type: 'boolean' }, email: { type: 'string', nullable: true }, smtpSecure: { type: 'boolean' }, @@ -106,8 +87,6 @@ export const paramDef = { tosUrl: { type: 'string', nullable: true }, repositoryUrl: { type: 'string' }, feedbackUrl: { type: 'string' }, - impressumUrl: { type: 'string', nullable: true }, - privacyPolicyUrl: { type: 'string', nullable: true }, useObjectStorage: { type: 'boolean' }, objectStorageBaseUrl: { type: 'string', nullable: true }, objectStorageBucket: { type: 'string', nullable: true }, @@ -123,446 +102,345 @@ export const paramDef = { objectStorageS3ForcePathStyle: { type: 'boolean' }, enableIpLogging: { type: 'boolean' }, enableActiveEmailValidation: { type: 'boolean' }, - enableVerifymailApi: { type: 'boolean' }, - verifymailAuthKey: { type: 'string', nullable: true }, - enableChartsForRemoteUser: { type: 'boolean' }, - enableChartsForFederatedInstances: { type: 'boolean' }, - enableServerMachineStats: { type: 'boolean' }, - enableAchievements: { type: 'boolean' }, - enableIdenticonGeneration: { type: 'boolean' }, - serverRules: { type: 'array', items: { type: 'string' } }, - bannedEmailDomains: { type: 'array', items: { type: 'string' } }, - preservedUsernames: { type: 'array', items: { type: 'string' } }, - bubbleInstances: { type: 'array', items: { type: 'string' } }, - manifestJsonOverride: { type: 'string' }, - enableFanoutTimeline: { type: 'boolean' }, - enableFanoutTimelineDbFallback: { type: 'boolean' }, - perLocalUserUserTimelineCacheMax: { type: 'integer' }, - perRemoteUserUserTimelineCacheMax: { type: 'integer' }, - perUserHomeTimelineCacheMax: { type: 'integer' }, - perUserListTimelineCacheMax: { type: 'integer' }, - notesPerOneAd: { type: 'integer' }, - silencedHosts: { - type: 'array', - nullable: true, - items: { - type: 'string', - }, - }, }, required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private metaService: MetaService, - private moderationLogService: ModerationLogService, - ) { - super(meta, paramDef, async (ps, me) => { - const set = {} as Partial; +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const set = {} as Partial; - if (typeof ps.disableRegistration === 'boolean') { - set.disableRegistration = ps.disableRegistration; - } - - if (Array.isArray(ps.pinnedUsers)) { - set.pinnedUsers = ps.pinnedUsers.filter(Boolean); - } - - if (Array.isArray(ps.hiddenTags)) { - set.hiddenTags = ps.hiddenTags.filter(Boolean); - } - - if (Array.isArray(ps.blockedHosts)) { - set.blockedHosts = ps.blockedHosts.filter(Boolean).map(x => x.toLowerCase()); - } - - if (Array.isArray(ps.allowedHosts)) { - set.allowedHosts = ps.allowedHosts.filter(Boolean).map(x => x.toLowerCase()); - } - - if (ps.allowlistMode !== undefined) { - set.allowlistMode = ps.allowlistMode; - } - - if (Array.isArray(ps.sensitiveWords)) { - set.sensitiveWords = ps.sensitiveWords.filter(Boolean); - } - if (Array.isArray(ps.silencedHosts)) { - let lastValue = ''; - set.silencedHosts = ps.silencedHosts.sort().filter((h) => { - const lv = lastValue; - lastValue = h; - return h !== '' && h !== lv && !set.blockedHosts?.includes(h); - }); - } - if (ps.themeColor !== undefined) { - set.themeColor = ps.themeColor; - } - - if (ps.mascotImageUrl !== undefined) { - set.mascotImageUrl = ps.mascotImageUrl; - } - - if (ps.bannerUrl !== undefined) { - set.bannerUrl = ps.bannerUrl; - } - - if (ps.iconUrl !== undefined) { - set.iconUrl = ps.iconUrl; - } - - if (ps.app192IconUrl !== undefined) { - set.app192IconUrl = ps.app192IconUrl; - } - - if (ps.app512IconUrl !== undefined) { - set.app512IconUrl = ps.app512IconUrl; - } - - if (ps.serverErrorImageUrl !== undefined) { - set.serverErrorImageUrl = ps.serverErrorImageUrl; - } - - if (ps.infoImageUrl !== undefined) { - set.infoImageUrl = ps.infoImageUrl; - } - - if (ps.notFoundImageUrl !== undefined) { - set.notFoundImageUrl = ps.notFoundImageUrl; - } - - if (ps.backgroundImageUrl !== undefined) { - set.backgroundImageUrl = ps.backgroundImageUrl; - } - - if (ps.logoImageUrl !== undefined) { - set.logoImageUrl = ps.logoImageUrl; - } - - if (ps.name !== undefined) { - set.name = ps.name; - } - - if (ps.shortName !== undefined) { - set.shortName = ps.shortName; - } - - if (ps.description !== undefined) { - set.description = ps.description; - } - - if (ps.defaultLightTheme !== undefined) { - set.defaultLightTheme = ps.defaultLightTheme; - } - - if (ps.defaultDarkTheme !== undefined) { - set.defaultDarkTheme = ps.defaultDarkTheme; - } - - if (ps.defaultLike !== undefined) { - set.defaultLike = ps.defaultLike; - } - - if (ps.cacheRemoteFiles !== undefined) { - set.cacheRemoteFiles = ps.cacheRemoteFiles; - } - - if (ps.cacheRemoteSensitiveFiles !== undefined) { - set.cacheRemoteSensitiveFiles = ps.cacheRemoteSensitiveFiles; - } - - if (ps.emailRequiredForSignup !== undefined) { - set.emailRequiredForSignup = ps.emailRequiredForSignup; - } - - if (ps.approvalRequiredForSignup !== undefined) { - set.approvalRequiredForSignup = ps.approvalRequiredForSignup; - } - - if (ps.enableHcaptcha !== undefined) { - set.enableHcaptcha = ps.enableHcaptcha; - } - - if (ps.hcaptchaSiteKey !== undefined) { - set.hcaptchaSiteKey = ps.hcaptchaSiteKey; - } - - if (ps.hcaptchaSecretKey !== undefined) { - set.hcaptchaSecretKey = ps.hcaptchaSecretKey; - } - - if (ps.enableRecaptcha !== undefined) { - set.enableRecaptcha = ps.enableRecaptcha; - } - - if (ps.recaptchaSiteKey !== undefined) { - set.recaptchaSiteKey = ps.recaptchaSiteKey; - } - - if (ps.recaptchaSecretKey !== undefined) { - set.recaptchaSecretKey = ps.recaptchaSecretKey; - } - - if (ps.enableTurnstile !== undefined) { - set.enableTurnstile = ps.enableTurnstile; - } - - if (ps.turnstileSiteKey !== undefined) { - set.turnstileSiteKey = ps.turnstileSiteKey; - } - - if (ps.turnstileSecretKey !== undefined) { - set.turnstileSecretKey = ps.turnstileSecretKey; - } - - if (ps.enableBotTrending !== undefined) { - set.enableBotTrending = ps.enableBotTrending; - } - - if (ps.proxyAccountId !== undefined) { - set.proxyAccountId = ps.proxyAccountId; - } - - if (ps.maintainerName !== undefined) { - set.maintainerName = ps.maintainerName; - } - - if (ps.maintainerEmail !== undefined) { - set.maintainerEmail = ps.maintainerEmail; - } - - if (Array.isArray(ps.langs)) { - set.langs = ps.langs.filter(Boolean); - } - - if (ps.summalyProxy !== undefined) { - set.summalyProxy = ps.summalyProxy; - } - - if (ps.enableEmail !== undefined) { - set.enableEmail = ps.enableEmail; - } - - if (ps.email !== undefined) { - set.email = ps.email; - } - - if (ps.smtpSecure !== undefined) { - set.smtpSecure = ps.smtpSecure; - } - - if (ps.smtpHost !== undefined) { - set.smtpHost = ps.smtpHost; - } - - if (ps.smtpPort !== undefined) { - set.smtpPort = ps.smtpPort; - } - - if (ps.smtpUser !== undefined) { - set.smtpUser = ps.smtpUser; - } - - if (ps.smtpPass !== undefined) { - set.smtpPass = ps.smtpPass; - } - - if (ps.enableServiceWorker !== undefined) { - set.enableServiceWorker = ps.enableServiceWorker; - } - - if (ps.swPublicKey !== undefined) { - set.swPublicKey = ps.swPublicKey; - } - - if (ps.swPrivateKey !== undefined) { - set.swPrivateKey = ps.swPrivateKey; - } - - if (ps.tosUrl !== undefined) { - set.termsOfServiceUrl = ps.tosUrl; - } - - if (ps.repositoryUrl !== undefined) { - set.repositoryUrl = ps.repositoryUrl; - } - - if (ps.feedbackUrl !== undefined) { - set.feedbackUrl = ps.feedbackUrl; - } - - if (ps.impressumUrl !== undefined) { - set.impressumUrl = ps.impressumUrl; - } - - if (ps.privacyPolicyUrl !== undefined) { - set.privacyPolicyUrl = ps.privacyPolicyUrl; - } - - if (ps.useObjectStorage !== undefined) { - set.useObjectStorage = ps.useObjectStorage; - } - - if (ps.objectStorageBaseUrl !== undefined) { - set.objectStorageBaseUrl = ps.objectStorageBaseUrl; - } - - if (ps.objectStorageBucket !== undefined) { - set.objectStorageBucket = ps.objectStorageBucket; - } - - if (ps.objectStoragePrefix !== undefined) { - set.objectStoragePrefix = ps.objectStoragePrefix; - } - - if (ps.objectStorageEndpoint !== undefined) { - set.objectStorageEndpoint = ps.objectStorageEndpoint; - } - - if (ps.objectStorageRegion !== undefined) { - set.objectStorageRegion = ps.objectStorageRegion; - } - - if (ps.objectStoragePort !== undefined) { - set.objectStoragePort = ps.objectStoragePort; - } - - if (ps.objectStorageAccessKey !== undefined) { - set.objectStorageAccessKey = ps.objectStorageAccessKey; - } - - if (ps.objectStorageSecretKey !== undefined) { - set.objectStorageSecretKey = ps.objectStorageSecretKey; - } - - if (ps.objectStorageUseSSL !== undefined) { - set.objectStorageUseSSL = ps.objectStorageUseSSL; - } - - if (ps.objectStorageUseProxy !== undefined) { - set.objectStorageUseProxy = ps.objectStorageUseProxy; - } - - if (ps.objectStorageSetPublicRead !== undefined) { - set.objectStorageSetPublicRead = ps.objectStorageSetPublicRead; - } - - if (ps.objectStorageS3ForcePathStyle !== undefined) { - set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle; - } - - if (ps.deeplAuthKey !== undefined) { - if (ps.deeplAuthKey === '') { - set.deeplAuthKey = null; - } else { - set.deeplAuthKey = ps.deeplAuthKey; - } - } - - if (ps.deeplIsPro !== undefined) { - set.deeplIsPro = ps.deeplIsPro; - } - - if (ps.enableIpLogging !== undefined) { - set.enableIpLogging = ps.enableIpLogging; - } - - if (ps.enableActiveEmailValidation !== undefined) { - set.enableActiveEmailValidation = ps.enableActiveEmailValidation; - } - - if (ps.enableVerifymailApi !== undefined) { - set.enableVerifymailApi = ps.enableVerifymailApi; - } - - if (ps.verifymailAuthKey !== undefined) { - if (ps.verifymailAuthKey === '') { - set.verifymailAuthKey = null; - } else { - set.verifymailAuthKey = ps.verifymailAuthKey; - } - } - - if (ps.enableChartsForRemoteUser !== undefined) { - set.enableChartsForRemoteUser = ps.enableChartsForRemoteUser; - } - - if (ps.enableChartsForFederatedInstances !== undefined) { - set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances; - } - - if (ps.enableServerMachineStats !== undefined) { - set.enableServerMachineStats = ps.enableServerMachineStats; - } - - if (ps.enableAchievements !== undefined) { - set.enableAchievements = ps.enableAchievements; - } - - if (ps.enableIdenticonGeneration !== undefined) { - set.enableIdenticonGeneration = ps.enableIdenticonGeneration; - } - - if (ps.serverRules !== undefined) { - set.serverRules = ps.serverRules; - } - - if (ps.preservedUsernames !== undefined) { - set.preservedUsernames = ps.preservedUsernames; - } - - if (ps.bubbleInstances !== undefined) { - set.bubbleInstances = ps.bubbleInstances; - } - - if (ps.manifestJsonOverride !== undefined) { - set.manifestJsonOverride = ps.manifestJsonOverride; - } - - if (ps.enableFanoutTimeline !== undefined) { - set.enableFanoutTimeline = ps.enableFanoutTimeline; - } - - if (ps.enableFanoutTimelineDbFallback !== undefined) { - set.enableFanoutTimelineDbFallback = ps.enableFanoutTimelineDbFallback; - } - - if (ps.perLocalUserUserTimelineCacheMax !== undefined) { - set.perLocalUserUserTimelineCacheMax = ps.perLocalUserUserTimelineCacheMax; - } - - if (ps.perRemoteUserUserTimelineCacheMax !== undefined) { - set.perRemoteUserUserTimelineCacheMax = ps.perRemoteUserUserTimelineCacheMax; - } - - if (ps.perUserHomeTimelineCacheMax !== undefined) { - set.perUserHomeTimelineCacheMax = ps.perUserHomeTimelineCacheMax; - } - - if (ps.perUserListTimelineCacheMax !== undefined) { - set.perUserListTimelineCacheMax = ps.perUserListTimelineCacheMax; - } - - if (ps.notesPerOneAd !== undefined) { - set.notesPerOneAd = ps.notesPerOneAd; - } - - if (ps.bannedEmailDomains !== undefined) { - set.bannedEmailDomains = ps.bannedEmailDomains; - } - - const before = await this.metaService.fetch(true); - - await this.metaService.update(set); - - const after = await this.metaService.fetch(true); - - this.moderationLogService.log(me, 'updateServerSettings', { - before, - after, - }); - }); + if (typeof ps.disableRegistration === 'boolean') { + set.disableRegistration = ps.disableRegistration; } -} + + if (typeof ps.disableLocalTimeline === 'boolean') { + set.disableLocalTimeline = ps.disableLocalTimeline; + } + + if (typeof ps.disableGlobalTimeline === 'boolean') { + set.disableGlobalTimeline = ps.disableGlobalTimeline; + } + + if (typeof ps.useStarForReactionFallback === 'boolean') { + set.useStarForReactionFallback = ps.useStarForReactionFallback; + } + + if (Array.isArray(ps.pinnedUsers)) { + set.pinnedUsers = ps.pinnedUsers.filter(Boolean); + } + + if (Array.isArray(ps.hiddenTags)) { + set.hiddenTags = ps.hiddenTags.filter(Boolean); + } + + if (Array.isArray(ps.blockedHosts)) { + set.blockedHosts = ps.blockedHosts.filter(Boolean); + } + + if (ps.themeColor !== undefined) { + set.themeColor = ps.themeColor; + } + + if (ps.mascotImageUrl !== undefined) { + set.mascotImageUrl = ps.mascotImageUrl; + } + + if (ps.bannerUrl !== undefined) { + set.bannerUrl = ps.bannerUrl; + } + + if (ps.iconUrl !== undefined) { + set.iconUrl = ps.iconUrl; + } + + if (ps.backgroundImageUrl !== undefined) { + set.backgroundImageUrl = ps.backgroundImageUrl; + } + + if (ps.logoImageUrl !== undefined) { + set.logoImageUrl = ps.logoImageUrl; + } + + if (ps.name !== undefined) { + set.name = ps.name; + } + + if (ps.description !== undefined) { + set.description = ps.description; + } + + if (ps.defaultLightTheme !== undefined) { + set.defaultLightTheme = ps.defaultLightTheme; + } + + if (ps.defaultDarkTheme !== undefined) { + set.defaultDarkTheme = ps.defaultDarkTheme; + } + + if (ps.localDriveCapacityMb !== undefined) { + set.localDriveCapacityMb = ps.localDriveCapacityMb; + } + + if (ps.remoteDriveCapacityMb !== undefined) { + set.remoteDriveCapacityMb = ps.remoteDriveCapacityMb; + } + + if (ps.cacheRemoteFiles !== undefined) { + set.cacheRemoteFiles = ps.cacheRemoteFiles; + } + + if (ps.emailRequiredForSignup !== undefined) { + set.emailRequiredForSignup = ps.emailRequiredForSignup; + } + + if (ps.enableHcaptcha !== undefined) { + set.enableHcaptcha = ps.enableHcaptcha; + } + + if (ps.hcaptchaSiteKey !== undefined) { + set.hcaptchaSiteKey = ps.hcaptchaSiteKey; + } + + if (ps.hcaptchaSecretKey !== undefined) { + set.hcaptchaSecretKey = ps.hcaptchaSecretKey; + } + + if (ps.enableRecaptcha !== undefined) { + set.enableRecaptcha = ps.enableRecaptcha; + } + + if (ps.recaptchaSiteKey !== undefined) { + set.recaptchaSiteKey = ps.recaptchaSiteKey; + } + + if (ps.recaptchaSecretKey !== undefined) { + set.recaptchaSecretKey = ps.recaptchaSecretKey; + } + + if (ps.sensitiveMediaDetection !== undefined) { + set.sensitiveMediaDetection = ps.sensitiveMediaDetection; + } + + if (ps.sensitiveMediaDetectionSensitivity !== undefined) { + set.sensitiveMediaDetectionSensitivity = ps.sensitiveMediaDetectionSensitivity; + } + + if (ps.setSensitiveFlagAutomatically !== undefined) { + set.setSensitiveFlagAutomatically = ps.setSensitiveFlagAutomatically; + } + + if (ps.enableSensitiveMediaDetectionForVideos !== undefined) { + set.enableSensitiveMediaDetectionForVideos = ps.enableSensitiveMediaDetectionForVideos; + } + + if (ps.proxyAccountId !== undefined) { + set.proxyAccountId = ps.proxyAccountId; + } + + if (ps.maintainerName !== undefined) { + set.maintainerName = ps.maintainerName; + } + + if (ps.maintainerEmail !== undefined) { + set.maintainerEmail = ps.maintainerEmail; + } + + if (Array.isArray(ps.langs)) { + set.langs = ps.langs.filter(Boolean); + } + + if (Array.isArray(ps.pinnedPages)) { + set.pinnedPages = ps.pinnedPages.filter(Boolean); + } + + if (ps.pinnedClipId !== undefined) { + set.pinnedClipId = ps.pinnedClipId; + } + + if (ps.summalyProxy !== undefined) { + set.summalyProxy = ps.summalyProxy; + } + + if (ps.enableTwitterIntegration !== undefined) { + set.enableTwitterIntegration = ps.enableTwitterIntegration; + } + + if (ps.twitterConsumerKey !== undefined) { + set.twitterConsumerKey = ps.twitterConsumerKey; + } + + if (ps.twitterConsumerSecret !== undefined) { + set.twitterConsumerSecret = ps.twitterConsumerSecret; + } + + if (ps.enableGithubIntegration !== undefined) { + set.enableGithubIntegration = ps.enableGithubIntegration; + } + + if (ps.githubClientId !== undefined) { + set.githubClientId = ps.githubClientId; + } + + if (ps.githubClientSecret !== undefined) { + set.githubClientSecret = ps.githubClientSecret; + } + + if (ps.enableDiscordIntegration !== undefined) { + set.enableDiscordIntegration = ps.enableDiscordIntegration; + } + + if (ps.discordClientId !== undefined) { + set.discordClientId = ps.discordClientId; + } + + if (ps.discordClientSecret !== undefined) { + set.discordClientSecret = ps.discordClientSecret; + } + + if (ps.enableEmail !== undefined) { + set.enableEmail = ps.enableEmail; + } + + if (ps.email !== undefined) { + set.email = ps.email; + } + + if (ps.smtpSecure !== undefined) { + set.smtpSecure = ps.smtpSecure; + } + + if (ps.smtpHost !== undefined) { + set.smtpHost = ps.smtpHost; + } + + if (ps.smtpPort !== undefined) { + set.smtpPort = ps.smtpPort; + } + + if (ps.smtpUser !== undefined) { + set.smtpUser = ps.smtpUser; + } + + if (ps.smtpPass !== undefined) { + set.smtpPass = ps.smtpPass; + } + + if (ps.errorImageUrl !== undefined) { + set.errorImageUrl = ps.errorImageUrl; + } + + if (ps.enableServiceWorker !== undefined) { + set.enableServiceWorker = ps.enableServiceWorker; + } + + if (ps.swPublicKey !== undefined) { + set.swPublicKey = ps.swPublicKey; + } + + if (ps.swPrivateKey !== undefined) { + set.swPrivateKey = ps.swPrivateKey; + } + + if (ps.tosUrl !== undefined) { + set.ToSUrl = ps.tosUrl; + } + + if (ps.repositoryUrl !== undefined) { + set.repositoryUrl = ps.repositoryUrl; + } + + if (ps.feedbackUrl !== undefined) { + set.feedbackUrl = ps.feedbackUrl; + } + + if (ps.useObjectStorage !== undefined) { + set.useObjectStorage = ps.useObjectStorage; + } + + if (ps.objectStorageBaseUrl !== undefined) { + set.objectStorageBaseUrl = ps.objectStorageBaseUrl; + } + + if (ps.objectStorageBucket !== undefined) { + set.objectStorageBucket = ps.objectStorageBucket; + } + + if (ps.objectStoragePrefix !== undefined) { + set.objectStoragePrefix = ps.objectStoragePrefix; + } + + if (ps.objectStorageEndpoint !== undefined) { + set.objectStorageEndpoint = ps.objectStorageEndpoint; + } + + if (ps.objectStorageRegion !== undefined) { + set.objectStorageRegion = ps.objectStorageRegion; + } + + if (ps.objectStoragePort !== undefined) { + set.objectStoragePort = ps.objectStoragePort; + } + + if (ps.objectStorageAccessKey !== undefined) { + set.objectStorageAccessKey = ps.objectStorageAccessKey; + } + + if (ps.objectStorageSecretKey !== undefined) { + set.objectStorageSecretKey = ps.objectStorageSecretKey; + } + + if (ps.objectStorageUseSSL !== undefined) { + set.objectStorageUseSSL = ps.objectStorageUseSSL; + } + + if (ps.objectStorageUseProxy !== undefined) { + set.objectStorageUseProxy = ps.objectStorageUseProxy; + } + + if (ps.objectStorageSetPublicRead !== undefined) { + set.objectStorageSetPublicRead = ps.objectStorageSetPublicRead; + } + + if (ps.objectStorageS3ForcePathStyle !== undefined) { + set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle; + } + + if (ps.deeplAuthKey !== undefined) { + if (ps.deeplAuthKey === '') { + set.deeplAuthKey = null; + } else { + set.deeplAuthKey = ps.deeplAuthKey; + } + } + + if (ps.deeplIsPro !== undefined) { + set.deeplIsPro = ps.deeplIsPro; + } + + if (ps.enableIpLogging !== undefined) { + set.enableIpLogging = ps.enableIpLogging; + } + + if (ps.enableActiveEmailValidation !== undefined) { + set.enableActiveEmailValidation = ps.enableActiveEmailValidation; + } + + await db.transaction(async transactionalEntityManager => { + const metas = await transactionalEntityManager.find(Meta, { + order: { + id: 'DESC', + }, + }); + + const meta = metas[0]; + + if (meta) { + await transactionalEntityManager.update(Meta, meta.id, set); + } else { + await transactionalEntityManager.save(Meta, set); + } + }); + + insertModerationLog(me, 'updateMeta'); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/update-user-note.ts b/packages/backend/src/server/api/endpoints/admin/update-user-note.ts index 397406b520..fa21ab7833 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-user-note.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-user-note.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UserProfilesRepository, UsersRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { UserProfiles, Users } from '@/models/index.js'; +import define from '../../define.js'; export const meta = { tags: ['admin'], - requireCredential: 'always', + requireCredential: true, requireModerator: true, - kind: 'write:admin:user-note', } as const; export const paramDef = { @@ -26,37 +17,15 @@ export const paramDef = { required: ['userId', 'text'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy({ id: ps.userId }); - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - private moderationLogService: ModerationLogService, - ) { - super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new Error('user not found'); - } - - const currentProfile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - - await this.userProfilesRepository.update({ userId: user.id }, { - moderationNote: ps.text, - }); - - this.moderationLogService.log(me, 'updateUserNote', { - userId: user.id, - userUsername: user.username, - userHost: user.host, - before: currentProfile.moderationNote, - after: ps.text, - }); - }); + if (user == null) { + throw new Error('user not found'); } -} + + await UserProfiles.update({ userId: user.id }, { + moderationNote: ps.text, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/vacuum.ts b/packages/backend/src/server/api/endpoints/admin/vacuum.ts new file mode 100644 index 0000000000..0546acfacb --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/vacuum.ts @@ -0,0 +1,36 @@ +import define from '../../define.js'; +import { insertModerationLog } from '@/services/insert-moderation-log.js'; +import { db } from '@/db/postgre.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + full: { type: 'boolean' }, + analyze: { type: 'boolean' }, + }, + required: ['full', 'analyze'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const params: string[] = []; + + if (ps.full) { + params.push('FULL'); + } + + if (ps.analyze) { + params.push('ANALYZE'); + } + + db.query('VACUUM ' + params.join(' ')); + + insertModerationLog(me, 'vacuum', ps); +}); diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts index 156be59ccb..23cb93c9a5 100644 --- a/packages/backend/src/server/api/endpoints/announcements.ts +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Brackets } from 'typeorm'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { AnnouncementService } from '@/core/AnnouncementService.js'; -import { DI } from '@/di-symbols.js'; -import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/_.js'; +import { Announcements, AnnouncementReads } from '@/models/index.js'; +import define from '../define.js'; +import { makePaginationQuery } from '../common/make-pagination-query.js'; export const meta = { tags: ['meta'], - requireCredential: 'never', + requireCredential: false, res: { type: 'array', @@ -22,7 +13,40 @@ export const meta = { items: { type: 'object', optional: false, nullable: false, - ref: 'Announcement', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + updatedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, + text: { + type: 'string', + optional: false, nullable: false, + }, + title: { + type: 'string', + optional: false, nullable: false, + }, + imageUrl: { + type: 'string', + optional: false, nullable: true, + }, + isRead: { + type: 'boolean', + optional: true, nullable: false, + }, + }, }, }, } as const; @@ -31,36 +55,32 @@ export const paramDef = { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + withUnreads: { type: 'boolean', default: false }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, - isActive: { type: 'boolean', default: true }, }, required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.announcementsRepository) - private announcementsRepository: AnnouncementsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery(Announcements.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); - @Inject(DI.announcementReadsRepository) - private announcementReadsRepository: AnnouncementReadsRepository, + const announcements = await query.take(ps.limit).getMany(); - private queryService: QueryService, - private announcementService: AnnouncementService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId) - .andWhere('announcement.isActive = :isActive', { isActive: ps.isActive }) - .andWhere(new Brackets(qb => { - if (me) qb.orWhere('announcement.userId = :meId', { meId: me.id }); - qb.orWhere('announcement.userId IS NULL'); - })); + if (user) { + const reads = (await AnnouncementReads.findBy({ + userId: user.id, + })).map(x => x.announcementId); - const announcements = await query.limit(ps.limit).getMany(); - - return this.announcementService.packMany(announcements, me); - }); + for (const announcement of announcements) { + (announcement as any).isRead = reads.includes(announcement.id); + } } -} + + return (ps.withUnreads ? announcements.filter((a: any) => !a.isRead) : announcements).map((a) => ({ + ...a, + createdAt: a.createdAt.toISOString(), + updatedAt: a.updatedAt?.toISOString() ?? null, + })); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index 56ddde3e94..7a4923b944 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -1,24 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { IdService } from '@/core/IdService.js'; -import type { UserListsRepository, AntennasRepository } from '@/models/_.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; +import define from '../../define.js'; +import { genId } from '@/misc/gen-id.js'; +import { Antennas, UserLists, UserGroupJoinings } from '@/models/index.js'; import { ApiError } from '../../error.js'; +import { publishInternalEvent } from '@/services/stream.js'; export const meta = { tags: ['antennas'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:account', @@ -29,10 +18,10 @@ export const meta = { id: '95063e93-a283-4b8b-9aa5-bcdb8df69a7f', }, - tooManyAntennas: { - message: 'You cannot create antenna any more.', - code: 'TOO_MANY_ANTENNAS', - id: 'faf47050-e8b5-438c-913c-db2b1576fde4', + noSuchUserGroup: { + message: 'No such user group.', + code: 'NO_SUCH_USER_GROUP', + id: 'aa3c0b9a-8cae-47c0-92ac-202ce5906682', }, }, @@ -47,8 +36,9 @@ export const paramDef = { type: 'object', properties: { name: { type: 'string', minLength: 1, maxLength: 100 }, - src: { type: 'string', enum: ['home', 'all', 'users', 'list', 'users_blacklist'] }, + src: { type: 'string', enum: ['home', 'all', 'users', 'list', 'group'] }, userListId: { type: 'string', format: 'misskey:id', nullable: true }, + userGroupId: { type: 'string', format: 'misskey:id', nullable: true }, keywords: { type: 'array', items: { type: 'array', items: { type: 'string', @@ -63,7 +53,6 @@ export const paramDef = { type: 'string', } }, caseSensitive: { type: 'boolean' }, - localOnly: { type: 'boolean' }, withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, notify: { type: 'boolean' }, @@ -71,67 +60,49 @@ export const paramDef = { required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile', 'notify'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.antennasRepository) - private antennasRepository: AntennasRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + let userList; + let userGroupJoining; - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - private antennaEntityService: AntennaEntityService, - private roleService: RoleService, - private idService: IdService, - private globalEventService: GlobalEventService, - ) { - 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.'); - } - - const currentAntennasCount = await this.antennasRepository.countBy({ - userId: me.id, - }); - if (currentAntennasCount > (await this.roleService.getUserPolicies(me.id)).antennaLimit) { - throw new ApiError(meta.errors.tooManyAntennas); - } - - let userList; - - if (ps.src === 'list' && ps.userListId) { - userList = await this.userListsRepository.findOneBy({ - id: ps.userListId, - userId: me.id, - }); - - if (userList == null) { - throw new ApiError(meta.errors.noSuchUserList); - } - } - - const now = new Date(); - - const antenna = await this.antennasRepository.insert({ - id: this.idService.gen(now.getTime()), - lastUsedAt: now, - userId: me.id, - name: ps.name, - src: ps.src, - userListId: userList ? userList.id : null, - keywords: ps.keywords, - excludeKeywords: ps.excludeKeywords, - users: ps.users, - caseSensitive: ps.caseSensitive, - localOnly: ps.localOnly, - withReplies: ps.withReplies, - withFile: ps.withFile, - notify: ps.notify, - }).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0])); - - this.globalEventService.publishInternalEvent('antennaCreated', antenna); - - return await this.antennaEntityService.pack(antenna); + if (ps.src === 'list' && ps.userListId) { + userList = await UserLists.findOneBy({ + id: ps.userListId, + userId: user.id, }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchUserList); + } + } else if (ps.src === 'group' && ps.userGroupId) { + userGroupJoining = await UserGroupJoinings.findOneBy({ + userGroupId: ps.userGroupId, + userId: user.id, + }); + + if (userGroupJoining == null) { + throw new ApiError(meta.errors.noSuchUserGroup); + } } -} + + const antenna = await Antennas.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + src: ps.src, + userListId: userList ? userList.id : null, + userGroupJoiningId: userGroupJoining ? userGroupJoining.id : null, + keywords: ps.keywords, + excludeKeywords: ps.excludeKeywords, + users: ps.users, + caseSensitive: ps.caseSensitive, + withReplies: ps.withReplies, + withFile: ps.withFile, + notify: ps.notify, + }).then(x => Antennas.findOneByOrFail(x.identifiers[0])); + + publishInternalEvent('antennaCreated', antenna); + + return await Antennas.pack(antenna); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/delete.ts b/packages/backend/src/server/api/endpoints/antennas/delete.ts index 908ed3a052..ced34ba313 100644 --- a/packages/backend/src/server/api/endpoints/antennas/delete.ts +++ b/packages/backend/src/server/api/endpoints/antennas/delete.ts @@ -1,19 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AntennasRepository } from '@/models/_.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { Antennas } from '@/models/index.js'; +import { publishInternalEvent } from '@/services/stream.js'; export const meta = { tags: ['antennas'], - requireCredential: 'always', + requireCredential: true, kind: 'write:account', @@ -34,27 +27,18 @@ export const paramDef = { required: ['antennaId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.antennasRepository) - private antennasRepository: AntennasRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const antenna = await Antennas.findOneBy({ + id: ps.antennaId, + userId: user.id, + }); - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - const antenna = await this.antennasRepository.findOneBy({ - id: ps.antennaId, - userId: me.id, - }); - - if (antenna == null) { - throw new ApiError(meta.errors.noSuchAntenna); - } - - await this.antennasRepository.delete(antenna.id); - - this.globalEventService.publishInternalEvent('antennaDeleted', antenna); - }); + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); } -} + + await Antennas.delete(antenna.id); + + publishInternalEvent('antennaDeleted', antenna); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/list.ts b/packages/backend/src/server/api/endpoints/antennas/list.ts index 5ee69824c0..c519b452ef 100644 --- a/packages/backend/src/server/api/endpoints/antennas/list.ts +++ b/packages/backend/src/server/api/endpoints/antennas/list.ts @@ -1,18 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AntennasRepository } from '@/models/_.js'; -import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { Antennas } from '@/models/index.js'; export const meta = { tags: ['antennas', 'account'], - requireCredential: 'always', + requireCredential: true, kind: 'read:account', @@ -33,20 +25,11 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.antennasRepository) - private antennasRepository: AntennasRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const antennas = await Antennas.findBy({ + userId: me.id, + }); - private antennaEntityService: AntennaEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const antennas = await this.antennasRepository.findBy({ - userId: me.id, - }); - - return await Promise.all(antennas.map(x => this.antennaEntityService.pack(x))); - }); - } -} + return await Promise.all(antennas.map(x => Antennas.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index 657bb0f456..8aac55b4a0 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -1,25 +1,16 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { NotesRepository, AntennasRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; -import { DI } from '@/di-symbols.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { IdService } from '@/core/IdService.js'; -import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import define from '../../define.js'; +import readNote from '@/services/note/read.js'; +import { Antennas, Notes, AntennaNotes } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; import { ApiError } from '../../error.js'; +import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; export const meta = { tags: ['antennas', 'account', 'notes'], - requireCredential: 'always', + requireCredential: true, kind: 'read:account', @@ -55,79 +46,44 @@ export const paramDef = { required: ['antennaId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const antenna = await Antennas.findOneBy({ + id: ps.antennaId, + userId: user.id, + }); - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.antennasRepository) - private antennasRepository: AntennasRepository, - - private idService: IdService, - private noteEntityService: NoteEntityService, - private queryService: QueryService, - private noteReadService: NoteReadService, - private fanoutTimelineService: FanoutTimelineService, - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); - const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); - - const antenna = await this.antennasRepository.findOneBy({ - id: ps.antennaId, - userId: me.id, - }); - - if (antenna == null) { - throw new ApiError(meta.errors.noSuchAntenna); - } - - // falseだった場合はアンテナの配信先が増えたことを通知したい - const needPublishEvent = !antenna.isActive; - - antenna.isActive = true; - antenna.lastUsedAt = new Date(); - this.antennasRepository.update(antenna.id, antenna); - - if (needPublishEvent) { - this.globalEventService.publishInternalEvent('antennaUpdated', antenna); - } - - let noteIds = await this.fanoutTimelineService.get(`antennaTimeline:${antenna.id}`, untilId, sinceId); - noteIds = noteIds.slice(0, ps.limit); - if (noteIds.length === 0) { - return []; - } - - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - - const notes = await query.getMany(); - if (sinceId != null && untilId == null) { - notes.sort((a, b) => a.id < b.id ? -1 : 1); - } else { - notes.sort((a, b) => a.id > b.id ? -1 : 1); - } - - if (notes.length > 0) { - this.noteReadService.read(me.id, notes); - } - - return await this.noteEntityService.packMany(notes, me); - }); + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); } -} + + const query = makePaginationQuery(Notes.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .innerJoin(AntennaNotes.metadata.targetName, 'antennaNote', 'antennaNote.noteId = note.id') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') + .andWhere('antennaNote.antennaId = :antennaId', { antennaId: antenna.id }); + + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + + const notes = await query + .take(ps.limit) + .getMany(); + + if (notes.length > 0) { + readNote(user.id, notes); + } + + return await Notes.packMany(notes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/show.ts b/packages/backend/src/server/api/endpoints/antennas/show.ts index 0f0d8f1a29..dd693789cb 100644 --- a/packages/backend/src/server/api/endpoints/antennas/show.ts +++ b/packages/backend/src/server/api/endpoints/antennas/show.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AntennasRepository } from '@/models/_.js'; -import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { Antennas } from '@/models/index.js'; export const meta = { tags: ['antennas', 'account'], - requireCredential: 'always', + requireCredential: true, kind: 'read:account', @@ -40,26 +32,17 @@ export const paramDef = { required: ['antennaId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.antennasRepository) - private antennasRepository: AntennasRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + // Fetch the antenna + const antenna = await Antennas.findOneBy({ + id: ps.antennaId, + userId: me.id, + }); - private antennaEntityService: AntennaEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - // Fetch the antenna - const antenna = await this.antennasRepository.findOneBy({ - id: ps.antennaId, - userId: me.id, - }); - - if (antenna == null) { - throw new ApiError(meta.errors.noSuchAntenna); - } - - return await this.antennaEntityService.pack(antenna); - }); + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); } -} + + return await Antennas.pack(antenna); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index d91f3fb0fa..edfedc1752 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -1,22 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AntennasRepository, UserListsRepository } from '@/models/_.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { Antennas, UserLists, UserGroupJoinings } from '@/models/index.js'; +import { publishInternalEvent } from '@/services/stream.js'; export const meta = { tags: ['antennas'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:account', @@ -32,6 +22,12 @@ export const meta = { code: 'NO_SUCH_USER_LIST', id: '1c6b35c9-943e-48c2-81e4-2844989407f7', }, + + noSuchUserGroup: { + message: 'No such user group.', + code: 'NO_SUCH_USER_GROUP', + id: '109ed789-b6eb-456e-b8a9-6059d567d385', + }, }, res: { @@ -46,8 +42,9 @@ export const paramDef = { properties: { antennaId: { type: 'string', format: 'misskey:id' }, name: { type: 'string', minLength: 1, maxLength: 100 }, - src: { type: 'string', enum: ['home', 'all', 'users', 'list', 'users_blacklist'] }, + src: { type: 'string', enum: ['home', 'all', 'users', 'list', 'group'] }, userListId: { type: 'string', format: 'misskey:id', nullable: true }, + userGroupId: { type: 'string', format: 'misskey:id', nullable: true }, keywords: { type: 'array', items: { type: 'array', items: { type: 'string', @@ -62,7 +59,6 @@ export const paramDef = { type: 'string', } }, caseSensitive: { type: 'boolean' }, - localOnly: { type: 'boolean' }, withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, notify: { type: 'boolean' }, @@ -70,64 +66,56 @@ export const paramDef = { required: ['antennaId', 'name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile', 'notify'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.antennasRepository) - private antennasRepository: AntennasRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Fetch the antenna + const antenna = await Antennas.findOneBy({ + id: ps.antennaId, + userId: user.id, + }); - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - private antennaEntityService: AntennaEntityService, - private globalEventService: GlobalEventService, - ) { - 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.'); - } - // Fetch the antenna - const antenna = await this.antennasRepository.findOneBy({ - id: ps.antennaId, - userId: me.id, - }); - - if (antenna == null) { - throw new ApiError(meta.errors.noSuchAntenna); - } - - let userList; - - if (ps.src === 'list' && ps.userListId) { - userList = await this.userListsRepository.findOneBy({ - id: ps.userListId, - userId: me.id, - }); - - if (userList == null) { - throw new ApiError(meta.errors.noSuchUserList); - } - } - - await this.antennasRepository.update(antenna.id, { - name: ps.name, - src: ps.src, - userListId: userList ? userList.id : null, - keywords: ps.keywords, - excludeKeywords: ps.excludeKeywords, - users: ps.users, - caseSensitive: ps.caseSensitive, - localOnly: ps.localOnly, - withReplies: ps.withReplies, - withFile: ps.withFile, - notify: ps.notify, - isActive: true, - lastUsedAt: new Date(), - }); - - this.globalEventService.publishInternalEvent('antennaUpdated', await this.antennasRepository.findOneByOrFail({ id: antenna.id })); - - return await this.antennaEntityService.pack(antenna.id); - }); + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); } -} + + let userList; + let userGroupJoining; + + if (ps.src === 'list' && ps.userListId) { + userList = await UserLists.findOneBy({ + id: ps.userListId, + userId: user.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchUserList); + } + } else if (ps.src === 'group' && ps.userGroupId) { + userGroupJoining = await UserGroupJoinings.findOneBy({ + userGroupId: ps.userGroupId, + userId: user.id, + }); + + if (userGroupJoining == null) { + throw new ApiError(meta.errors.noSuchUserGroup); + } + } + + await Antennas.update(antenna.id, { + name: ps.name, + src: ps.src, + userListId: userList ? userList.id : null, + userGroupJoiningId: userGroupJoining ? userGroupJoining.id : null, + keywords: ps.keywords, + excludeKeywords: ps.excludeKeywords, + users: ps.users, + caseSensitive: ps.caseSensitive, + withReplies: ps.withReplies, + withFile: ps.withFile, + notify: ps.notify, + }); + + publishInternalEvent('antennaUpdated', await Antennas.findOneByOrFail({ id: antenna.id })); + + return await Antennas.pack(antenna.id); +}); diff --git a/packages/backend/src/server/api/endpoints/ap/get.ts b/packages/backend/src/server/api/endpoints/ap/get.ts index 72c9784d49..0cbe7ebc67 100644 --- a/packages/backend/src/server/api/endpoints/ap/get.ts +++ b/packages/backend/src/server/api/endpoints/ap/get.ts @@ -1,18 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; +import define from '../../define.js'; +import Resolver from '@/remote/activitypub/resolver.js'; +import { ApiError } from '../../error.js'; import ms from 'ms'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; export const meta = { tags: ['federation'], - requireCredential: 'always', - kind: 'read:federation', + requireCredential: true, limit: { duration: ms('1hour'), @@ -36,15 +30,9 @@ export const paramDef = { required: ['uri'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private apResolverService: ApResolverService, - ) { - super(meta, paramDef, async (ps, me) => { - const resolver = this.apResolverService.createResolver(); - const object = await resolver.resolve(ps.uri); - return object; - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const resolver = new Resolver(); + const object = await resolver.resolve(ps.uri); + return object; +}); diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 7bfb27eb6f..6442a1412c 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -1,34 +1,26 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * 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 type { MiNote } from '@/models/Note.js'; -import type { MiLocalUser, MiUser } from '@/models/User.js'; -import { isActor, isPost, getApId } from '@/core/activitypub/type.js'; -import type { SchemaType } from '@/misc/json-schema.js'; -import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; -import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; -import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; +import define from '../../define.js'; +import config from '@/config/index.js'; +import { createPerson } from '@/remote/activitypub/models/person.js'; +import { createNote } from '@/remote/activitypub/models/note.js'; +import DbResolver from '@/remote/activitypub/db-resolver.js'; +import Resolver from '@/remote/activitypub/resolver.js'; import { ApiError } from '../../error.js'; +import { extractDbHost } from '@/misc/convert-host.js'; +import { Users, Notes } from '@/models/index.js'; +import { Note } from '@/models/entities/note.js'; +import { CacheableLocalUser, User } from '@/models/entities/user.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { isActor, isPost, getApId } from '@/remote/activitypub/type.js'; +import ms from 'ms'; +import { SchemaType } from '@/misc/schema.js'; export const meta = { tags: ['federation'], - requireCredential: 'always', - kind: 'read:account', + requireCredential: true, limit: { - duration: ms('1minute'), + duration: ms('1hour'), max: 30, }, @@ -55,8 +47,8 @@ export const meta = { type: 'object', optional: false, nullable: false, ref: 'UserDetailedNotMe', - }, - }, + } + } }, { type: 'object', @@ -70,9 +62,9 @@ export const meta = { type: 'object', optional: false, nullable: false, ref: 'Note', - }, - }, - }, + } + } + } ], }, } as const; @@ -85,86 +77,71 @@ export const paramDef = { required: ['uri'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private utilityService: UtilityService, - private userEntityService: UserEntityService, - private noteEntityService: NoteEntityService, - private metaService: MetaService, - private apResolverService: ApResolverService, - private apDbResolverService: ApDbResolverService, - private apPersonService: ApPersonService, - private apNoteService: ApNoteService, - ) { - super(meta, paramDef, async (ps, me) => { - const object = await this.fetchAny(ps.uri, me); - if (object) { - return object; - } else { - throw new ApiError(meta.errors.noSuchObject); - } - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const object = await fetchAny(ps.uri, me); + if (object) { + return object; + } else { + throw new ApiError(meta.errors.noSuchObject); } +}); - /*** - * URIからUserかNoteを解決する - */ - @bindThis - private async fetchAny(uri: string, me: MiLocalUser | null | undefined): Promise | null> { +/*** + * URIからUserかNoteを解決する + */ +async function fetchAny(uri: string, me: CacheableLocalUser | null | undefined): Promise | null> { // ブロックしてたら中断 - const fetchedMeta = await this.metaService.fetch(); - const dbHost = this.utilityService.extractDbHost(uri); - if (this.utilityService.isBlockedHost(fetchedMeta.blockedHosts, dbHost)) return null; - if (fetchedMeta.allowlistMode && !this.utilityService.isAllowedHost(fetchedMeta.allowedHosts, dbHost)) return null; + const fetchedMeta = await fetchMeta(); + if (fetchedMeta.blockedHosts.includes(extractDbHost(uri))) return null; - let local = await this.mergePack(me, ...await Promise.all([ - this.apDbResolverService.getUserFromApId(uri), - this.apDbResolverService.getNoteFromApId(uri), + const dbResolver = new DbResolver(); + + let local = await mergePack(me, ...await Promise.all([ + dbResolver.getUserFromApId(uri), + dbResolver.getNoteFromApId(uri), + ])); + if (local != null) return local; + + // リモートから一旦オブジェクトフェッチ + const resolver = new Resolver(); + const object = await resolver.resolve(uri) as any; + + // /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する + // これはDBに存在する可能性があるため再度DB検索 + if (uri !== object.id) { + local = await mergePack(me, ...await Promise.all([ + dbResolver.getUserFromApId(object.id), + dbResolver.getNoteFromApId(object.id), ])); if (local != null) return local; - - // リモートから一旦オブジェクトフェッチ - const resolver = this.apResolverService.createResolver(); - const object = await resolver.resolve(uri) as any; - - // /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する - // これはDBに存在する可能性があるため再度DB検索 - if (uri !== object.id) { - local = await this.mergePack(me, ...await Promise.all([ - this.apDbResolverService.getUserFromApId(object.id), - this.apDbResolverService.getNoteFromApId(object.id), - ])); - if (local != null) return local; - } - - return await this.mergePack( - me, - isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null, - isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, true) : null, - ); } - @bindThis - private async mergePack(me: MiLocalUser | null | undefined, user: MiUser | null | undefined, note: MiNote | null | undefined): Promise | null> { - if (user != null) { - return { - type: 'User', - object: await this.userEntityService.pack(user, me, { detail: true }), - }; - } else if (note != null) { - try { - const object = await this.noteEntityService.pack(note, me, { detail: true }); - - return { - type: 'Note', - object, - }; - } catch (e) { - return null; - } - } - - return null; - } + return await mergePack( + me, + isActor(object) ? await createPerson(getApId(object)) : null, + isPost(object) ? await createNote(getApId(object), undefined, true) : null, + ); +} + +async function mergePack(me: CacheableLocalUser | null | undefined, user: User | null | undefined, note: Note | null | undefined): Promise | null> { + if (user != null) { + return { + type: 'User', + object: await Users.pack(user, me, { detail: true }), + }; + } else if (note != null) { + try { + const object = await Notes.pack(note, me, { detail: true }); + + return { + type: 'Note', + object, + }; + } catch (e) { + return null; + } + } + + return null; } diff --git a/packages/backend/src/server/api/endpoints/app/create.ts b/packages/backend/src/server/api/endpoints/app/create.ts index e16398fb99..a0a7350822 100644 --- a/packages/backend/src/server/api/endpoints/app/create.ts +++ b/packages/backend/src/server/api/endpoints/app/create.ts @@ -1,21 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AppsRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { unique } from '@/misc/prelude/array.js'; +import define from '../../define.js'; +import { Apps } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { unique } from '@/prelude/array.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; -import { AppEntityService } from '@/core/entities/AppEntityService.js'; -import { DI } from '@/di-symbols.js'; export const meta = { tags: ['app'], - requireCredential: 'never', + requireCredential: false, res: { type: 'object', @@ -37,37 +29,28 @@ export const paramDef = { required: ['name', 'description', 'permission'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.appsRepository) - private appsRepository: AppsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Generate secret + const secret = secureRndstr(32, true); - private appEntityService: AppEntityService, - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - // Generate secret - const secret = secureRndstr(32); + // for backward compatibility + const permission = unique(ps.permission.map(v => v.replace(/^(.+)(\/|-)(read|write)$/, '$3:$1'))); - // for backward compatibility - const permission = unique(ps.permission.map(v => v.replace(/^(.+)(\/|-)(read|write)$/, '$3:$1'))); + // Create account + const app = await Apps.insert({ + id: genId(), + createdAt: new Date(), + userId: user ? user.id : null, + name: ps.name, + description: ps.description, + permission, + callbackUrl: ps.callbackUrl, + secret: secret, + }).then(x => Apps.findOneByOrFail(x.identifiers[0])); - // Create account - const app = await this.appsRepository.insert({ - id: this.idService.gen(), - userId: me ? me.id : null, - name: ps.name, - description: ps.description, - permission, - callbackUrl: ps.callbackUrl, - secret: secret, - }).then(x => this.appsRepository.findOneByOrFail(x.identifiers[0])); - - return await this.appEntityService.pack(app, null, { - detail: true, - includeSecret: true, - }); - }); - } -} + return await Apps.pack(app, null, { + detail: true, + includeSecret: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/app/show.ts b/packages/backend/src/server/api/endpoints/app/show.ts index 6d5ed9d91a..451969d971 100644 --- a/packages/backend/src/server/api/endpoints/app/show.ts +++ b/packages/backend/src/server/api/endpoints/app/show.ts @@ -1,20 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AppsRepository } from '@/models/_.js'; -import { AppEntityService } from '@/core/entities/AppEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { Apps } from '@/models/index.js'; export const meta = { tags: ['app'], - requireCredential: 'never', - errors: { noSuchApp: { message: 'No such app.', @@ -38,28 +28,19 @@ export const paramDef = { required: ['appId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.appsRepository) - private appsRepository: AppsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user, token) => { + const isSecure = user != null && token == null; - private appEntityService: AppEntityService, - ) { - super(meta, paramDef, async (ps, user, token) => { - const isSecure = user != null && token == null; + // Lookup app + const ap = await Apps.findOneBy({ id: ps.appId }); - // Lookup app - const ap = await this.appsRepository.findOneBy({ id: ps.appId }); - - if (ap == null) { - throw new ApiError(meta.errors.noSuchApp); - } - - return await this.appEntityService.pack(ap, user, { - detail: true, - includeSecret: isSecure && (ap.userId === user!.id), - }); - }); + if (ap == null) { + throw new ApiError(meta.errors.noSuchApp); } -} + + return await Apps.pack(ap, user, { + detail: true, + includeSecret: isSecure && (ap.userId === user!.id), + }); +}); diff --git a/packages/backend/src/server/api/endpoints/auth/accept.ts b/packages/backend/src/server/api/endpoints/auth/accept.ts index 0fe0f4dd8b..b5c06792bb 100644 --- a/packages/backend/src/server/api/endpoints/auth/accept.ts +++ b/packages/backend/src/server/api/endpoints/auth/accept.ts @@ -1,21 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import * as crypto from 'node:crypto'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AuthSessionsRepository, AppsRepository, AccessTokensRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { secureRndstr } from '@/misc/secure-rndstr.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { AuthSessions, AccessTokens, Apps } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; export const meta = { tags: ['auth'], - requireCredential: 'always', + requireCredential: true, secure: true, @@ -36,63 +29,50 @@ export const paramDef = { required: ['token'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.appsRepository) - private appsRepository: AppsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Fetch token + const session = await AuthSessions + .findOneBy({ token: ps.token }); - @Inject(DI.authSessionsRepository) - private authSessionsRepository: AuthSessionsRepository, + if (session == null) { + throw new ApiError(meta.errors.noSuchSession); + } - @Inject(DI.accessTokensRepository) - private accessTokensRepository: AccessTokensRepository, + // Generate access token + const accessToken = secureRndstr(32, true); - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - // Fetch token - const session = await this.authSessionsRepository - .findOneBy({ token: ps.token }); + // Fetch exist access token + const exist = await AccessTokens.findOneBy({ + appId: session.appId, + userId: user.id, + }); - if (session == null) { - throw new ApiError(meta.errors.noSuchSession); - } + if (exist == null) { + // Lookup app + const app = await Apps.findOneByOrFail({ id: session.appId }); - const accessToken = secureRndstr(32); + // Generate Hash + const sha256 = crypto.createHash('sha256'); + sha256.update(accessToken + app.secret); + const hash = sha256.digest('hex'); - // Fetch exist access token - const exist = await this.accessTokensRepository.exist({ - where: { - appId: session.appId, - userId: me.id, - }, - }); + const now = new Date(); - if (!exist) { - const app = await this.appsRepository.findOneByOrFail({ id: session.appId }); - - // Generate Hash - const sha256 = crypto.createHash('sha256'); - sha256.update(accessToken + app.secret); - const hash = sha256.digest('hex'); - - const now = new Date(); - - await this.accessTokensRepository.insert({ - id: this.idService.gen(now.getTime()), - lastUsedAt: now, - appId: session.appId, - userId: me.id, - token: accessToken, - hash: hash, - }); - } - - // Update session - await this.authSessionsRepository.update(session.id, { - userId: me.id, - }); + // Insert access token doc + await AccessTokens.insert({ + id: genId(), + createdAt: now, + lastUsedAt: now, + appId: session.appId, + userId: user.id, + token: accessToken, + hash: hash, }); } -} + + // Update session + await AuthSessions.update(session.id, { + userId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/auth/session/generate.ts b/packages/backend/src/server/api/endpoints/auth/session/generate.ts index b2990f867a..717c3e5086 100644 --- a/packages/backend/src/server/api/endpoints/auth/session/generate.ts +++ b/packages/backend/src/server/api/endpoints/auth/session/generate.ts @@ -1,21 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { randomUUID } from 'node:crypto'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AppsRepository, AuthSessionsRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import type { Config } from '@/config.js'; -import { DI } from '@/di-symbols.js'; +import { v4 as uuid } from 'uuid'; +import config from '@/config/index.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { Apps, AuthSessions } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; export const meta = { tags: ['auth'], - requireCredential: 'never', + requireCredential: false, res: { type: 'object', @@ -50,44 +43,30 @@ export const paramDef = { required: ['appSecret'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.config) - private config: Config, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + // Lookup app + const app = await Apps.findOneBy({ + secret: ps.appSecret, + }); - @Inject(DI.appsRepository) - private appsRepository: AppsRepository, - - @Inject(DI.authSessionsRepository) - private authSessionsRepository: AuthSessionsRepository, - - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - // Lookup app - const app = await this.appsRepository.findOneBy({ - secret: ps.appSecret, - }); - - if (app == null) { - throw new ApiError(meta.errors.noSuchApp); - } - - // Generate token - const token = randomUUID(); - - // Create session token document - const doc = await this.authSessionsRepository.insert({ - id: this.idService.gen(), - appId: app.id, - token: token, - }).then(x => this.authSessionsRepository.findOneByOrFail(x.identifiers[0])); - - return { - token: doc.token, - url: `${this.config.authUrl}/${doc.token}`, - }; - }); + if (app == null) { + throw new ApiError(meta.errors.noSuchApp); } -} + + // Generate token + const token = uuid(); + + // Create session token document + const doc = await AuthSessions.insert({ + id: genId(), + createdAt: new Date(), + appId: app.id, + token: token, + }).then(x => AuthSessions.findOneByOrFail(x.identifiers[0])); + + return { + token: doc.token, + url: `${config.authUrl}/${doc.token}`, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/auth/session/show.ts b/packages/backend/src/server/api/endpoints/auth/session/show.ts index 569cf45e7c..3f3a4d1427 100644 --- a/packages/backend/src/server/api/endpoints/auth/session/show.ts +++ b/packages/backend/src/server/api/endpoints/auth/session/show.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AuthSessionsRepository } from '@/models/_.js'; -import { AuthSessionEntityService } from '@/core/entities/AuthSessionEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { AuthSessions } from '@/models/index.js'; export const meta = { tags: ['auth'], - requireCredential: 'never', + requireCredential: false, errors: { noSuchSession: { @@ -53,25 +45,16 @@ export const paramDef = { required: ['token'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.authSessionsRepository) - private authSessionsRepository: AuthSessionsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Lookup session + const session = await AuthSessions.findOneBy({ + token: ps.token, + }); - private authSessionEntityService: AuthSessionEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - // Lookup session - const session = await this.authSessionsRepository.findOneBy({ - token: ps.token, - }); - - if (session == null) { - throw new ApiError(meta.errors.noSuchSession); - } - - return await this.authSessionEntityService.pack(session, me); - }); + if (session == null) { + throw new ApiError(meta.errors.noSuchSession); } -} + + return await AuthSessions.pack(session, user); +}); diff --git a/packages/backend/src/server/api/endpoints/auth/session/userkey.ts b/packages/backend/src/server/api/endpoints/auth/session/userkey.ts index 1a1353c0a1..89884ed38a 100644 --- a/packages/backend/src/server/api/endpoints/auth/session/userkey.ts +++ b/packages/backend/src/server/api/endpoints/auth/session/userkey.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AppsRepository, AccessTokensRepository, AuthSessionsRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { Apps, AuthSessions, AccessTokens, Users } from '@/models/index.js'; export const meta = { tags: ['auth'], - requireCredential: 'never', + requireCredential: false, res: { type: 'object', @@ -62,59 +54,44 @@ export const paramDef = { required: ['appSecret', 'token'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.appsRepository) - private appsRepository: AppsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + // Lookup app + const app = await Apps.findOneBy({ + secret: ps.appSecret, + }); - @Inject(DI.authSessionsRepository) - private authSessionsRepository: AuthSessionsRepository, - - @Inject(DI.accessTokensRepository) - private accessTokensRepository: AccessTokensRepository, - - private userEntityService: UserEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - // Lookup app - const app = await this.appsRepository.findOneBy({ - secret: ps.appSecret, - }); - - if (app == null) { - throw new ApiError(meta.errors.noSuchApp); - } - - // Fetch token - const session = await this.authSessionsRepository.findOneBy({ - token: ps.token, - appId: app.id, - }); - - if (session == null) { - throw new ApiError(meta.errors.noSuchSession); - } - - if (session.userId == null) { - throw new ApiError(meta.errors.pendingSession); - } - - // Lookup access token - const accessToken = await this.accessTokensRepository.findOneByOrFail({ - appId: app.id, - userId: session.userId, - }); - - // Delete session - this.authSessionsRepository.delete(session.id); - - return { - accessToken: accessToken.token, - user: await this.userEntityService.pack(session.userId, null, { - detail: true, - }), - }; - }); + if (app == null) { + throw new ApiError(meta.errors.noSuchApp); } -} + + // Fetch token + const session = await AuthSessions.findOneBy({ + token: ps.token, + appId: app.id, + }); + + if (session == null) { + throw new ApiError(meta.errors.noSuchSession); + } + + if (session.userId == null) { + throw new ApiError(meta.errors.pendingSession); + } + + // Lookup access token + const accessToken = await AccessTokens.findOneByOrFail({ + appId: app.id, + userId: session.userId, + }); + + // Delete session + AuthSessions.delete(session.id); + + return { + accessToken: accessToken.token, + user: await Users.pack(session.userId, null, { + detail: true, + }), + }; +}); diff --git a/packages/backend/src/server/api/endpoints/blocking/create.ts b/packages/backend/src/server/api/endpoints/blocking/create.ts index ee877f1791..0540e6ab0f 100644 --- a/packages/backend/src/server/api/endpoints/blocking/create.ts +++ b/packages/backend/src/server/api/endpoints/blocking/create.ts @@ -1,27 +1,19 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import ms from 'ms'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, BlockingsRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { UserBlockingService } from '@/core/UserBlockingService.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; +import create from '@/services/blocking/create.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { getUser } from '../../common/getters.js'; +import { Blockings, NoteWatchings, Users } from '@/models/index.js'; export const meta = { tags: ['account'], limit: { duration: ms('1hour'), - max: 20, + max: 100, }, - requireCredential: 'always', + requireCredential: true, kind: 'write:blocks', @@ -60,50 +52,39 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const blocker = await Users.findOneByOrFail({ id: user.id }); - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - - private userEntityService: UserEntityService, - private getterService: GetterService, - private userBlockingService: UserBlockingService, - ) { - super(meta, paramDef, async (ps, me) => { - const blocker = await this.usersRepository.findOneByOrFail({ id: me.id }); - - // 自分自身 - if (me.id === ps.userId) { - throw new ApiError(meta.errors.blockeeIsYourself); - } - - // Get blockee - const blockee = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); - - // Check if already blocking - const exist = await this.blockingsRepository.exist({ - where: { - blockerId: blocker.id, - blockeeId: blockee.id, - }, - }); - - if (exist) { - throw new ApiError(meta.errors.alreadyBlocking); - } - - await this.userBlockingService.block(blocker, blockee); - - return await this.userEntityService.pack(blockee.id, blocker, { - detail: true, - }); - }); + // 自分自身 + if (user.id === ps.userId) { + throw new ApiError(meta.errors.blockeeIsYourself); } -} + + // Get blockee + const blockee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check if already blocking + const exist = await Blockings.findOneBy({ + blockerId: blocker.id, + blockeeId: blockee.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyBlocking); + } + + await create(blocker, blockee); + + NoteWatchings.delete({ + userId: blocker.id, + noteUserId: blockee.id, + }); + + return await Users.pack(blockee.id, blocker, { + detail: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/blocking/delete.ts b/packages/backend/src/server/api/endpoints/blocking/delete.ts index e1600ba223..77e17b3ba9 100644 --- a/packages/backend/src/server/api/endpoints/blocking/delete.ts +++ b/packages/backend/src/server/api/endpoints/blocking/delete.ts @@ -1,17 +1,9 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import ms from 'ms'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, BlockingsRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { UserBlockingService } from '@/core/UserBlockingService.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; +import deleteBlocking from '@/services/blocking/delete.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { getUser } from '../../common/getters.js'; +import { Blockings, Users } from '@/models/index.js'; export const meta = { tags: ['account'], @@ -21,7 +13,7 @@ export const meta = { max: 100, }, - requireCredential: 'always', + requireCredential: true, kind: 'write:blocks', @@ -60,51 +52,35 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const blocker = await Users.findOneByOrFail({ id: user.id }); - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - - private userEntityService: UserEntityService, - private getterService: GetterService, - private userBlockingService: UserBlockingService, - ) { - super(meta, paramDef, async (ps, me) => { - const blocker = await this.usersRepository.findOneByOrFail({ id: me.id }); - - // Check if the blockee is yourself - if (me.id === ps.userId) { - throw new ApiError(meta.errors.blockeeIsYourself); - } - - // Get blockee - const blockee = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); - - // Check not blocking - const exist = await this.blockingsRepository.exist({ - where: { - blockerId: blocker.id, - blockeeId: blockee.id, - }, - }); - - if (!exist) { - throw new ApiError(meta.errors.notBlocking); - } - - // Delete blocking - await this.userBlockingService.unblock(blocker, blockee); - - return await this.userEntityService.pack(blockee.id, blocker, { - detail: true, - }); - }); + // Check if the blockee is yourself + if (user.id === ps.userId) { + throw new ApiError(meta.errors.blockeeIsYourself); } -} + + // Get blockee + const blockee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check not blocking + const exist = await Blockings.findOneBy({ + blockerId: blocker.id, + blockeeId: blockee.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notBlocking); + } + + // Delete blocking + await deleteBlocking(blocker, blockee); + + return await Users.pack(blockee.id, blocker, { + detail: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/blocking/list.ts b/packages/backend/src/server/api/endpoints/blocking/list.ts index 4336b4e77f..29095ebe21 100644 --- a/packages/backend/src/server/api/endpoints/blocking/list.ts +++ b/packages/backend/src/server/api/endpoints/blocking/list.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { BlockingsRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { BlockingEntityService } from '@/core/entities/BlockingEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { Blockings } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['account'], - requireCredential: 'always', + requireCredential: true, kind: 'read:blocks', @@ -38,24 +30,14 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery(Blockings.createQueryBuilder('blocking'), ps.sinceId, ps.untilId) + .andWhere(`blocking.blockerId = :meId`, { meId: me.id }); - private blockingEntityService: BlockingEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.blockingsRepository.createQueryBuilder('blocking'), ps.sinceId, ps.untilId) - .andWhere('blocking.blockerId = :meId', { meId: me.id }); + const blockings = await query + .take(ps.limit) + .getMany(); - const blockings = await query - .limit(ps.limit) - .getMany(); - - return await this.blockingEntityService.packMany(blockings, me); - }); - } -} + return await Blockings.packMany(blockings, me); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/create.ts b/packages/backend/src/server/api/endpoints/channels/create.ts index 56aaf3a811..94dcfe5023 100644 --- a/packages/backend/src/server/api/endpoints/channels/create.ts +++ b/packages/backend/src/server/api/endpoints/channels/create.ts @@ -1,32 +1,16 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import ms from 'ms'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelsRepository, DriveFilesRepository } from '@/models/_.js'; -import type { MiChannel } from '@/models/Channel.js'; -import { IdService } from '@/core/IdService.js'; -import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { Channels, DriveFiles } from '@/models/index.js'; +import { Channel } from '@/models/entities/channel.js'; +import { genId } from '@/misc/gen-id.js'; export const meta = { tags: ['channels'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:channels', - limit: { - duration: ms('1hour'), - max: 10, - }, - res: { type: 'object', optional: false, nullable: false, @@ -48,50 +32,32 @@ export const paramDef = { name: { type: 'string', minLength: 1, maxLength: 128 }, description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, - color: { type: 'string', minLength: 1, maxLength: 16 }, - isSensitive: { type: 'boolean', nullable: true }, - allowRenoteToExternal: { type: 'boolean', nullable: true }, }, required: ['name'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - - private idService: IdService, - private channelEntityService: ChannelEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - let banner = null; - if (ps.bannerId != null) { - banner = await this.driveFilesRepository.findOneBy({ - id: ps.bannerId, - userId: me.id, - }); - - if (banner == null) { - throw new ApiError(meta.errors.noSuchFile); - } - } - - const channel = await this.channelsRepository.insert({ - id: this.idService.gen(), - userId: me.id, - name: ps.name, - description: ps.description ?? null, - bannerId: banner ? banner.id : null, - isSensitive: ps.isSensitive ?? false, - ...(ps.color !== undefined ? { color: ps.color } : {}), - allowRenoteToExternal: ps.allowRenoteToExternal ?? true, - } as MiChannel).then(x => this.channelsRepository.findOneByOrFail(x.identifiers[0])); - - return await this.channelEntityService.pack(channel, me); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + let banner = null; + if (ps.bannerId != null) { + banner = await DriveFiles.findOneBy({ + id: ps.bannerId, + userId: user.id, }); + + if (banner == null) { + throw new ApiError(meta.errors.noSuchFile); + } } -} + + const channel = await Channels.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + description: ps.description || null, + bannerId: banner ? banner.id : null, + } as Channel).then(x => Channels.findOneByOrFail(x.identifiers[0])); + + return await Channels.pack(channel, user); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/favorite.ts b/packages/backend/src/server/api/endpoints/channels/favorite.ts deleted file mode 100644 index 8bb1b65f13..0000000000 --- a/packages/backend/src/server/api/endpoints/channels/favorite.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelFavoritesRepository, ChannelsRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['channels'], - - requireCredential: 'always', - - prohibitMoved: true, - - kind: 'write:channels', - - errors: { - noSuchChannel: { - message: 'No such channel.', - code: 'NO_SUCH_CHANNEL', - id: '4938f5f3-6167-4c04-9149-6607b7542861', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - channelId: { type: 'string', format: 'misskey:id' }, - }, - required: ['channelId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - - @Inject(DI.channelFavoritesRepository) - private channelFavoritesRepository: ChannelFavoritesRepository, - - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const channel = await this.channelsRepository.findOneBy({ - id: ps.channelId, - }); - - if (channel == null) { - throw new ApiError(meta.errors.noSuchChannel); - } - - await this.channelFavoritesRepository.insert({ - id: this.idService.gen(), - userId: me.id, - channelId: channel.id, - }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/channels/featured.ts b/packages/backend/src/server/api/endpoints/channels/featured.ts index 63021f7b6a..73980c0fad 100644 --- a/packages/backend/src/server/api/endpoints/channels/featured.ts +++ b/packages/backend/src/server/api/endpoints/channels/featured.ts @@ -1,18 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelsRepository } from '@/models/_.js'; -import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { Channels } from '@/models/index.js'; export const meta = { tags: ['channels'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'array', @@ -31,23 +23,13 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = Channels.createQueryBuilder('channel') + .where('channel.lastNotedAt IS NOT NULL') + .orderBy('channel.lastNotedAt', 'DESC'); - private channelEntityService: ChannelEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.channelsRepository.createQueryBuilder('channel') - .where('channel.lastNotedAt IS NOT NULL') - .andWhere('channel.isArchived = FALSE') - .orderBy('channel.lastNotedAt', 'DESC'); + const channels = await query.take(10).getMany(); - const channels = await query.limit(10).getMany(); - - return await Promise.all(channels.map(x => this.channelEntityService.pack(x, me))); - }); - } -} + return await Promise.all(channels.map(x => Channels.pack(x, me))); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/follow.ts b/packages/backend/src/server/api/endpoints/channels/follow.ts index 45b3d06076..895ffed0bd 100644 --- a/packages/backend/src/server/api/endpoints/channels/follow.ts +++ b/packages/backend/src/server/api/endpoints/channels/follow.ts @@ -1,21 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { Channels, ChannelFollowings } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { publishUserEvent } from '@/services/stream.js'; export const meta = { tags: ['channels'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:channels', @@ -36,23 +28,22 @@ export const paramDef = { required: ['channelId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - private channelFollowingService: ChannelFollowingService, - ) { - super(meta, paramDef, async (ps, me) => { - const channel = await this.channelsRepository.findOneBy({ - id: ps.channelId, - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const channel = await Channels.findOneBy({ + id: ps.channelId, + }); - if (channel == null) { - throw new ApiError(meta.errors.noSuchChannel); - } - - await this.channelFollowingService.follow(me, channel); - }); + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); } -} + + await ChannelFollowings.insert({ + id: genId(), + createdAt: new Date(), + followerId: user.id, + followeeId: channel.id, + }); + + publishUserEvent(user.id, 'followChannel', channel); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/followed.ts b/packages/backend/src/server/api/endpoints/channels/followed.ts index 034f4e3d11..e4aa4d1614 100644 --- a/packages/backend/src/server/api/endpoints/channels/followed.ts +++ b/packages/backend/src/server/api/endpoints/channels/followed.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelFollowingsRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { Channels, ChannelFollowings } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['channels', 'account'], - requireCredential: 'always', + requireCredential: true, kind: 'read:channels', @@ -38,24 +30,14 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery(ChannelFollowings.createQueryBuilder(), ps.sinceId, ps.untilId) + .andWhere({ followerId: me.id }); - private channelEntityService: ChannelEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.channelFollowingsRepository.createQueryBuilder(), ps.sinceId, ps.untilId) - .andWhere({ followerId: me.id }); + const followings = await query + .take(ps.limit) + .getMany(); - const followings = await query - .limit(ps.limit) - .getMany(); - - return await Promise.all(followings.map(x => this.channelEntityService.pack(x.followeeId, me))); - }); - } -} + return await Promise.all(followings.map(x => Channels.pack(x.followeeId, me))); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/my-favorites.ts b/packages/backend/src/server/api/endpoints/channels/my-favorites.ts deleted file mode 100644 index 21882d961f..0000000000 --- a/packages/backend/src/server/api/endpoints/channels/my-favorites.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelFavoritesRepository } from '@/models/_.js'; -import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['channels', 'account'], - - requireCredential: 'always', - - kind: 'read:channels', - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'Channel', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - }, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.channelFavoritesRepository) - private channelFavoritesRepository: ChannelFavoritesRepository, - - private channelEntityService: ChannelEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.channelFavoritesRepository.createQueryBuilder('favorite') - .andWhere('favorite.userId = :meId', { meId: me.id }) - .leftJoinAndSelect('favorite.channel', 'channel'); - - const favorites = await query - .getMany(); - - return await Promise.all(favorites.map(x => this.channelEntityService.pack(x.channel!, me))); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/channels/owned.ts b/packages/backend/src/server/api/endpoints/channels/owned.ts index 14470a9f31..ed7e41cac2 100644 --- a/packages/backend/src/server/api/endpoints/channels/owned.ts +++ b/packages/backend/src/server/api/endpoints/channels/owned.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelsRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { Channels } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['channels', 'account'], - requireCredential: 'always', + requireCredential: true, kind: 'read:channels', @@ -38,25 +30,14 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery(Channels.createQueryBuilder(), ps.sinceId, ps.untilId) + .andWhere({ userId: me.id }); - private channelEntityService: ChannelEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId) - .andWhere('channel.isArchived = FALSE') - .andWhere({ userId: me.id }); + const channels = await query + .take(ps.limit) + .getMany(); - const channels = await query - .limit(ps.limit) - .getMany(); - - return await Promise.all(channels.map(x => this.channelEntityService.pack(x, me))); - }); - } -} + return await Promise.all(channels.map(x => Channels.pack(x, me))); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/search.ts b/packages/backend/src/server/api/endpoints/channels/search.ts deleted file mode 100644 index a071c80b3c..0000000000 --- a/packages/backend/src/server/api/endpoints/channels/search.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Brackets } from 'typeorm'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import type { ChannelsRepository } from '@/models/_.js'; -import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; - -export const meta = { - tags: ['channels'], - - requireCredential: 'conditional', - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'Channel', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - query: { type: 'string' }, - type: { type: 'string', enum: ['nameAndDescription', 'nameOnly'], default: 'nameAndDescription' }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - limit: { type: 'integer', minimum: 1, maximum: 100, default: 5 }, - }, - required: ['query'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - - private channelEntityService: ChannelEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId) - .andWhere('channel.isArchived = FALSE'); - - if (ps.query !== '') { - if (ps.type === 'nameAndDescription') { - query.andWhere(new Brackets(qb => { - qb - .where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }) - .orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); - })); - } else { - query.andWhere('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); - } - } - - const channels = await query - .limit(ps.limit) - .getMany(); - - return await Promise.all(channels.map(x => this.channelEntityService.pack(x, me))); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/channels/show.ts b/packages/backend/src/server/api/endpoints/channels/show.ts index b1b2827797..87665a9865 100644 --- a/packages/backend/src/server/api/endpoints/channels/show.ts +++ b/packages/backend/src/server/api/endpoints/channels/show.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelsRepository } from '@/models/_.js'; -import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { Channels } from '@/models/index.js'; export const meta = { tags: ['channels'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'object', @@ -38,24 +30,15 @@ export const paramDef = { required: ['channelId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const channel = await Channels.findOneBy({ + id: ps.channelId, + }); - private channelEntityService: ChannelEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const channel = await this.channelsRepository.findOneBy({ - id: ps.channelId, - }); - - if (channel == null) { - throw new ApiError(meta.errors.noSuchChannel); - } - - return await this.channelEntityService.pack(channel, me, true); - }); + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); } -} + + return await Channels.pack(channel, me); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 3f92213d43..deaa299013 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -1,26 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelsRepository, NotesRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import ActiveUsersChart from '@/core/chart/charts/active-users.js'; -import { DI } from '@/di-symbols.js'; -import { IdService } from '@/core/IdService.js'; -import { CacheService } from '@/core/CacheService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; -import { MiLocalUser } from '@/models/User.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { Notes, Channels } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { activeUsersChart } from '@/services/chart/index.js'; export const meta = { tags: ['notes', 'channels'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'array', @@ -50,86 +37,40 @@ export const paramDef = { untilId: { type: 'string', format: 'misskey:id' }, sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, - allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default }, required: ['channelId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const channel = await Channels.findOneBy({ + id: ps.channelId, + }); - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - - private idService: IdService, - private noteEntityService: NoteEntityService, - private queryService: QueryService, - private fanoutTimelineEndpointService: FanoutTimelineEndpointService, - private cacheService: CacheService, - private activeUsersChart: ActiveUsersChart, - private metaService: MetaService, - ) { - super(meta, paramDef, async (ps, me) => { - const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); - const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); - - const serverSettings = await this.metaService.fetch(); - - const channel = await this.channelsRepository.findOneBy({ - id: ps.channelId, - }); - - if (channel == null) { - throw new ApiError(meta.errors.noSuchChannel); - } - - if (me) this.activeUsersChart.read(me); - - if (!serverSettings.enableFanoutTimeline) { - return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id }, me), me); - } - - return await this.fanoutTimelineEndpointService.timeline({ - untilId, - sinceId, - limit: ps.limit, - allowPartial: ps.allowPartial, - me, - useDbFallback: true, - redisTimelines: [`channelTimeline:${channel.id}`], - excludePureRenotes: false, - dbFallback: async (untilId, sinceId, limit) => { - return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me); - }, - }); - }); + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); } - private async getFromDb(ps: { - untilId: string | null, - sinceId: string | null, - limit: number, - channelId: string - }, me: MiLocalUser | null) { - //#region fallback to database - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere('note.channelId = :channelId', { channelId: ps.channelId }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); + //#region Construct query + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.channelId = :channelId', { channelId: channel.id }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') + .leftJoinAndSelect('note.channel', 'channel'); + //#endregion - if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - } - //#endregion + const timeline = await query.take(ps.limit).getMany(); - return await query.limit(ps.limit).getMany(); - } -} + if (user) activeUsersChart.read(user); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/unfavorite.ts b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts deleted file mode 100644 index ec3dac2e9a..0000000000 --- a/packages/backend/src/server/api/endpoints/channels/unfavorite.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelFavoritesRepository, ChannelsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['channels'], - - requireCredential: 'always', - - prohibitMoved: true, - - kind: 'write:channels', - - errors: { - noSuchChannel: { - message: 'No such channel.', - code: 'NO_SUCH_CHANNEL', - id: '353c68dd-131a-476c-aa99-88a345e83668', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - channelId: { type: 'string', format: 'misskey:id' }, - }, - required: ['channelId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - - @Inject(DI.channelFavoritesRepository) - private channelFavoritesRepository: ChannelFavoritesRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const channel = await this.channelsRepository.findOneBy({ - id: ps.channelId, - }); - - if (channel == null) { - throw new ApiError(meta.errors.noSuchChannel); - } - - await this.channelFavoritesRepository.delete({ - userId: me.id, - channelId: channel.id, - }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/channels/unfollow.ts b/packages/backend/src/server/api/endpoints/channels/unfollow.ts index d018e11af9..e065d897a5 100644 --- a/packages/backend/src/server/api/endpoints/channels/unfollow.ts +++ b/packages/backend/src/server/api/endpoints/channels/unfollow.ts @@ -1,21 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { Channels, ChannelFollowings } from '@/models/index.js'; +import { publishUserEvent } from '@/services/stream.js'; export const meta = { tags: ['channels'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:channels', @@ -36,23 +27,20 @@ export const paramDef = { required: ['channelId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - private channelFollowingService: ChannelFollowingService, - ) { - super(meta, paramDef, async (ps, me) => { - const channel = await this.channelsRepository.findOneBy({ - id: ps.channelId, - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const channel = await Channels.findOneBy({ + id: ps.channelId, + }); - if (channel == null) { - throw new ApiError(meta.errors.noSuchChannel); - } - - await this.channelFollowingService.unfollow(me, channel); - }); + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); } -} + + await ChannelFollowings.delete({ + followerId: user.id, + followeeId: channel.id, + }); + + publishUserEvent(user.id, 'unfollowChannel', channel); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index 89b9b7e41c..13104f324f 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFilesRepository, ChannelsRepository } from '@/models/_.js'; -import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { Channels, DriveFiles } from '@/models/index.js'; export const meta = { tags: ['channels'], - requireCredential: 'always', + requireCredential: true, kind: 'write:channels', @@ -52,74 +43,44 @@ export const paramDef = { name: { type: 'string', minLength: 1, maxLength: 128 }, description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, - isArchived: { type: 'boolean', nullable: true }, - pinnedNoteIds: { - type: 'array', - items: { - type: 'string', format: 'misskey:id', - }, - }, - color: { type: 'string', minLength: 1, maxLength: 16 }, - isSensitive: { type: 'boolean', nullable: true }, - allowRenoteToExternal: { type: 'boolean', nullable: true }, }, required: ['channelId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const channel = await Channels.findOneBy({ + id: ps.channelId, + }); - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private channelEntityService: ChannelEntityService, - - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const channel = await this.channelsRepository.findOneBy({ - id: ps.channelId, - }); - - if (channel == null) { - throw new ApiError(meta.errors.noSuchChannel); - } - - const iAmModerator = await this.roleService.isModerator(me); - if (channel.userId !== me.id && !iAmModerator) { - throw new ApiError(meta.errors.accessDenied); - } - - // eslint:disable-next-line:no-unnecessary-initializer - let banner = undefined; - if (ps.bannerId != null) { - banner = await this.driveFilesRepository.findOneBy({ - id: ps.bannerId, - userId: me.id, - }); - - if (banner == null) { - throw new ApiError(meta.errors.noSuchFile); - } - } else if (ps.bannerId === null) { - banner = null; - } - - await this.channelsRepository.update(channel.id, { - ...(ps.name !== undefined ? { name: ps.name } : {}), - ...(ps.description !== undefined ? { description: ps.description } : {}), - ...(ps.pinnedNoteIds !== undefined ? { pinnedNoteIds: ps.pinnedNoteIds } : {}), - ...(ps.color !== undefined ? { color: ps.color } : {}), - ...(typeof ps.isArchived === 'boolean' ? { isArchived: ps.isArchived } : {}), - ...(banner ? { bannerId: banner.id } : {}), - ...(typeof ps.isSensitive === 'boolean' ? { isSensitive: ps.isSensitive } : {}), - ...(typeof ps.allowRenoteToExternal === 'boolean' ? { allowRenoteToExternal: ps.allowRenoteToExternal } : {}), - }); - - return await this.channelEntityService.pack(channel.id, me); - }); + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); } -} + + if (channel.userId !== me.id) { + throw new ApiError(meta.errors.accessDenied); + } + + // eslint:disable-next-line:no-unnecessary-initializer + let banner = undefined; + if (ps.bannerId != null) { + banner = await DriveFiles.findOneBy({ + id: ps.bannerId, + userId: me.id, + }); + + if (banner == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } else if (ps.bannerId === null) { + banner = null; + } + + await Channels.update(channel.id, { + ...(ps.name !== undefined ? { name: ps.name } : {}), + ...(ps.description !== undefined ? { description: ps.description } : {}), + ...(banner ? { bannerId: banner.id } : {}), + }); + + return await Channels.pack(channel.id, me); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/active-users.ts b/packages/backend/src/server/api/endpoints/charts/active-users.ts index 5b7a977a88..ea23794296 100644 --- a/packages/backend/src/server/api/endpoints/charts/active-users.ts +++ b/packages/backend/src/server/api/endpoints/charts/active-users.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { getJsonSchema } from '@/core/chart/core.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import ActiveUsersChart from '@/core/chart/charts/active-users.js'; -import { schema } from '@/core/chart/charts/entities/active-users.js'; +import { getJsonSchema } from '@/services/chart/core.js'; +import { activeUsersChart } from '@/services/chart/index.js'; +import define from '../../define.js'; export const meta = { tags: ['charts', 'users'], - requireCredential: 'conditional', - - res: getJsonSchema(schema), + res: getJsonSchema(activeUsersChart.schema), allowGet: true, cacheSec: 60 * 60, @@ -30,13 +21,7 @@ export const paramDef = { required: ['span'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private activeUsersChart: ActiveUsersChart, - ) { - super(meta, paramDef, async (ps, me) => { - return await this.activeUsersChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + return await activeUsersChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/ap-request.ts b/packages/backend/src/server/api/endpoints/charts/ap-request.ts index e6f805aeee..06dee250ee 100644 --- a/packages/backend/src/server/api/endpoints/charts/ap-request.ts +++ b/packages/backend/src/server/api/endpoints/charts/ap-request.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { getJsonSchema } from '@/core/chart/core.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import ApRequestChart from '@/core/chart/charts/ap-request.js'; -import { schema } from '@/core/chart/charts/entities/ap-request.js'; +import { getJsonSchema } from '@/services/chart/core.js'; +import { apRequestChart } from '@/services/chart/index.js'; +import define from '../../define.js'; export const meta = { tags: ['charts'], - requireCredential: 'conditional', - - res: getJsonSchema(schema), + res: getJsonSchema(apRequestChart.schema), allowGet: true, cacheSec: 60 * 60, @@ -30,13 +21,7 @@ export const paramDef = { required: ['span'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private apRequestChart: ApRequestChart, - ) { - super(meta, paramDef, async (ps, me) => { - return await this.apRequestChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + return await apRequestChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/drive.ts b/packages/backend/src/server/api/endpoints/charts/drive.ts index b3bd0d83f5..dd2c2d6838 100644 --- a/packages/backend/src/server/api/endpoints/charts/drive.ts +++ b/packages/backend/src/server/api/endpoints/charts/drive.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { getJsonSchema } from '@/core/chart/core.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import DriveChart from '@/core/chart/charts/drive.js'; -import { schema } from '@/core/chart/charts/entities/drive.js'; +import { getJsonSchema } from '@/services/chart/core.js'; +import { driveChart } from '@/services/chart/index.js'; +import define from '../../define.js'; export const meta = { tags: ['charts', 'drive'], - requireCredential: 'conditional', - - res: getJsonSchema(schema), + res: getJsonSchema(driveChart.schema), allowGet: true, cacheSec: 60 * 60, @@ -30,13 +21,7 @@ export const paramDef = { required: ['span'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private driveChart: DriveChart, - ) { - super(meta, paramDef, async (ps, me) => { - return await this.driveChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + return await driveChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/federation.ts b/packages/backend/src/server/api/endpoints/charts/federation.ts index b3bb424a2e..8c35b3c46d 100644 --- a/packages/backend/src/server/api/endpoints/charts/federation.ts +++ b/packages/backend/src/server/api/endpoints/charts/federation.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { getJsonSchema } from '@/core/chart/core.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import FederationChart from '@/core/chart/charts/federation.js'; -import { schema } from '@/core/chart/charts/entities/federation.js'; +import { getJsonSchema } from '@/services/chart/core.js'; +import { federationChart } from '@/services/chart/index.js'; +import define from '../../define.js'; export const meta = { tags: ['charts'], - requireCredential: 'conditional', - - res: getJsonSchema(schema), + res: getJsonSchema(federationChart.schema), allowGet: true, cacheSec: 60 * 60, @@ -30,13 +21,7 @@ export const paramDef = { required: ['span'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private federationChart: FederationChart, - ) { - super(meta, paramDef, async (ps, me) => { - return await this.federationChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + return await federationChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/hashtag.ts b/packages/backend/src/server/api/endpoints/charts/hashtag.ts new file mode 100644 index 0000000000..77e24a62c3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/hashtag.ts @@ -0,0 +1,28 @@ +import { getJsonSchema } from '@/services/chart/core.js'; +import { hashtagChart } from '@/services/chart/index.js'; +import define from '../../define.js'; + +export const meta = { + tags: ['charts', 'hashtags'], + + res: getJsonSchema(hashtagChart.schema), + + allowGet: true, + cacheSec: 60 * 60, +} as const; + +export const paramDef = { + type: 'object', + properties: { + span: { type: 'string', enum: ['day', 'hour'] }, + limit: { type: 'integer', minimum: 1, maximum: 500, default: 30 }, + offset: { type: 'integer', nullable: true, default: null }, + tag: { type: 'string' }, + }, + required: ['span', 'tag'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + return await hashtagChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.tag); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/instance.ts b/packages/backend/src/server/api/endpoints/charts/instance.ts index 07a0efc7bf..817d51ad01 100644 --- a/packages/backend/src/server/api/endpoints/charts/instance.ts +++ b/packages/backend/src/server/api/endpoints/charts/instance.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { getJsonSchema } from '@/core/chart/core.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import InstanceChart from '@/core/chart/charts/instance.js'; -import { schema } from '@/core/chart/charts/entities/instance.js'; +import { getJsonSchema } from '@/services/chart/core.js'; +import { instanceChart } from '@/services/chart/index.js'; +import define from '../../define.js'; export const meta = { tags: ['charts'], - requireCredential: 'conditional', - - res: getJsonSchema(schema), + res: getJsonSchema(instanceChart.schema), allowGet: true, cacheSec: 60 * 60, @@ -31,13 +22,7 @@ export const paramDef = { required: ['span', 'host'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private instanceChart: InstanceChart, - ) { - super(meta, paramDef, async (ps, me) => { - return await this.instanceChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.host); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + return await instanceChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.host); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/notes.ts b/packages/backend/src/server/api/endpoints/charts/notes.ts index 06cd500ef1..951adf5408 100644 --- a/packages/backend/src/server/api/endpoints/charts/notes.ts +++ b/packages/backend/src/server/api/endpoints/charts/notes.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { getJsonSchema } from '@/core/chart/core.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import NotesChart from '@/core/chart/charts/notes.js'; -import { schema } from '@/core/chart/charts/entities/notes.js'; +import { getJsonSchema } from '@/services/chart/core.js'; +import { notesChart } from '@/services/chart/index.js'; +import define from '../../define.js'; export const meta = { tags: ['charts', 'notes'], - requireCredential: 'conditional', - - res: getJsonSchema(schema), + res: getJsonSchema(notesChart.schema), allowGet: true, cacheSec: 60 * 60, @@ -30,13 +21,7 @@ export const paramDef = { required: ['span'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private notesChart: NotesChart, - ) { - super(meta, paramDef, async (ps, me) => { - return await this.notesChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + return await notesChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/user/drive.ts b/packages/backend/src/server/api/endpoints/charts/user/drive.ts index 0e0ab72e8d..f165b40224 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/drive.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/drive.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { getJsonSchema } from '@/core/chart/core.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js'; -import { schema } from '@/core/chart/charts/entities/per-user-drive.js'; +import { getJsonSchema } from '@/services/chart/core.js'; +import { perUserDriveChart } from '@/services/chart/index.js'; +import define from '../../../define.js'; export const meta = { tags: ['charts', 'drive', 'users'], - requireCredential: 'conditional', - - res: getJsonSchema(schema), + res: getJsonSchema(perUserDriveChart.schema), allowGet: true, cacheSec: 60 * 60, @@ -31,13 +22,7 @@ export const paramDef = { required: ['span', 'userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private perUserDriveChart: PerUserDriveChart, - ) { - super(meta, paramDef, async (ps, me) => { - return await this.perUserDriveChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + return await perUserDriveChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/user/following.ts b/packages/backend/src/server/api/endpoints/charts/user/following.ts index 7c58a3416f..f5d42e21c2 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/following.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/following.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { getJsonSchema } from '@/core/chart/core.js'; -import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; -import { schema } from '@/core/chart/charts/entities/per-user-following.js'; +import define from '../../../define.js'; +import { getJsonSchema } from '@/services/chart/core.js'; +import { perUserFollowingChart } from '@/services/chart/index.js'; export const meta = { tags: ['charts', 'users', 'following'], - requireCredential: 'conditional', - - res: getJsonSchema(schema), + res: getJsonSchema(perUserFollowingChart.schema), allowGet: true, cacheSec: 60 * 60, @@ -31,13 +22,7 @@ export const paramDef = { required: ['span', 'userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private perUserFollowingChart: PerUserFollowingChart, - ) { - super(meta, paramDef, async (ps, me) => { - return await this.perUserFollowingChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + return await perUserFollowingChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/user/notes.ts b/packages/backend/src/server/api/endpoints/charts/user/notes.ts index 26946d252a..aefe550d43 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/notes.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/notes.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { getJsonSchema } from '@/core/chart/core.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; -import { schema } from '@/core/chart/charts/entities/per-user-notes.js'; +import { getJsonSchema } from '@/services/chart/core.js'; +import { perUserNotesChart } from '@/services/chart/index.js'; +import define from '../../../define.js'; export const meta = { tags: ['charts', 'users', 'notes'], - requireCredential: 'conditional', - - res: getJsonSchema(schema), + res: getJsonSchema(perUserNotesChart.schema), allowGet: true, cacheSec: 60 * 60, @@ -31,13 +22,7 @@ export const paramDef = { required: ['span', 'userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private perUserNotesChart: PerUserNotesChart, - ) { - super(meta, paramDef, async (ps, me) => { - return await this.perUserNotesChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + return await perUserNotesChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/user/pv.ts b/packages/backend/src/server/api/endpoints/charts/user/pv.ts deleted file mode 100644 index 40dfc1b9ef..0000000000 --- a/packages/backend/src/server/api/endpoints/charts/user/pv.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { getJsonSchema } from '@/core/chart/core.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import PerUserPvChart from '@/core/chart/charts/per-user-pv.js'; -import { schema } from '@/core/chart/charts/entities/per-user-pv.js'; - -export const meta = { - tags: ['charts', 'users'], - - requireCredential: 'conditional', - - res: getJsonSchema(schema), - - allowGet: true, - cacheSec: 60 * 60, -} as const; - -export const paramDef = { - type: 'object', - properties: { - span: { type: 'string', enum: ['day', 'hour'] }, - limit: { type: 'integer', minimum: 1, maximum: 500, default: 30 }, - offset: { type: 'integer', nullable: true, default: null }, - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['span', 'userId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private perUserPvChart: PerUserPvChart, - ) { - super(meta, paramDef, async (ps, me) => { - return await this.perUserPvChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/charts/user/reactions.ts b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts index 17a5383d02..6bc6b56bf0 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/reactions.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { getJsonSchema } from '@/core/chart/core.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js'; -import { schema } from '@/core/chart/charts/entities/per-user-reactions.js'; +import { getJsonSchema } from '@/services/chart/core.js'; +import { perUserReactionsChart } from '@/services/chart/index.js'; +import define from '../../../define.js'; export const meta = { tags: ['charts', 'users', 'reactions'], - requireCredential: 'conditional', - - res: getJsonSchema(schema), + res: getJsonSchema(perUserReactionsChart.schema), allowGet: true, cacheSec: 60 * 60, @@ -31,13 +22,7 @@ export const paramDef = { required: ['span', 'userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private perUserReactionsChart: PerUserReactionsChart, - ) { - super(meta, paramDef, async (ps, me) => { - return await this.perUserReactionsChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + return await perUserReactionsChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/users.ts b/packages/backend/src/server/api/endpoints/charts/users.ts index b6a4f49991..338e8fd338 100644 --- a/packages/backend/src/server/api/endpoints/charts/users.ts +++ b/packages/backend/src/server/api/endpoints/charts/users.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { getJsonSchema } from '@/core/chart/core.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import UsersChart from '@/core/chart/charts/users.js'; -import { schema } from '@/core/chart/charts/entities/users.js'; +import { getJsonSchema } from '@/services/chart/core.js'; +import { usersChart } from '@/services/chart/index.js'; +import define from '../../define.js'; export const meta = { tags: ['charts', 'users'], - requireCredential: 'conditional', - - res: getJsonSchema(schema), + res: getJsonSchema(usersChart.schema), allowGet: true, cacheSec: 60 * 60, @@ -30,13 +21,7 @@ export const paramDef = { required: ['span'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private usersChart: UsersChart, - ) { - super(meta, paramDef, async (ps, me) => { - return await this.usersChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + return await usersChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/add-note.ts b/packages/backend/src/server/api/endpoints/clips/add-note.ts index 9116b50a87..5d72f5c1bf 100644 --- a/packages/backend/src/server/api/endpoints/clips/add-note.ts +++ b/packages/backend/src/server/api/endpoints/clips/add-note.ts @@ -1,28 +1,16 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import ms from 'ms'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ClipService } from '@/core/ClipService.js'; +import define from '../../define.js'; +import { ClipNotes, Clips } from '@/models/index.js'; import { ApiError } from '../../error.js'; +import { genId } from '@/misc/gen-id.js'; +import { getNote } from '../../common/getters.js'; export const meta = { tags: ['account', 'notes', 'clips'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:account', - limit: { - duration: ms('1hour'), - max: 20, - }, - errors: { noSuchClip: { message: 'No such clip.', @@ -41,12 +29,6 @@ export const meta = { code: 'ALREADY_CLIPPED', id: '734806c4-542c-463a-9311-15c512803965', }, - - tooManyClipNotes: { - message: 'You cannot add notes to the clip any more.', - code: 'TOO_MANY_CLIP_NOTES', - id: 'f0dba960-ff73-4615-8df4-d6ac5d9dc118', - }, }, } as const; @@ -59,27 +41,34 @@ export const paramDef = { required: ['clipId', 'noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private clipService: ClipService, - ) { - super(meta, paramDef, async (ps, me) => { - try { - await this.clipService.addNote(me, ps.clipId, ps.noteId); - } catch (e) { - if (e instanceof ClipService.NoSuchClipError) { - throw new ApiError(meta.errors.noSuchClip); - } else if (e instanceof ClipService.NoSuchNoteError) { - throw new ApiError(meta.errors.noSuchNote); - } else if (e instanceof ClipService.AlreadyAddedError) { - throw new ApiError(meta.errors.alreadyClipped); - } else if (e instanceof ClipService.TooManyClipNotesError) { - throw new ApiError(meta.errors.tooManyClipNotes); - } else { - throw e; - } - } - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const clip = await Clips.findOneBy({ + id: ps.clipId, + userId: user.id, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); } -} + + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const exist = await ClipNotes.findOneBy({ + noteId: note.id, + clipId: clip.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyClipped); + } + + await ClipNotes.insert({ + id: genId(), + noteId: note.id, + clipId: clip.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/create.ts b/packages/backend/src/server/api/endpoints/clips/create.ts index 40c6a3b8d1..4afe4222a1 100644 --- a/packages/backend/src/server/api/endpoints/clips/create.ts +++ b/packages/backend/src/server/api/endpoints/clips/create.ts @@ -1,21 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { MiClip } from '@/models/_.js'; -import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; -import { ApiError } from '@/server/api/error.js'; -import { ClipService } from '@/core/ClipService.js'; +import define from '../../define.js'; +import { genId } from '@/misc/gen-id.js'; +import { Clips } from '@/models/index.js'; export const meta = { tags: ['clips'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:account', @@ -24,14 +14,6 @@ export const meta = { optional: false, nullable: false, ref: 'Clip', }, - - errors: { - tooManyClips: { - message: 'You cannot create clip any more.', - code: 'TOO_MANY_CLIPS', - id: '920f7c2d-6208-4b76-8082-e632020f5883', - }, - }, } as const; export const paramDef = { @@ -44,23 +26,16 @@ export const paramDef = { required: ['name'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private clipEntityService: ClipEntityService, - private clipService: ClipService, - ) { - super(meta, paramDef, async (ps, me) => { - let clip: MiClip; - try { - clip = await this.clipService.create(me, ps.name, ps.isPublic, ps.description ?? null); - } catch (e) { - if (e instanceof ClipService.TooManyClipsError) { - throw new ApiError(meta.errors.tooManyClips); - } - throw e; - } - return await this.clipEntityService.pack(clip, me); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const clip = await Clips.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + isPublic: ps.isPublic, + description: ps.description, + }).then(x => Clips.findOneByOrFail(x.identifiers[0])); + + return await Clips.pack(clip); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/delete.ts b/packages/backend/src/server/api/endpoints/clips/delete.ts index 7b19fb4b69..b6c0eb702a 100644 --- a/packages/backend/src/server/api/endpoints/clips/delete.ts +++ b/packages/backend/src/server/api/endpoints/clips/delete.ts @@ -1,17 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ClipService } from '@/core/ClipService.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { Clips } from '@/models/index.js'; export const meta = { tags: ['clips'], - requireCredential: 'always', + requireCredential: true, kind: 'write:account', @@ -32,20 +26,16 @@ export const paramDef = { required: ['clipId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private clipService: ClipService, - ) { - super(meta, paramDef, async (ps, me) => { - try { - await this.clipService.delete(me, ps.clipId); - } catch (e) { - if (e instanceof ClipService.NoSuchClipError) { - throw new ApiError(meta.errors.noSuchClip); - } - throw e; - } - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const clip = await Clips.findOneBy({ + id: ps.clipId, + userId: user.id, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); } -} + + await Clips.delete(clip.id); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/favorite.ts b/packages/backend/src/server/api/endpoints/clips/favorite.ts deleted file mode 100644 index b9e402018c..0000000000 --- a/packages/backend/src/server/api/endpoints/clips/favorite.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { ClipsRepository, ClipFavoritesRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['clip'], - - requireCredential: 'always', - - prohibitMoved: true, - - kind: 'write:clip-favorite', - - errors: { - noSuchClip: { - message: 'No such clip.', - code: 'NO_SUCH_CLIP', - id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5', - }, - - alreadyFavorited: { - message: 'The clip has already been favorited.', - code: 'ALREADY_FAVORITED', - id: '92658936-c625-4273-8326-2d790129256e', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - clipId: { type: 'string', format: 'misskey:id' }, - }, - required: ['clipId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.clipsRepository) - private clipsRepository: ClipsRepository, - - @Inject(DI.clipFavoritesRepository) - private clipFavoritesRepository: ClipFavoritesRepository, - - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const clip = await this.clipsRepository.findOneBy({ id: ps.clipId }); - if (clip == null) { - throw new ApiError(meta.errors.noSuchClip); - } - if ((clip.userId !== me.id) && !clip.isPublic) { - throw new ApiError(meta.errors.noSuchClip); - } - - const exist = await this.clipFavoritesRepository.exist({ - where: { - clipId: clip.id, - userId: me.id, - }, - }); - - if (exist) { - throw new ApiError(meta.errors.alreadyFavorited); - } - - await this.clipFavoritesRepository.insert({ - id: this.idService.gen(), - clipId: clip.id, - userId: me.id, - }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/clips/list.ts b/packages/backend/src/server/api/endpoints/clips/list.ts index b04112fa49..378811eba0 100644 --- a/packages/backend/src/server/api/endpoints/clips/list.ts +++ b/packages/backend/src/server/api/endpoints/clips/list.ts @@ -1,18 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ClipsRepository } from '@/models/_.js'; -import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { Clips } from '@/models/index.js'; export const meta = { tags: ['clips', 'account'], - requireCredential: 'always', + requireCredential: true, kind: 'read:account', @@ -33,20 +25,11 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.clipsRepository) - private clipsRepository: ClipsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const clips = await Clips.findBy({ + userId: me.id, + }); - private clipEntityService: ClipEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const clips = await this.clipsRepository.findBy({ - userId: me.id, - }); - - return await this.clipEntityService.packMany(clips, me); - }); - } -} + return await Promise.all(clips.map(x => Clips.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/my-favorites.ts b/packages/backend/src/server/api/endpoints/clips/my-favorites.ts deleted file mode 100644 index b6676fb88b..0000000000 --- a/packages/backend/src/server/api/endpoints/clips/my-favorites.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ClipFavoritesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; - -export const meta = { - tags: ['account', 'clip'], - - requireCredential: 'always', - - kind: 'read:clip-favorite', - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'Clip', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - }, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.clipFavoritesRepository) - private clipFavoritesRepository: ClipFavoritesRepository, - - private clipEntityService: ClipEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.clipFavoritesRepository.createQueryBuilder('favorite') - .andWhere('favorite.userId = :meId', { meId: me.id }) - .leftJoinAndSelect('favorite.clip', 'clip'); - - const favorites = await query - .getMany(); - - return this.clipEntityService.packMany(favorites.map(x => x.clip!), me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts index d81e8780e3..4ace747efe 100644 --- a/packages/backend/src/server/api/endpoints/clips/notes.ts +++ b/packages/backend/src/server/api/endpoints/clips/notes.ts @@ -1,20 +1,15 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { NotesRepository, ClipsRepository, ClipNotesRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { ClipNotes, Clips, Notes } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; import { ApiError } from '../../error.js'; +import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; export const meta = { tags: ['account', 'notes', 'clips'], - requireCredential: 'conditional', + requireCredential: false, kind: 'read:account', @@ -48,54 +43,44 @@ export const paramDef = { required: ['clipId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.clipsRepository) - private clipsRepository: ClipsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const clip = await Clips.findOneBy({ + id: ps.clipId, + }); - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.clipNotesRepository) - private clipNotesRepository: ClipNotesRepository, - - private noteEntityService: NoteEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const clip = await this.clipsRepository.findOneBy({ - id: ps.clipId, - }); - - if (clip == null) { - throw new ApiError(meta.errors.noSuchClip); - } - - if (!clip.isPublic && (me == null || (clip.userId !== me.id))) { - throw new ApiError(meta.errors.noSuchClip); - } - - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .innerJoin(this.clipNotesRepository.metadata.targetName, 'clipNote', 'clipNote.noteId = note.id') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .andWhere('clipNote.clipId = :clipId', { clipId: clip.id }); - - if (me) { - this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - } - - const notes = await query - .limit(ps.limit) - .getMany(); - - return await this.noteEntityService.packMany(notes, me); - }); + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); } -} + + if (!clip.isPublic && (user == null || (clip.userId !== user.id))) { + throw new ApiError(meta.errors.noSuchClip); + } + + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .innerJoin(ClipNotes.metadata.targetName, 'clipNote', 'clipNote.noteId = note.id') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') + .andWhere('clipNote.clipId = :clipId', { clipId: clip.id }); + + if (user) { + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + } + + const notes = await query + .take(ps.limit) + .getMany(); + + return await Notes.packMany(notes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/remove-note.ts b/packages/backend/src/server/api/endpoints/clips/remove-note.ts index d3b3ff331f..8b90e31f65 100644 --- a/packages/backend/src/server/api/endpoints/clips/remove-note.ts +++ b/packages/backend/src/server/api/endpoints/clips/remove-note.ts @@ -1,62 +1,57 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ClipService } from '@/core/ClipService.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['account', 'notes', 'clips'], - - requireCredential: 'always', - - prohibitMoved: true, - - kind: 'write:account', - - errors: { - noSuchClip: { - message: 'No such clip.', - code: 'NO_SUCH_CLIP', - id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', - }, - - noSuchNote: { - message: 'No such note.', - code: 'NO_SUCH_NOTE', - id: 'aff017de-190e-434b-893e-33a9ff5049d8', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - clipId: { type: 'string', format: 'misskey:id' }, - noteId: { type: 'string', format: 'misskey:id' }, - }, - required: ['clipId', 'noteId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private clipService: ClipService, - ) { - super(meta, paramDef, async (ps, me) => { - try { - await this.clipService.removeNote(me, ps.clipId, ps.noteId); - } catch (e) { - if (e instanceof ClipService.NoSuchClipError) { - throw new ApiError(meta.errors.noSuchClip); - } else if (e instanceof ClipService.NoSuchNoteError) { - throw new ApiError(meta.errors.noSuchNote); - } - throw e; - } - }); - } -} +import define from '../../define.js'; +import { ClipNotes, Clips } from '@/models/index.js'; +import { ApiError } from '../../error.js'; +import { getNote } from '../../common/getters.js'; + +export const meta = { + tags: ['account', 'notes', 'clips'], + + requireCredential: true, + + kind: 'write:account', + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', + }, + + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'aff017de-190e-434b-893e-33a9ff5049d8', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + clipId: { type: 'string', format: 'misskey:id' }, + noteId: { type: 'string', format: 'misskey:id' }, + }, + required: ['clipId', 'noteId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const clip = await Clips.findOneBy({ + id: ps.clipId, + userId: user.id, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + await ClipNotes.delete({ + noteId: note.id, + clipId: clip.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/show.ts b/packages/backend/src/server/api/endpoints/clips/show.ts index f7b2b6fd34..c3d73c168d 100644 --- a/packages/backend/src/server/api/endpoints/clips/show.ts +++ b/packages/backend/src/server/api/endpoints/clips/show.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ClipsRepository } from '@/models/_.js'; -import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { Clips } from '@/models/index.js'; export const meta = { tags: ['clips', 'account'], - requireCredential: 'conditional', + requireCredential: false, kind: 'read:account', @@ -40,29 +32,20 @@ export const paramDef = { required: ['clipId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.clipsRepository) - private clipsRepository: ClipsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + // Fetch the clip + const clip = await Clips.findOneBy({ + id: ps.clipId, + }); - private clipEntityService: ClipEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - // Fetch the clip - const clip = await this.clipsRepository.findOneBy({ - id: ps.clipId, - }); - - if (clip == null) { - throw new ApiError(meta.errors.noSuchClip); - } - - if (!clip.isPublic && (me == null || (clip.userId !== me.id))) { - throw new ApiError(meta.errors.noSuchClip); - } - - return await this.clipEntityService.pack(clip, me); - }); + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); } -} + + if (!clip.isPublic && (me == null || (clip.userId !== me.id))) { + throw new ApiError(meta.errors.noSuchClip); + } + + return await Clips.pack(clip); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/unfavorite.ts b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts deleted file mode 100644 index 6be66659e7..0000000000 --- a/packages/backend/src/server/api/endpoints/clips/unfavorite.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { ClipsRepository, ClipFavoritesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['clip'], - - requireCredential: 'always', - - prohibitMoved: true, - - kind: 'write:clip-favorite', - - errors: { - noSuchClip: { - message: 'No such clip.', - code: 'NO_SUCH_CLIP', - id: '2603966e-b865-426c-94a7-af4a01241dc1', - }, - - notFavorited: { - message: 'You have not favorited the clip.', - code: 'NOT_FAVORITED', - id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - clipId: { type: 'string', format: 'misskey:id' }, - }, - required: ['clipId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.clipsRepository) - private clipsRepository: ClipsRepository, - - @Inject(DI.clipFavoritesRepository) - private clipFavoritesRepository: ClipFavoritesRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const clip = await this.clipsRepository.findOneBy({ id: ps.clipId }); - if (clip == null) { - throw new ApiError(meta.errors.noSuchClip); - } - - const exist = await this.clipFavoritesRepository.findOneBy({ - clipId: clip.id, - userId: me.id, - }); - - if (exist == null) { - throw new ApiError(meta.errors.notFavorited); - } - - await this.clipFavoritesRepository.delete(exist.id); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts index d5b5c5e6e9..b67d844f6e 100644 --- a/packages/backend/src/server/api/endpoints/clips/update.ts +++ b/packages/backend/src/server/api/endpoints/clips/update.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; -import { ClipService } from '@/core/ClipService.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { Clips } from '@/models/index.js'; export const meta = { tags: ['clips'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:account', @@ -44,24 +35,23 @@ export const paramDef = { required: ['clipId', 'name'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private clipService: ClipService, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Fetch the clip + const clip = await Clips.findOneBy({ + id: ps.clipId, + userId: user.id, + }); - private clipEntityService: ClipEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - try { - await this.clipService.update(me, ps.clipId, ps.name, ps.isPublic, ps.description); - } catch (e) { - if (e instanceof ClipService.NoSuchClipError) { - throw new ApiError(meta.errors.noSuchClip); - } - throw e; - } - - return await this.clipEntityService.pack(ps.clipId, me); - }); + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); } -} + + await Clips.update(clip.id, { + name: ps.name, + description: ps.description, + isPublic: ps.isPublic, + }); + + return await Clips.pack(clip.id); +}); diff --git a/packages/backend/src/server/api/endpoints/drive.ts b/packages/backend/src/server/api/endpoints/drive.ts index e654e571e7..82497adefa 100644 --- a/packages/backend/src/server/api/endpoints/drive.ts +++ b/packages/backend/src/server/api/endpoints/drive.ts @@ -1,18 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { MetaService } from '@/core/MetaService.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { RoleService } from '@/core/RoleService.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { DriveFiles } from '@/models/index.js'; +import define from '../define.js'; export const meta = { tags: ['drive', 'account'], - requireCredential: 'always', + requireCredential: true, kind: 'read:drive', @@ -38,25 +31,15 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private metaService: MetaService, - private driveFileEntityService: DriveFileEntityService, - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const instance = await this.metaService.fetch(true); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const instance = await fetchMeta(true); - // Calculate drive usage - const usage = await this.driveFileEntityService.calcDriveUsageOf(me.id); + // Calculate drive usage + const usage = await DriveFiles.calcDriveUsageOf(user.id); - const policies = await this.roleService.getUserPolicies(me.id); - - return { - capacity: 1024 * 1024 * policies.driveCapacityMb, - usage: usage, - }; - }); - } -} + return { + capacity: 1024 * 1024 * (user.driveCapacityOverrideMb || instance.localDriveCapacityMb), + usage: usage, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts index c8835e4861..40e6c16c9c 100644 --- a/packages/backend/src/server/api/endpoints/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/drive/files.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { DriveFiles } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['drive'], - requireCredential: 'always', + requireCredential: true, kind: 'read:drive', @@ -36,50 +28,30 @@ export const paramDef = { untilId: { type: 'string', format: 'misskey:id' }, folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, type: { type: 'string', nullable: true, pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1) }, - sort: { type: 'string', nullable: true, enum: ['+createdAt', '-createdAt', '+name', '-name', '+size', '-size'] }, }, required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId) + .andWhere('file.userId = :userId', { userId: user.id }); - private driveFileEntityService: DriveFileEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.driveFilesRepository.createQueryBuilder('file'), ps.sinceId, ps.untilId) - .andWhere('file.userId = :userId', { userId: me.id }); - - if (ps.folderId) { - query.andWhere('file.folderId = :folderId', { folderId: ps.folderId }); - } else { - query.andWhere('file.folderId IS NULL'); - } - - if (ps.type) { - if (ps.type.endsWith('/*')) { - query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); - } else { - query.andWhere('file.type = :type', { type: ps.type }); - } - } - - switch (ps.sort) { - case '+createdAt': query.orderBy('file.id', 'DESC'); break; - case '-createdAt': query.orderBy('file.id', 'ASC'); break; - case '+name': query.orderBy('file.name', 'DESC'); break; - case '-name': query.orderBy('file.name', 'ASC'); break; - case '+size': query.orderBy('file.size', 'DESC'); break; - case '-size': query.orderBy('file.size', 'ASC'); break; - } - - const files = await query.limit(ps.limit).getMany(); - - return await this.driveFileEntityService.packMany(files, { detail: false, self: true }); - }); + if (ps.folderId) { + query.andWhere('file.folderId = :folderId', { folderId: ps.folderId }); + } else { + query.andWhere('file.folderId IS NULL'); } -} + + if (ps.type) { + if (ps.type.endsWith('/*')) { + query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); + } else { + query.andWhere('file.type = :type', { type: ps.type }); + } + } + + const files = await query.take(ps.limit).getMany(); + + return await DriveFiles.packMany(files, { detail: false, self: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts index f45cc650d8..415a8cc693 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { NotesRepository, DriveFilesRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { DriveFiles, Notes } from '@/models/index.js'; export const meta = { tags: ['drive', 'notes'], - requireCredential: 'always', + requireCredential: true, kind: 'read:drive', @@ -42,45 +33,28 @@ export const meta = { export const paramDef = { type: 'object', properties: { - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, fileId: { type: 'string', format: 'misskey:id' }, }, required: ['fileId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Fetch file + const file = await DriveFiles.findOneBy({ + id: ps.fileId, + userId: user.id, + }); - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - private noteEntityService: NoteEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - // Fetch file - const file = await this.driveFilesRepository.findOneBy({ - id: ps.fileId, - userId: me.id, - }); - - if (file == null) { - throw new ApiError(meta.errors.noSuchFile); - } - - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId); - query.andWhere(':file = ANY(note.fileIds)', { file: file.id }); - - const notes = await query.limit(ps.limit).getMany(); - - return await this.noteEntityService.packMany(notes, me, { - detail: true, - }); - }); + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); } -} + + const notes = await Notes.createQueryBuilder('note') + .where(':file = ANY(note.fileIds)', { file: file.id }) + .getMany(); + + return await Notes.packMany(notes, user, { + detail: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/check-existence.ts b/packages/backend/src/server/api/endpoints/drive/files/check-existence.ts index 1d102b4a0a..bbae9bf4e4 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/check-existence.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/check-existence.ts @@ -1,17 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; +import { DriveFiles } from '@/models/index.js'; export const meta = { tags: ['drive'], - requireCredential: 'always', + requireCredential: true, kind: 'read:drive', @@ -31,21 +24,12 @@ export const paramDef = { required: ['md5'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const exist = await this.driveFilesRepository.exist({ - where: { - md5: ps.md5, - userId: me.id, - }, - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const file = await DriveFiles.findOneBy({ + md5: ps.md5, + userId: user.id, + }); - return exist; - }); - } -} + return file != null; +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts index 252138c467..ddcbd62889 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -1,24 +1,17 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import ms from 'ms'; -import { Injectable } from '@nestjs/common'; -import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js'; +import { addFile } from '@/services/drive/add-file.js'; +import { DriveFiles } from '@/models/index.js'; +import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { DriveService } from '@/core/DriveService.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import define from '../../../define.js'; +import { apiLogger } from '../../../logger.js'; import { ApiError } from '../../../error.js'; export const meta = { tags: ['drive'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, limit: { duration: ms('1hour'), @@ -70,55 +63,49 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private driveFileEntityService: DriveFileEntityService, - private metaService: MetaService, - private driveService: DriveService, - ) { - super(meta, paramDef, async (ps, me, _, file, cleanup, ip, headers) => { - // Get 'name' parameter - let name = ps.name ?? file!.name ?? null; - if (name != null) { - name = name.trim(); - if (name.length === 0) { - name = null; - } else if (name === 'blob') { - name = null; - } else if (!this.driveFileEntityService.validateFileName(name)) { - throw new ApiError(meta.errors.invalidFileName); - } - } - - const instance = await this.metaService.fetch(); - - try { - // Create file - const driveFile = await this.driveService.addFile({ - user: me, - path: file!.path, - name, - comment: ps.comment, - folderId: ps.folderId, - force: ps.force, - sensitive: ps.isSensitive, - requestIp: instance.enableIpLogging ? ip : null, - requestHeaders: instance.enableIpLogging ? headers : null, - }); - return await this.driveFileEntityService.pack(driveFile, { self: true }); - } catch (err) { - if (err instanceof Error || typeof err === 'string') { - console.error(err); - } - if (err instanceof IdentifiableError) { - if (err.id === '282f77bf-5816-4f72-9264-aa14d8261a21') throw new ApiError(meta.errors.inappropriate); - if (err.id === 'c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6') throw new ApiError(meta.errors.noFreeSpace); - } - throw new ApiError(); - } finally { - cleanup!(); - } - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user, _, file, cleanup, ip, headers) => { + // Get 'name' parameter + let name = ps.name || file.originalname; + if (name !== undefined && name !== null) { + name = name.trim(); + if (name.length === 0) { + name = null; + } else if (name === 'blob') { + name = null; + } else if (!DriveFiles.validateFileName(name)) { + throw new ApiError(meta.errors.invalidFileName); + } + } else { + name = null; } -} + + const meta = await fetchMeta(); + + try { + // Create file + const driveFile = await addFile({ + user, + path: file.path, + name, + comment: ps.comment, + folderId: ps.folderId, + force: ps.force, + sensitive: ps.isSensitive, + requestIp: meta.enableIpLogging ? ip : null, + requestHeaders: meta.enableIpLogging ? headers : null, + }); + return await DriveFiles.pack(driveFile, { self: true }); + } catch (e) { + if (e instanceof Error || typeof e === 'string') { + apiLogger.error(e); + } + if (e instanceof IdentifiableError) { + if (e.id === '282f77bf-5816-4f72-9264-aa14d8261a21') throw new ApiError(meta.errors.inappropriate); + if (e.id === 'c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6') throw new ApiError(meta.errors.noFreeSpace); + } + throw new ApiError(); + } finally { + cleanup!(); + } +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/delete.ts b/packages/backend/src/server/api/endpoints/drive/files/delete.ts index 3871c162a9..6108ae7da9 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/delete.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/delete.ts @@ -1,21 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { DriveService } from '@/core/DriveService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; +import { deleteFile } from '@/services/drive/delete-file.js'; +import { publishDriveStream } from '@/services/stream.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { DriveFiles, Users } from '@/models/index.js'; export const meta = { tags: ['drive'], - requireCredential: 'always', + requireCredential: true, kind: 'write:drive', @@ -44,28 +36,21 @@ export const paramDef = { required: ['fileId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const file = await DriveFiles.findOneBy({ id: ps.fileId }); - private driveService: DriveService, - private roleService: RoleService, - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); - - if (file == null) { - throw new ApiError(meta.errors.noSuchFile); - } - - if (!await this.roleService.isModerator(me) && (file.userId !== me.id)) { - throw new ApiError(meta.errors.accessDenied); - } - - await this.driveService.deleteFile(file, false, me); - }); + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); } -} + + if ((!user.isAdmin && !user.isModerator) && (file.userId !== user.id)) { + throw new ApiError(meta.errors.accessDenied); + } + + // Delete + await deleteFile(file); + + // Publish fileDeleted event + publishDriveStream(user.id, 'fileDeleted', file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/find-by-hash.ts b/packages/backend/src/server/api/endpoints/drive/files/find-by-hash.ts index b68831c9e6..f2bc7348c6 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/find-by-hash.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/find-by-hash.ts @@ -1,18 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { DI } from '@/di-symbols.js'; +import { DriveFiles } from '@/models/index.js'; +import define from '../../../define.js'; export const meta = { tags: ['drive'], - requireCredential: 'always', + requireCredential: true, kind: 'read:drive', @@ -37,21 +29,12 @@ export const paramDef = { required: ['md5'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const files = await DriveFiles.findBy({ + md5: ps.md5, + userId: user.id, + }); - private driveFileEntityService: DriveFileEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const files = await this.driveFilesRepository.findBy({ - md5: ps.md5, - userId: me.id, - }); - - return await this.driveFileEntityService.packMany(files, { self: true }); - }); - } -} + return await DriveFiles.packMany(files, { self: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/find.ts b/packages/backend/src/server/api/endpoints/drive/files/find.ts index 97aa4609ca..245fb45a65 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/find.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/find.ts @@ -1,17 +1,9 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; +import define from '../../../define.js'; +import { DriveFiles } from '@/models/index.js'; import { IsNull } from 'typeorm'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { DI } from '@/di-symbols.js'; export const meta = { - requireCredential: 'always', + requireCredential: true, tags: ['drive'], @@ -39,22 +31,13 @@ export const paramDef = { required: ['name'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const files = await DriveFiles.findBy({ + name: ps.name, + userId: user.id, + folderId: ps.folderId ?? IsNull(), + }); - private driveFileEntityService: DriveFileEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const files = await this.driveFilesRepository.findBy({ - name: ps.name, - userId: me.id, - folderId: ps.folderId ?? IsNull(), - }); - - return await Promise.all(files.map(file => this.driveFileEntityService.pack(file, { self: true }))); - }); - } -} + return await Promise.all(files.map(file => DriveFiles.pack(file, { self: true }))); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/show.ts b/packages/backend/src/server/api/endpoints/drive/files/show.ts index d4b6fb0810..2c604c54c8 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/show.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/show.ts @@ -1,21 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { DriveFiles, Users } from '@/models/index.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; export const meta = { tags: ['drive'], - requireCredential: 'always', + requireCredential: true, kind: 'read:drive', @@ -44,55 +35,51 @@ export const meta = { export const paramDef = { type: 'object', - properties: { - fileId: { type: 'string', format: 'misskey:id' }, - url: { type: 'string' }, - }, anyOf: [ - { required: ['fileId'] }, - { required: ['url'] }, + { + properties: { + fileId: { type: 'string', format: 'misskey:id' }, + }, + required: ['fileId'], + }, + { + properties: { + url: { type: 'string' }, + }, + required: ['url'], + }, ], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + let file: DriveFile | null = null; - private driveFileEntityService: DriveFileEntityService, - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - let file: MiDriveFile | null = null; - - if (ps.fileId) { - file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); - } else if (ps.url) { - file = await this.driveFilesRepository.findOne({ - where: [{ - url: ps.url, - }, { - webpublicUrl: ps.url, - }, { - thumbnailUrl: ps.url, - }], - }); - } - - if (file == null) { - throw new ApiError(meta.errors.noSuchFile); - } - - if (!await this.roleService.isModerator(me) && (file.userId !== me.id)) { - throw new ApiError(meta.errors.accessDenied); - } - - return await this.driveFileEntityService.pack(file, { - detail: true, - withUser: true, - self: true, - }); + if (ps.fileId) { + file = await DriveFiles.findOneBy({ id: ps.fileId }); + } else if (ps.url) { + file = await DriveFiles.findOne({ + where: [{ + url: ps.url, + }, { + webpublicUrl: ps.url, + }, { + thumbnailUrl: ps.url, + }], }); } -} + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + + if ((!user.isAdmin && !user.isModerator) && (file.userId !== user.id)) { + throw new ApiError(meta.errors.accessDenied); + } + + return await DriveFiles.pack(file, { + detail: true, + withUser: true, + self: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/update.ts b/packages/backend/src/server/api/endpoints/drive/files/update.ts index e2a5a9671d..fa2ec8519c 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/update.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/update.ts @@ -1,21 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; -import { DriveService } from '@/core/DriveService.js'; +import { publishDriveStream } from '@/services/stream.js'; +import { DriveFiles, DriveFolders, Users } from '@/models/index.js'; +import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; -import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js'; export const meta = { tags: ['drive'], - requireCredential: 'always', + requireCredential: true, kind: 'write:drive', @@ -45,13 +37,8 @@ export const meta = { code: 'NO_SUCH_FOLDER', id: 'ea8fb7a5-af77-4a08-b608-c0218176cd73', }, - - restrictedByRole: { - message: 'This feature is restricted by your role.', - code: 'RESTRICTED_BY_ROLE', - id: '7f59dccb-f465-75ab-5cf4-3ce44e3282f7', - }, }, + res: { type: 'object', optional: false, nullable: false, @@ -66,52 +53,60 @@ export const paramDef = { folderId: { type: 'string', format: 'misskey:id', nullable: true }, name: { type: 'string' }, isSensitive: { type: 'boolean' }, - comment: { type: 'string', nullable: true, maxLength: DB_MAX_IMAGE_COMMENT_LENGTH }, + comment: { type: 'string', nullable: true, maxLength: 512 }, }, required: ['fileId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const file = await DriveFiles.findOneBy({ id: ps.fileId }); - private driveService: DriveService, - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); - if (file == null) { - throw new ApiError(meta.errors.noSuchFile); - } - - if (!await this.roleService.isModerator(me) && (file.userId !== me.id)) { - throw new ApiError(meta.errors.accessDenied); - } - - let packedFile; - - try { - packedFile = await this.driveService.updateFile(file, { - folderId: ps.folderId, - name: ps.name, - isSensitive: ps.isSensitive, - comment: ps.comment, - }, me); - } catch (e) { - if (e instanceof DriveService.InvalidFileNameError) { - throw new ApiError(meta.errors.invalidFileName); - } else if (e instanceof DriveService.NoSuchFolderError) { - throw new ApiError(meta.errors.noSuchFolder); - } else if (e instanceof DriveService.CannotUnmarkSensitiveError) { - throw new ApiError(meta.errors.restrictedByRole); - } else { - throw e; - } - } - - return packedFile; - }); + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); } -} + + if ((!user.isAdmin && !user.isModerator) && (file.userId !== user.id)) { + throw new ApiError(meta.errors.accessDenied); + } + + if (ps.name) file.name = ps.name; + if (!DriveFiles.validateFileName(file.name)) { + throw new ApiError(meta.errors.invalidFileName); + } + + if (ps.comment !== undefined) file.comment = ps.comment; + + if (ps.isSensitive !== undefined) file.isSensitive = ps.isSensitive; + + if (ps.folderId !== undefined) { + if (ps.folderId === null) { + file.folderId = null; + } else { + const folder = await DriveFolders.findOneBy({ + id: ps.folderId, + userId: user.id, + }); + + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + + file.folderId = folder.id; + } + } + + await DriveFiles.update(file.id, { + name: file.name, + comment: file.comment, + folderId: file.folderId, + isSensitive: file.isSensitive, + }); + + const fileObj = await DriveFiles.pack(file, { self: true }); + + // Publish fileUpdated event + publishDriveStream(user.id, 'fileUpdated', fileObj); + + return fileObj; +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts index 172f5194e7..eb8071c3c9 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts @@ -1,15 +1,9 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import ms from 'ms'; -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { DriveService } from '@/core/DriveService.js'; -import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js'; +import { uploadFromUrl } from '@/services/drive/upload-from-url.js'; +import { DriveFiles } from '@/models/index.js'; +import { publishMainStream } from '@/services/stream.js'; +import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; +import define from '../../../define.js'; export const meta = { tags: ['drive'], @@ -21,9 +15,7 @@ export const meta = { description: 'Request the server to download a new drive file from the specified URL.', - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:drive', } as const; @@ -34,29 +26,21 @@ export const paramDef = { url: { type: 'string' }, folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, isSensitive: { type: 'boolean', default: false }, - comment: { type: 'string', nullable: true, maxLength: DB_MAX_IMAGE_COMMENT_LENGTH, default: null }, + comment: { type: 'string', nullable: true, maxLength: 512, default: null }, marker: { type: 'string', nullable: true, default: null }, force: { type: 'boolean', default: false }, }, required: ['url'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private driveFileEntityService: DriveFileEntityService, - private driveService: DriveService, - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, user, _1, _2, _3, ip, headers) => { - this.driveService.uploadFromUrl({ url: ps.url, user, folderId: ps.folderId, sensitive: ps.isSensitive, force: ps.force, comment: ps.comment, requestIp: ip, requestHeaders: headers }).then(file => { - this.driveFileEntityService.pack(file, { self: true }).then(packedFile => { - this.globalEventService.publishMainStream(user.id, 'urlUploadFinished', { - marker: ps.marker, - file: packedFile, - }); - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user, _1, _2, _3, ip, headers) => { + uploadFromUrl({ url: ps.url, user, folderId: ps.folderId, sensitive: ps.isSensitive, force: ps.force, comment: ps.comment, requestIp: ip, requestHeaders: headers }).then(file => { + DriveFiles.pack(file, { self: true }).then(packedFile => { + publishMainStream(user.id, 'urlUploadFinished', { + marker: ps.marker, + file: packedFile, }); }); - } -} + }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders.ts b/packages/backend/src/server/api/endpoints/drive/folders.ts index 669f29e3b8..d4d530ba9e 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFoldersRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { DriveFolders } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['drive'], - requireCredential: 'always', + requireCredential: true, kind: 'read:drive', @@ -39,28 +31,18 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFoldersRepository) - private driveFoldersRepository: DriveFoldersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery(DriveFolders.createQueryBuilder('folder'), ps.sinceId, ps.untilId) + .andWhere('folder.userId = :userId', { userId: user.id }); - private driveFolderEntityService: DriveFolderEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.driveFoldersRepository.createQueryBuilder('folder'), ps.sinceId, ps.untilId) - .andWhere('folder.userId = :userId', { userId: me.id }); - - if (ps.folderId) { - query.andWhere('folder.parentId = :parentId', { parentId: ps.folderId }); - } else { - query.andWhere('folder.parentId IS NULL'); - } - - const folders = await query.limit(ps.limit).getMany(); - - return await Promise.all(folders.map(folder => this.driveFolderEntityService.pack(folder))); - }); + if (ps.folderId) { + query.andWhere('folder.parentId = :parentId', { parentId: ps.folderId }); + } else { + query.andWhere('folder.parentId IS NULL'); } -} + + const folders = await query.take(ps.limit).getMany(); + + return await Promise.all(folders.map(folder => DriveFolders.pack(folder))); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/create.ts b/packages/backend/src/server/api/endpoints/drive/folders/create.ts index fb136136de..3d7f514c85 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders/create.ts @@ -1,30 +1,16 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import ms from 'ms'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFoldersRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; +import { publishDriveStream } from '@/services/stream.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { DriveFolders } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; export const meta = { tags: ['drive'], - requireCredential: 'always', + requireCredential: true, kind: 'write:drive', - limit: { - duration: ms('1hour'), - max: 10, - }, - errors: { noSuchFolder: { message: 'No such folder.', @@ -43,51 +29,41 @@ export const meta = { export const paramDef = { type: 'object', properties: { - name: { type: 'string', default: 'Untitled', maxLength: 200 }, + name: { type: 'string', default: "Untitled", maxLength: 200 }, parentId: { type: 'string', format: 'misskey:id', nullable: true }, }, required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFoldersRepository) - private driveFoldersRepository: DriveFoldersRepository, - - private driveFolderEntityService: DriveFolderEntityService, - private idService: IdService, - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - // If the parent folder is specified - let parent = null; - if (ps.parentId) { - // Fetch parent folder - parent = await this.driveFoldersRepository.findOneBy({ - id: ps.parentId, - userId: me.id, - }); - - if (parent == null) { - throw new ApiError(meta.errors.noSuchFolder); - } - } - - // Create folder - const folder = await this.driveFoldersRepository.insert({ - id: this.idService.gen(), - name: ps.name, - parentId: parent !== null ? parent.id : null, - userId: me.id, - }).then(x => this.driveFoldersRepository.findOneByOrFail(x.identifiers[0])); - - const folderObj = await this.driveFolderEntityService.pack(folder); - - // Publish folderCreated event - this.globalEventService.publishDriveStream(me.id, 'folderCreated', folderObj); - - return folderObj; +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // If the parent folder is specified + let parent = null; + if (ps.parentId) { + // Fetch parent folder + parent = await DriveFolders.findOneBy({ + id: ps.parentId, + userId: user.id, }); + + if (parent == null) { + throw new ApiError(meta.errors.noSuchFolder); + } } -} + + // Create folder + const folder = await DriveFolders.insert({ + id: genId(), + createdAt: new Date(), + name: ps.name, + parentId: parent !== null ? parent.id : null, + userId: user.id, + }).then(x => DriveFolders.findOneByOrFail(x.identifiers[0])); + + const folderObj = await DriveFolders.pack(folder); + + // Publish folderCreated event + publishDriveStream(user.id, 'folderCreated', folderObj); + + return folderObj; +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/delete.ts b/packages/backend/src/server/api/endpoints/drive/folders/delete.ts index aba44dd227..ab9d411ec0 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders/delete.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders/delete.ts @@ -1,19 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFoldersRepository, DriveFilesRepository } from '@/models/_.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; +import { publishDriveStream } from '@/services/stream.js'; import { ApiError } from '../../../error.js'; +import { DriveFolders, DriveFiles } from '@/models/index.js'; export const meta = { tags: ['drive'], - requireCredential: 'always', + requireCredential: true, kind: 'write:drive', @@ -40,41 +33,29 @@ export const paramDef = { required: ['folderId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Get folder + const folder = await DriveFolders.findOneBy({ + id: ps.folderId, + userId: user.id, + }); - @Inject(DI.driveFoldersRepository) - private driveFoldersRepository: DriveFoldersRepository, - - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - // Get folder - const folder = await this.driveFoldersRepository.findOneBy({ - id: ps.folderId, - userId: me.id, - }); - - if (folder == null) { - throw new ApiError(meta.errors.noSuchFolder); - } - - const [childFoldersCount, childFilesCount] = await Promise.all([ - this.driveFoldersRepository.countBy({ parentId: folder.id }), - this.driveFilesRepository.countBy({ folderId: folder.id }), - ]); - - if (childFoldersCount !== 0 || childFilesCount !== 0) { - throw new ApiError(meta.errors.hasChildFilesOrFolders); - } - - await this.driveFoldersRepository.delete(folder.id); - - // Publish folderCreated event - this.globalEventService.publishDriveStream(me.id, 'folderDeleted', folder.id); - }); + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); } -} + + const [childFoldersCount, childFilesCount] = await Promise.all([ + DriveFolders.countBy({ parentId: folder.id }), + DriveFiles.countBy({ folderId: folder.id }), + ]); + + if (childFoldersCount !== 0 || childFilesCount !== 0) { + throw new ApiError(meta.errors.hasChildFilesOrFolders); + } + + await DriveFolders.delete(folder.id); + + // Publish folderCreated event + publishDriveStream(user.id, 'folderDeleted', folder.id); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/find.ts b/packages/backend/src/server/api/endpoints/drive/folders/find.ts index a0eb7cd26f..1feab273a1 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders/find.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders/find.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; +import define from '../../../define.js'; +import { DriveFolders } from '@/models/index.js'; import { IsNull } from 'typeorm'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFoldersRepository } from '@/models/_.js'; -import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js'; -import { DI } from '@/di-symbols.js'; export const meta = { tags: ['drive'], - requireCredential: 'always', + requireCredential: true, kind: 'read:drive', @@ -37,22 +29,13 @@ export const paramDef = { required: ['name'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFoldersRepository) - private driveFoldersRepository: DriveFoldersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const folders = await DriveFolders.findBy({ + name: ps.name, + userId: user.id, + parentId: ps.parentId ?? IsNull(), + }); - private driveFolderEntityService: DriveFolderEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const folders = await this.driveFoldersRepository.findBy({ - name: ps.name, - userId: me.id, - parentId: ps.parentId ?? IsNull(), - }); - - return await Promise.all(folders.map(folder => this.driveFolderEntityService.pack(folder))); - }); - } -} + return await Promise.all(folders.map(folder => DriveFolders.pack(folder))); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/show.ts b/packages/backend/src/server/api/endpoints/drive/folders/show.ts index e280e15afa..1e7aa2b16c 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders/show.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders/show.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFoldersRepository } from '@/models/_.js'; -import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { DriveFolders } from '@/models/index.js'; export const meta = { tags: ['drive'], - requireCredential: 'always', + requireCredential: true, kind: 'read:drive', @@ -40,28 +32,19 @@ export const paramDef = { required: ['folderId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFoldersRepository) - private driveFoldersRepository: DriveFoldersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Get folder + const folder = await DriveFolders.findOneBy({ + id: ps.folderId, + userId: user.id, + }); - private driveFolderEntityService: DriveFolderEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - // Get folder - const folder = await this.driveFoldersRepository.findOneBy({ - id: ps.folderId, - userId: me.id, - }); - - if (folder == null) { - throw new ApiError(meta.errors.noSuchFolder); - } - - return await this.driveFolderEntityService.pack(folder, { - detail: true, - }); - }); + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); } -} + + return await DriveFolders.pack(folder, { + detail: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/update.ts b/packages/backend/src/server/api/endpoints/drive/folders/update.ts index a4137a03c3..1aa2e84292 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders/update.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders/update.ts @@ -1,20 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFoldersRepository } from '@/models/_.js'; -import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; +import { publishDriveStream } from '@/services/stream.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { DriveFolders } from '@/models/index.js'; export const meta = { tags: ['drive'], - requireCredential: 'always', + requireCredential: true, kind: 'write:drive', @@ -33,8 +25,8 @@ export const meta = { recursiveNesting: { message: 'It can not be structured like nesting folders recursively.', - code: 'RECURSIVE_NESTING', - id: 'dbeb024837894013aed44279f9199740', + code: 'NO_SUCH_PARENT_FOLDER', + id: 'ce104e3a-faaf-49d5-b459-10ff0cbbcaa1', }, }, @@ -55,82 +47,72 @@ export const paramDef = { required: ['folderId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFoldersRepository) - private driveFoldersRepository: DriveFoldersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Fetch folder + const folder = await DriveFolders.findOneBy({ + id: ps.folderId, + userId: user.id, + }); - private driveFolderEntityService: DriveFolderEntityService, - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - // Fetch folder - const folder = await this.driveFoldersRepository.findOneBy({ - id: ps.folderId, - userId: me.id, + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + + if (ps.name) folder.name = ps.name; + + if (ps.parentId !== undefined) { + if (ps.parentId === folder.id) { + throw new ApiError(meta.errors.recursiveNesting); + } else if (ps.parentId === null) { + folder.parentId = null; + } else { + // Get parent folder + const parent = await DriveFolders.findOneBy({ + id: ps.parentId, + userId: user.id, }); - if (folder == null) { - throw new ApiError(meta.errors.noSuchFolder); + if (parent == null) { + throw new ApiError(meta.errors.noSuchParentFolder); } - if (ps.name) folder.name = ps.name; + // Check if the circular reference will occur + async function checkCircle(folderId: string): Promise { + // Fetch folder + const folder2 = await DriveFolders.findOneBy({ + id: folderId, + }); - if (ps.parentId !== undefined) { - if (ps.parentId === folder.id) { - throw new ApiError(meta.errors.recursiveNesting); - } else if (ps.parentId === null) { - folder.parentId = null; + if (folder2!.id === folder!.id) { + return true; + } else if (folder2!.parentId) { + return await checkCircle(folder2!.parentId); } else { - // Get parent folder - const parent = await this.driveFoldersRepository.findOneBy({ - id: ps.parentId, - userId: me.id, - }); - - if (parent == null) { - throw new ApiError(meta.errors.noSuchParentFolder); - } - - // Check if the circular reference will occur - const checkCircle = async (folderId: string): Promise => { - // Fetch folder - const folder2 = await this.driveFoldersRepository.findOneBy({ - id: folderId, - }); - - if (folder2!.id === folder!.id) { - return true; - } else if (folder2!.parentId) { - return await checkCircle(folder2!.parentId); - } else { - return false; - } - }; - - if (parent.parentId !== null) { - if (await checkCircle(parent.parentId)) { - throw new ApiError(meta.errors.recursiveNesting); - } - } - - folder.parentId = parent.id; + return false; } } - // Update - this.driveFoldersRepository.update(folder.id, { - name: folder.name, - parentId: folder.parentId, - }); + if (parent.parentId !== null) { + if (await checkCircle(parent.parentId)) { + throw new ApiError(meta.errors.recursiveNesting); + } + } - const folderObj = await this.driveFolderEntityService.pack(folder); - - // Publish folderUpdated event - this.globalEventService.publishDriveStream(me.id, 'folderUpdated', folderObj); - - return folderObj; - }); + folder.parentId = parent.id; + } } -} + + // Update + DriveFolders.update(folder.id, { + name: folder.name, + parentId: folder.parentId, + }); + + const folderObj = await DriveFolders.pack(folder); + + // Publish folderUpdated event + publishDriveStream(user.id, 'folderUpdated', folderObj); + + return folderObj; +}); diff --git a/packages/backend/src/server/api/endpoints/drive/stream.ts b/packages/backend/src/server/api/endpoints/drive/stream.ts index d15973548c..99e8d024fb 100644 --- a/packages/backend/src/server/api/endpoints/drive/stream.ts +++ b/packages/backend/src/server/api/endpoints/drive/stream.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { DriveFiles } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['drive'], - requireCredential: 'always', + requireCredential: true, kind: 'read:drive', @@ -39,30 +31,20 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId) + .andWhere('file.userId = :userId', { userId: user.id }); - private driveFileEntityService: DriveFileEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.driveFilesRepository.createQueryBuilder('file'), ps.sinceId, ps.untilId) - .andWhere('file.userId = :userId', { userId: me.id }); - - if (ps.type) { - if (ps.type.endsWith('/*')) { - query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); - } else { - query.andWhere('file.type = :type', { type: ps.type }); - } - } - - const files = await query.limit(ps.limit).getMany(); - - return await this.driveFileEntityService.packMany(files, { detail: false, self: true }); - }); + if (ps.type) { + if (ps.type.endsWith('/*')) { + query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); + } else { + query.andWhere('file.type = :type', { type: ps.type }); + } } -} + + const files = await query.take(ps.limit).getMany(); + + return await DriveFiles.packMany(files, { detail: false, self: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/email-address/available.ts b/packages/backend/src/server/api/endpoints/email-address/available.ts index 4c54f4dd85..07064ce9fa 100644 --- a/packages/backend/src/server/api/endpoints/email-address/available.ts +++ b/packages/backend/src/server/api/endpoints/email-address/available.ts @@ -1,16 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { EmailService } from '@/core/EmailService.js'; +import define from '../../define.js'; +import { validateEmailForAccount } from '@/services/validate-email-for-account.js'; export const meta = { tags: ['users'], - requireCredential: 'never', + requireCredential: false, res: { type: 'object', @@ -36,13 +30,7 @@ export const paramDef = { required: ['emailAddress'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private emailService: EmailService, - ) { - super(meta, paramDef, async (ps, me) => { - return await this.emailService.validateEmailForAccount(ps.emailAddress); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + return await validateEmailForAccount(ps.emailAddress); +}); diff --git a/packages/backend/src/server/api/endpoints/emoji.ts b/packages/backend/src/server/api/endpoints/emoji.ts deleted file mode 100644 index 9ce0d755c4..0000000000 --- a/packages/backend/src/server/api/endpoints/emoji.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { IsNull } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { EmojisRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['meta'], - - requireCredential: 'never', - allowGet: true, - cacheSec: 3600, - - res: { - type: 'object', - optional: false, nullable: false, - ref: 'EmojiDetailed', - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - name: { - type: 'string', - }, - }, - required: ['name'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private emojiEntityService: EmojiEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const emoji = await this.emojisRepository.findOneOrFail({ - where: { - name: ps.name, - host: IsNull(), - }, - }); - - return this.emojiEntityService.packDetailed(emoji); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/emojis.ts b/packages/backend/src/server/api/endpoints/emojis.ts deleted file mode 100644 index 6b22d90d61..0000000000 --- a/packages/backend/src/server/api/endpoints/emojis.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { IsNull } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { EmojisRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['meta'], - - requireCredential: 'never', - allowGet: true, - cacheSec: 3600, - - res: { - type: 'object', - optional: false, nullable: false, - properties: { - emojis: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'EmojiSimple', - }, - }, - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - }, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private emojiEntityService: EmojiEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const emojis = await this.emojisRepository.find({ - where: { - host: IsNull(), - }, - order: { - category: 'ASC', - name: 'ASC', - }, - }); - - return { - emojis: await this.emojiEntityService.packSimpleMany(emojis), - }; - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/endpoint.ts b/packages/backend/src/server/api/endpoints/endpoint.ts index 1eef83f7ab..c174126779 100644 --- a/packages/backend/src/server/api/endpoints/endpoint.ts +++ b/packages/backend/src/server/api/endpoints/endpoint.ts @@ -1,33 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; +import define from '../define.js'; import endpoints from '../endpoints.js'; export const meta = { - requireCredential: 'never', + requireCredential: false, tags: ['meta'], - - res: { - type: 'object', - nullable: true, - properties: { - params: { - type: 'array', - items: { - type: 'object', - properties: { - name: { type: 'string' }, - type: { type: 'string' }, - }, - }, - }, - }, - }, } as const; export const paramDef = { @@ -38,19 +15,14 @@ export const paramDef = { required: ['endpoint'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - ) { - super(meta, paramDef, async (ps) => { - const ep = endpoints.find(x => x.name === ps.endpoint); - if (ep == null) return null; - return { - params: Object.entries(ep.params.properties ?? {}).map(([k, v]) => ({ - name: k, - type: v.type ? v.type.charAt(0).toUpperCase() + v.type.slice(1) : 'string', - })), - }; - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const ep = endpoints.find(x => x.name === ps.endpoint); + if (ep == null) return null; + return { + params: Object.entries(ep.params.properties || {}).map(([k, v]) => ({ + name: k, + type: v.type.charAt(0).toUpperCase() + v.type.slice(1), + })), + }; +}); diff --git a/packages/backend/src/server/api/endpoints/endpoints.ts b/packages/backend/src/server/api/endpoints/endpoints.ts index 3cbd4b92dc..b20da96eb3 100644 --- a/packages/backend/src/server/api/endpoints/endpoints.ts +++ b/packages/backend/src/server/api/endpoints/endpoints.ts @@ -1,14 +1,8 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; +import define from '../define.js'; import endpoints from '../endpoints.js'; export const meta = { - requireCredential: 'never', + requireCredential: false, tags: ['meta'], @@ -34,12 +28,7 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - ) { - super(meta, paramDef, async () => { - return endpoints.map(x => x.name); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async () => { + return endpoints.map(x => x.name); +}); diff --git a/packages/backend/src/server/api/endpoints/export-custom-emojis.ts b/packages/backend/src/server/api/endpoints/export-custom-emojis.ts index c5b17f9b31..5fe622932d 100644 --- a/packages/backend/src/server/api/endpoints/export-custom-emojis.ts +++ b/packages/backend/src/server/api/endpoints/export-custom-emojis.ts @@ -1,16 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import ms from 'ms'; -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueueService } from '@/core/QueueService.js'; +import { createExportCustomEmojisJob } from '@/queue/index.js'; +import define from '../define.js'; export const meta = { secure: true, - requireCredential: 'always', + requireCredential: true, limit: { duration: ms('1hour'), max: 1, @@ -23,13 +17,7 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private queueService: QueueService, - ) { - super(meta, paramDef, async (ps, me) => { - this.queueService.createExportCustomEmojisJob(me); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + createExportCustomEmojisJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/followers.ts b/packages/backend/src/server/api/endpoints/federation/followers.ts index 6c9af68dc0..7b1197d1e5 100644 --- a/packages/backend/src/server/api/endpoints/federation/followers.ts +++ b/packages/backend/src/server/api/endpoints/federation/followers.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { FollowingsRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { Followings } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['federation'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'array', @@ -37,24 +29,14 @@ export const paramDef = { required: ['host'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) + .andWhere(`following.followeeHost = :host`, { host: ps.host }); - private followingEntityService: FollowingEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId) - .andWhere('following.followeeHost = :host', { host: ps.host }); + const followings = await query + .take(ps.limit) + .getMany(); - const followings = await query - .limit(ps.limit) - .getMany(); - - return await this.followingEntityService.packMany(followings, me, { populateFollowee: true }); - }); - } -} + return await Followings.packMany(followings, me, { populateFollowee: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/following.ts b/packages/backend/src/server/api/endpoints/federation/following.ts index 865d9c886f..ed1f142d88 100644 --- a/packages/backend/src/server/api/endpoints/federation/following.ts +++ b/packages/backend/src/server/api/endpoints/federation/following.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { FollowingsRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { Followings } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['federation'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'array', @@ -37,24 +29,14 @@ export const paramDef = { required: ['host'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) + .andWhere(`following.followerHost = :host`, { host: ps.host }); - private followingEntityService: FollowingEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId) - .andWhere('following.followerHost = :host', { host: ps.host }); + const followings = await query + .take(ps.limit) + .getMany(); - const followings = await query - .limit(ps.limit) - .getMany(); - - return await this.followingEntityService.packMany(followings, me, { populateFollowee: true }); - }); - } -} + return await Followings.packMany(followings, me, { populateFollowee: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts index e2b59040d0..07e5c07c6a 100644 --- a/packages/backend/src/server/api/endpoints/federation/instances.ts +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -1,22 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { InstancesRepository } from '@/models/_.js'; -import { InstanceEntityService } from '@/core/entities/InstanceEntityService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { DI } from '@/di-symbols.js'; -import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import config from '@/config/index.js'; +import define from '../../define.js'; +import { Instances } from '@/models/index.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; export const meta = { tags: ['federation'], - requireCredential: 'never', - allowGet: true, - cacheSec: 3600, + requireCredential: false, res: { type: 'array', @@ -34,161 +24,95 @@ export const paramDef = { properties: { host: { type: 'string', nullable: true, description: 'Omit or use `null` to not filter by host.' }, blocked: { type: 'boolean', nullable: true }, - allowed: { type: 'boolean', nullable: true }, notResponding: { type: 'boolean', nullable: true }, suspended: { type: 'boolean', nullable: true }, - silenced: { type: 'boolean', nullable: true }, federating: { type: 'boolean', nullable: true }, subscribing: { type: 'boolean', nullable: true }, publishing: { type: 'boolean', nullable: true }, - nsfw: { type: 'boolean', nullable: true }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, offset: { type: 'integer', default: 0 }, - sort: { - type: 'string', - nullable: true, - enum: [ - '+pubSub', - '-pubSub', - '+notes', - '-notes', - '+users', - '-users', - '+following', - '-following', - '+followers', - '-followers', - '+firstRetrievedAt', - '-firstRetrievedAt', - '+latestRequestReceivedAt', - '-latestRequestReceivedAt', - ], - }, + sort: { type: 'string' }, }, required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = Instances.createQueryBuilder('instance'); - private instanceEntityService: InstanceEntityService, - private metaService: MetaService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.instancesRepository.createQueryBuilder('instance'); + switch (ps.sort) { + case '+pubSub': query.orderBy('instance.followingCount', 'DESC').orderBy('instance.followersCount', 'DESC'); break; + case '-pubSub': query.orderBy('instance.followingCount', 'ASC').orderBy('instance.followersCount', 'ASC'); break; + case '+notes': query.orderBy('instance.notesCount', 'DESC'); break; + case '-notes': query.orderBy('instance.notesCount', 'ASC'); break; + case '+users': query.orderBy('instance.usersCount', 'DESC'); break; + case '-users': query.orderBy('instance.usersCount', 'ASC'); break; + case '+following': query.orderBy('instance.followingCount', 'DESC'); break; + case '-following': query.orderBy('instance.followingCount', 'ASC'); break; + case '+followers': query.orderBy('instance.followersCount', 'DESC'); break; + case '-followers': query.orderBy('instance.followersCount', 'ASC'); break; + case '+caughtAt': query.orderBy('instance.caughtAt', 'DESC'); break; + case '-caughtAt': query.orderBy('instance.caughtAt', 'ASC'); break; + case '+lastCommunicatedAt': query.orderBy('instance.lastCommunicatedAt', 'DESC'); break; + case '-lastCommunicatedAt': query.orderBy('instance.lastCommunicatedAt', 'ASC'); break; - switch (ps.sort) { - case '+pubSub': query.orderBy('instance.followingCount', 'DESC').orderBy('instance.followersCount', 'DESC'); break; - case '-pubSub': query.orderBy('instance.followingCount', 'ASC').orderBy('instance.followersCount', 'ASC'); break; - case '+notes': query.orderBy('instance.notesCount', 'DESC'); break; - case '-notes': query.orderBy('instance.notesCount', 'ASC'); break; - case '+users': query.orderBy('instance.usersCount', 'DESC'); break; - case '-users': query.orderBy('instance.usersCount', 'ASC'); break; - case '+following': query.orderBy('instance.followingCount', 'DESC'); break; - case '-following': query.orderBy('instance.followingCount', 'ASC'); break; - case '+followers': query.orderBy('instance.followersCount', 'DESC'); break; - case '-followers': query.orderBy('instance.followersCount', 'ASC'); break; - case '+firstRetrievedAt': query.orderBy('instance.firstRetrievedAt', 'DESC'); break; - case '-firstRetrievedAt': query.orderBy('instance.firstRetrievedAt', 'ASC'); break; - case '+latestRequestReceivedAt': query.orderBy('instance.latestRequestReceivedAt', 'DESC', 'NULLS LAST'); break; - case '-latestRequestReceivedAt': query.orderBy('instance.latestRequestReceivedAt', 'ASC', 'NULLS FIRST'); break; - - default: query.orderBy('instance.id', 'DESC'); break; - } - - if (typeof ps.blocked === 'boolean') { - const meta = await this.metaService.fetch(true); - if (ps.blocked) { - query.andWhere(meta.blockedHosts.length === 0 ? '1=0' : 'instance.host IN (:...blocks)', { blocks: meta.blockedHosts }); - } else { - query.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...blocks)', { blocks: meta.blockedHosts }); - } - } - - if (typeof ps.allowed === 'boolean') { - const meta = await this.metaService.fetch(true); - if (ps.allowed) { - query.andWhere(meta.allowedHosts.length === 0 ? '1=0' : 'instance.host IN (:...allows)', { allows: meta.allowedHosts }); - } else { - query.andWhere(meta.allowedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...allows)', { allows: meta.allowedHosts }); - } - } - - if (typeof ps.notResponding === 'boolean') { - if (ps.notResponding) { - query.andWhere('instance.isNotResponding = TRUE'); - } else { - query.andWhere('instance.isNotResponding = FALSE'); - } - } - - if (typeof ps.suspended === 'boolean') { - if (ps.suspended) { - query.andWhere('instance.isSuspended = TRUE'); - } else { - query.andWhere('instance.isSuspended = FALSE'); - } - } - - if (typeof ps.nsfw === 'boolean') { - if (ps.nsfw) { - query.andWhere('instance.isNSFW = TRUE'); - } else { - query.andWhere('instance.isNSFW = FALSE'); - } - } - - if (typeof ps.silenced === 'boolean') { - const meta = await this.metaService.fetch(true); - - if (ps.silenced) { - if (meta.silencedHosts.length === 0) { - return []; - } - query.andWhere('instance.host IN (:...silences)', { - silences: meta.silencedHosts, - }); - } else if (meta.silencedHosts.length > 0) { - query.andWhere('instance.host NOT IN (:...silences)', { - silences: meta.silencedHosts, - }); - } - } - - if (typeof ps.federating === 'boolean') { - if (ps.federating) { - query.andWhere('((instance.followingCount > 0) OR (instance.followersCount > 0))'); - } else { - query.andWhere('((instance.followingCount = 0) AND (instance.followersCount = 0))'); - } - } - - if (typeof ps.subscribing === 'boolean') { - if (ps.subscribing) { - query.andWhere('instance.followersCount > 0'); - } else { - query.andWhere('instance.followersCount = 0'); - } - } - - if (typeof ps.publishing === 'boolean') { - if (ps.publishing) { - query.andWhere('instance.followingCount > 0'); - } else { - query.andWhere('instance.followingCount = 0'); - } - } - - if (ps.host) { - query.andWhere('instance.host like :host', { host: '%' + sqlLikeEscape(ps.host.toLowerCase()) + '%' }); - } - - const instances = await query.limit(ps.limit).offset(ps.offset).getMany(); - - return await this.instanceEntityService.packMany(instances); - }); + default: query.orderBy('instance.id', 'DESC'); break; } -} + + if (typeof ps.blocked === 'boolean') { + const meta = await fetchMeta(true); + if (ps.blocked) { + query.andWhere('instance.host IN (:...blocks)', { blocks: meta.blockedHosts }); + } else { + query.andWhere('instance.host NOT IN (:...blocks)', { blocks: meta.blockedHosts }); + } + } + + if (typeof ps.notResponding === 'boolean') { + if (ps.notResponding) { + query.andWhere('instance.isNotResponding = TRUE'); + } else { + query.andWhere('instance.isNotResponding = FALSE'); + } + } + + if (typeof ps.suspended === 'boolean') { + if (ps.suspended) { + query.andWhere('instance.isSuspended = TRUE'); + } else { + query.andWhere('instance.isSuspended = FALSE'); + } + } + + if (typeof ps.federating === 'boolean') { + if (ps.federating) { + query.andWhere('((instance.followingCount > 0) OR (instance.followersCount > 0))'); + } else { + query.andWhere('((instance.followingCount = 0) AND (instance.followersCount = 0))'); + } + } + + if (typeof ps.subscribing === 'boolean') { + if (ps.subscribing) { + query.andWhere('instance.followersCount > 0'); + } else { + query.andWhere('instance.followersCount = 0'); + } + } + + if (typeof ps.publishing === 'boolean') { + if (ps.publishing) { + query.andWhere('instance.followingCount > 0'); + } else { + query.andWhere('instance.followingCount = 0'); + } + } + + if (ps.host) { + query.andWhere('instance.host like :host', { host: '%' + ps.host.toLowerCase() + '%' }); + } + + const instances = await query.take(ps.limit).skip(ps.offset).getMany(); + + return await Instances.packMany(instances); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/show-instance.ts b/packages/backend/src/server/api/endpoints/federation/show-instance.ts index f5ac235d6d..2fbb8a15cb 100644 --- a/packages/backend/src/server/api/endpoints/federation/show-instance.ts +++ b/packages/backend/src/server/api/endpoints/federation/show-instance.ts @@ -1,24 +1,19 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { InstancesRepository } from '@/models/_.js'; -import { InstanceEntityService } from '@/core/entities/InstanceEntityService.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { Instances } from '@/models/index.js'; +import { toPuny } from '@/misc/convert-host.js'; export const meta = { tags: ['federation'], - requireCredential: 'never', + requireCredential: false, res: { - type: 'object', - optional: false, nullable: true, - ref: 'FederationInstance', + oneOf: [{ + type: 'object', + ref: 'FederationInstance', + }, { + type: 'null', + }], }, } as const; @@ -30,20 +25,10 @@ export const paramDef = { required: ['host'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const instance = await Instances + .findOneBy({ host: toPuny(ps.host) }); - private utilityService: UtilityService, - private instanceEntityService: InstanceEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const instance = await this.instancesRepository - .findOneBy({ host: this.utilityService.toPuny(ps.host) }); - - return instance ? await this.instanceEntityService.pack(instance) : null; - }); - } -} + return instance ? await Instances.pack(instance) : null; +}); diff --git a/packages/backend/src/server/api/endpoints/federation/stats.ts b/packages/backend/src/server/api/endpoints/federation/stats.ts index 7d63b9a95e..e02c7b97e0 100644 --- a/packages/backend/src/server/api/endpoints/federation/stats.ts +++ b/packages/backend/src/server/api/endpoints/federation/stats.ts @@ -1,55 +1,15 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { IsNull, MoreThan, Not } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { FollowingsRepository, InstancesRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { InstanceEntityService } from '@/core/entities/InstanceEntityService.js'; -import { DI } from '@/di-symbols.js'; +import { Followings, Instances } from '@/models/index.js'; +import { awaitAll } from '@/prelude/await-all.js'; +import define from '../../define.js'; export const meta = { tags: ['federation'], - requireCredential: 'never', + requireCredential: false, allowGet: true, cacheSec: 60 * 60, - - res: { - type: 'object', - optional: false, - nullable: false, - properties: { - topSubInstances: { - type: 'array', - optional: false, - nullable: false, - items: { - type: 'object', - optional: false, - nullable: false, - ref: 'FederationInstance', - }, - }, - otherFollowersCount: { type: 'number' }, - topPubInstances: { - type: 'array', - optional: false, - nullable: false, - items: { - type: 'object', - optional: false, - nullable: false, - ref: 'FederationInstance', - }, - }, - otherFollowingCount: { type: 'number' }, - }, - }, } as const; export const paramDef = { @@ -60,58 +20,46 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const [topSubInstances, topPubInstances, allSubCount, allPubCount] = await Promise.all([ + Instances.find({ + where: { + followersCount: MoreThan(0), + }, + order: { + followersCount: 'DESC', + }, + take: ps.limit, + }), + Instances.find({ + where: { + followingCount: MoreThan(0), + }, + order: { + followingCount: 'DESC', + }, + take: ps.limit, + }), + Followings.count({ + where: { + followeeHost: Not(IsNull()), + }, + }), + Followings.count({ + where: { + followerHost: Not(IsNull()), + }, + }), + ]); - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, + const gotSubCount = topSubInstances.map(x => x.followersCount).reduce((a, b) => a + b, 0); + const gotPubCount = topPubInstances.map(x => x.followingCount).reduce((a, b) => a + b, 0); - private instanceEntityService: InstanceEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const [topSubInstances, topPubInstances, allSubCount, allPubCount] = await Promise.all([ - this.instancesRepository.find({ - where: { - followersCount: MoreThan(0), - }, - order: { - followersCount: 'DESC', - }, - take: ps.limit, - }), - this.instancesRepository.find({ - where: { - followingCount: MoreThan(0), - }, - order: { - followingCount: 'DESC', - }, - take: ps.limit, - }), - this.followingsRepository.count({ - where: { - followeeHost: Not(IsNull()), - }, - }), - this.followingsRepository.count({ - where: { - followerHost: Not(IsNull()), - }, - }), - ]); - - const gotSubCount = topSubInstances.map(x => x.followersCount).reduce((a, b) => a + b, 0); - const gotPubCount = topPubInstances.map(x => x.followingCount).reduce((a, b) => a + b, 0); - - return await awaitAll({ - topSubInstances: this.instanceEntityService.packMany(topSubInstances), - otherFollowersCount: Math.max(0, allSubCount - gotSubCount), - topPubInstances: this.instanceEntityService.packMany(topPubInstances), - otherFollowingCount: Math.max(0, allPubCount - gotPubCount), - }); - }); - } -} + return await awaitAll({ + topSubInstances: Instances.packMany(topSubInstances), + otherFollowersCount: Math.max(0, allSubCount - gotSubCount), + topPubInstances: Instances.packMany(topPubInstances), + otherFollowingCount: Math.max(0, allPubCount - gotPubCount), + }); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts index aae3a65c93..409cc7695e 100644 --- a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts +++ b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts @@ -1,17 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; -import { GetterService } from '@/server/api/GetterService.js'; +import define from '../../define.js'; +import { getRemoteUser } from '../../common/getters.js'; +import { updatePerson } from '@/remote/activitypub/models/person.js'; export const meta = { tags: ['federation'], - requireCredential: 'conditional', + requireCredential: true, } as const; export const paramDef = { @@ -22,15 +16,8 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private getterService: GetterService, - private apPersonService: ApPersonService, - ) { - super(meta, paramDef, async (ps) => { - const user = await this.getterService.getRemoteUser(ps.userId); - await this.apPersonService.updatePerson(user.uri!); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const user = await getRemoteUser(ps.userId); + await updatePerson(user.uri!); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/users.ts b/packages/backend/src/server/api/endpoints/federation/users.ts index 50fea3c455..65ad9f88d3 100644 --- a/packages/backend/src/server/api/endpoints/federation/users.ts +++ b/packages/backend/src/server/api/endpoints/federation/users.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { Users } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['federation'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'array', @@ -37,24 +29,14 @@ export const paramDef = { required: ['host'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery(Users.createQueryBuilder('user'), ps.sinceId, ps.untilId) + .andWhere(`user.host = :host`, { host: ps.host }); - private userEntityService: UserEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.usersRepository.createQueryBuilder('user'), ps.sinceId, ps.untilId) - .andWhere('user.host = :host', { host: ps.host }); + const users = await query + .take(ps.limit) + .getMany(); - const users = await query - .limit(ps.limit) - .getMany(); - - return await this.userEntityService.packMany(users, me, { detail: true }); - }); - } -} + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/fetch-external-resources.ts b/packages/backend/src/server/api/endpoints/fetch-external-resources.ts deleted file mode 100644 index e543e0df8b..0000000000 --- a/packages/backend/src/server/api/endpoints/fetch-external-resources.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { createHash } from 'crypto'; -import ms from 'ms'; -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { ApiError } from '../error.js'; - -export const meta = { - tags: ['meta'], - - requireCredential: 'always', - secure: true, - - limit: { - duration: ms('1hour'), - max: 50, - }, - - errors: { - invalidSchema: { - message: 'External resource returned invalid schema.', - code: 'EXT_RESOURCE_RETURNED_INVALID_SCHEMA', - id: 'bb774091-7a15-4a70-9dc5-6ac8cf125856', - }, - hashUnmached: { - message: 'Hash did not match.', - code: 'EXT_RESOURCE_HASH_DIDNT_MATCH', - id: '693ba8ba-b486-40df-a174-72f8279b56a4', - }, - }, - - res: { - type: 'object', - properties: { - type: { - type: 'string', - }, - data: { - type: 'string', - }, - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - url: { type: 'string' }, - hash: { type: 'string' }, - }, - required: ['url', 'hash'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private httpRequestService: HttpRequestService, - ) { - super(meta, paramDef, async (ps) => { - const res = await this.httpRequestService.getJson<{ - type: string; - data: string; - }>(ps.url); - - if (!res.data || !res.type) { - throw new ApiError(meta.errors.invalidSchema); - } - - const resHash = createHash('sha512').update(res.data.replace(/\r\n/g, '\n')).digest('hex'); - if (resHash !== ps.hash) { - throw new ApiError(meta.errors.hashUnmached); - } - - return { - type: res.type, - data: res.data, - }; - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/fetch-rss.ts b/packages/backend/src/server/api/endpoints/fetch-rss.ts index a941b30b48..05fa22a9e4 100644 --- a/packages/backend/src/server/api/endpoints/fetch-rss.ts +++ b/packages/backend/src/server/api/endpoints/fetch-rss.ts @@ -1,33 +1,16 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import Parser from 'rss-parser'; -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { getResponse } from '@/misc/fetch.js'; +import config from '@/config/index.js'; +import define from '../define.js'; const rssParser = new Parser(); export const meta = { tags: ['meta'], - requireCredential: 'conditional', + requireCredential: false, allowGet: true, cacheSec: 60 * 3, - - res: { - type: 'object', - properties: { - items: { - type: 'array', - items: { - type: 'object', - }, - } - } - }, } as const; export const paramDef = { @@ -38,23 +21,19 @@ export const paramDef = { required: ['url'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private httpRequestService: HttpRequestService, - ) { - super(meta, paramDef, async (ps, me) => { - const res = await this.httpRequestService.send(ps.url, { - method: 'GET', - headers: { - Accept: 'application/rss+xml, */*', - }, - timeout: 5000, - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const res = await getResponse({ + url: ps.url, + method: 'GET', + headers: Object.assign({ + 'User-Agent': config.userAgent, + Accept: 'application/rss+xml, */*', + }), + timeout: 5000, + }); - const text = await res.text(); + const text = await res.text(); - return rssParser.parseString(text); - }); - } -} + return rssParser.parseString(text); +}); diff --git a/packages/backend/src/server/api/endpoints/flash/create.ts b/packages/backend/src/server/api/endpoints/flash/create.ts deleted file mode 100644 index e7f1e63fc6..0000000000 --- a/packages/backend/src/server/api/endpoints/flash/create.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import ms from 'ms'; -import { Inject, Injectable } from '@nestjs/common'; -import type { FlashsRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; - -export const meta = { - tags: ['flash'], - - requireCredential: 'always', - - prohibitMoved: true, - - kind: 'write:flash', - - limit: { - duration: ms('1hour'), - max: 10, - }, - - errors: { - }, - - res: { - type: 'object', - optional: false, nullable: false, - ref: 'Flash', - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - title: { type: 'string' }, - summary: { type: 'string' }, - script: { type: 'string' }, - permissions: { type: 'array', items: { - type: 'string', - } }, - }, - required: ['title', 'summary', 'script', 'permissions'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.flashsRepository) - private flashsRepository: FlashsRepository, - - private flashEntityService: FlashEntityService, - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const flash = await this.flashsRepository.insert({ - id: this.idService.gen(), - userId: me.id, - updatedAt: new Date(), - title: ps.title, - summary: ps.summary, - script: ps.script, - permissions: ps.permissions, - }).then(x => this.flashsRepository.findOneByOrFail(x.identifiers[0])); - - return await this.flashEntityService.pack(flash); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/flash/delete.ts b/packages/backend/src/server/api/endpoints/flash/delete.ts deleted file mode 100644 index 9873708c53..0000000000 --- a/packages/backend/src/server/api/endpoints/flash/delete.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { FlashsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['flashs'], - - requireCredential: 'always', - - kind: 'write:flash', - - errors: { - noSuchFlash: { - message: 'No such flash.', - code: 'NO_SUCH_FLASH', - id: 'de1623ef-bbb3-4289-a71e-14cfa83d9740', - }, - - accessDenied: { - message: 'Access denied.', - code: 'ACCESS_DENIED', - id: '1036ad7b-9f92-4fff-89c3-0e50dc941704', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - flashId: { type: 'string', format: 'misskey:id' }, - }, - required: ['flashId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.flashsRepository) - private flashsRepository: FlashsRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const flash = await this.flashsRepository.findOneBy({ id: ps.flashId }); - if (flash == null) { - throw new ApiError(meta.errors.noSuchFlash); - } - if (flash.userId !== me.id) { - throw new ApiError(meta.errors.accessDenied); - } - - await this.flashsRepository.delete(flash.id); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/flash/featured.ts b/packages/backend/src/server/api/endpoints/flash/featured.ts deleted file mode 100644 index 018b2ca751..0000000000 --- a/packages/backend/src/server/api/endpoints/flash/featured.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { FlashsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['flash'], - - requireCredential: 'conditional', - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'Flash', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: {}, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.flashsRepository) - private flashsRepository: FlashsRepository, - - private flashEntityService: FlashEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.flashsRepository.createQueryBuilder('flash') - .andWhere('flash.likedCount > 0') - .orderBy('flash.likedCount', 'DESC'); - - const flashs = await query.limit(10).getMany(); - - return await this.flashEntityService.packMany(flashs, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/flash/like.ts b/packages/backend/src/server/api/endpoints/flash/like.ts deleted file mode 100644 index b1027c0b94..0000000000 --- a/packages/backend/src/server/api/endpoints/flash/like.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { FlashsRepository, FlashLikesRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['flash'], - - requireCredential: 'always', - - prohibitMoved: true, - - kind: 'write:flash-likes', - - errors: { - noSuchFlash: { - message: 'No such flash.', - code: 'NO_SUCH_FLASH', - id: 'c07c1491-9161-4c5c-9d75-01906f911f73', - }, - - yourFlash: { - message: 'You cannot like your flash.', - code: 'YOUR_FLASH', - id: '3fd8a0e7-5955-4ba9-85bb-bf3e0c30e13b', - }, - - alreadyLiked: { - message: 'The flash has already been liked.', - code: 'ALREADY_LIKED', - id: '010065cf-ad43-40df-8067-abff9f4686e3', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - flashId: { type: 'string', format: 'misskey:id' }, - }, - required: ['flashId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.flashsRepository) - private flashsRepository: FlashsRepository, - - @Inject(DI.flashLikesRepository) - private flashLikesRepository: FlashLikesRepository, - - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const flash = await this.flashsRepository.findOneBy({ id: ps.flashId }); - if (flash == null) { - throw new ApiError(meta.errors.noSuchFlash); - } - - if (flash.userId === me.id) { - throw new ApiError(meta.errors.yourFlash); - } - - // if already liked - const exist = await this.flashLikesRepository.exist({ - where: { - flashId: flash.id, - userId: me.id, - }, - }); - - if (exist) { - throw new ApiError(meta.errors.alreadyLiked); - } - - // Create like - await this.flashLikesRepository.insert({ - id: this.idService.gen(), - flashId: flash.id, - userId: me.id, - }); - - this.flashsRepository.increment({ id: flash.id }, 'likedCount', 1); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/flash/my-likes.ts b/packages/backend/src/server/api/endpoints/flash/my-likes.ts deleted file mode 100644 index ed2819ce01..0000000000 --- a/packages/backend/src/server/api/endpoints/flash/my-likes.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { FlashLikesRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { FlashLikeEntityService } from '@/core/entities/FlashLikeEntityService.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['account', 'flash'], - - requireCredential: 'always', - - kind: 'read:flash-likes', - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - flash: { - type: 'object', - optional: false, nullable: false, - ref: 'Flash', - }, - }, - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - }, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.flashLikesRepository) - private flashLikesRepository: FlashLikesRepository, - - private flashLikeEntityService: FlashLikeEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.flashLikesRepository.createQueryBuilder('like'), ps.sinceId, ps.untilId) - .andWhere('like.userId = :meId', { meId: me.id }) - .leftJoinAndSelect('like.flash', 'flash'); - - const likes = await query - .limit(ps.limit) - .getMany(); - - return this.flashLikeEntityService.packMany(likes, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/flash/my.ts b/packages/backend/src/server/api/endpoints/flash/my.ts deleted file mode 100644 index 0ced56bd88..0000000000 --- a/packages/backend/src/server/api/endpoints/flash/my.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { FlashsRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['account', 'flash'], - - requireCredential: 'always', - - kind: 'read:flash', - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'Flash', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - }, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.flashsRepository) - private flashsRepository: FlashsRepository, - - private flashEntityService: FlashEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.flashsRepository.createQueryBuilder('flash'), ps.sinceId, ps.untilId) - .andWhere('flash.userId = :meId', { meId: me.id }); - - const flashs = await query - .limit(ps.limit) - .getMany(); - - return await this.flashEntityService.packMany(flashs); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/flash/show.ts b/packages/backend/src/server/api/endpoints/flash/show.ts deleted file mode 100644 index 57afcffcbd..0000000000 --- a/packages/backend/src/server/api/endpoints/flash/show.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { FlashsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['flashs'], - - requireCredential: 'conditional', - - res: { - type: 'object', - optional: false, nullable: false, - ref: 'Flash', - }, - - errors: { - noSuchFlash: { - message: 'No such flash.', - code: 'NO_SUCH_FLASH', - id: 'f0d34a1a-d29a-401d-90ba-1982122b5630', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - flashId: { type: 'string', format: 'misskey:id' }, - }, - required: ['flashId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.flashsRepository) - private flashsRepository: FlashsRepository, - - private flashEntityService: FlashEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const flash = await this.flashsRepository.findOneBy({ id: ps.flashId }); - - if (flash == null) { - throw new ApiError(meta.errors.noSuchFlash); - } - - return await this.flashEntityService.pack(flash, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/flash/unlike.ts b/packages/backend/src/server/api/endpoints/flash/unlike.ts deleted file mode 100644 index 6b3ca93d3a..0000000000 --- a/packages/backend/src/server/api/endpoints/flash/unlike.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { FlashsRepository, FlashLikesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['flash'], - - requireCredential: 'always', - - prohibitMoved: true, - - kind: 'write:flash-likes', - - errors: { - noSuchFlash: { - message: 'No such flash.', - code: 'NO_SUCH_FLASH', - id: 'afe8424a-a69e-432d-a5f2-2f0740c62410', - }, - - notLiked: { - message: 'You have not liked that flash.', - code: 'NOT_LIKED', - id: '755f25a7-9871-4f65-9f34-51eaad9ae0ac', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - flashId: { type: 'string', format: 'misskey:id' }, - }, - required: ['flashId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.flashsRepository) - private flashsRepository: FlashsRepository, - - @Inject(DI.flashLikesRepository) - private flashLikesRepository: FlashLikesRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const flash = await this.flashsRepository.findOneBy({ id: ps.flashId }); - if (flash == null) { - throw new ApiError(meta.errors.noSuchFlash); - } - - const exist = await this.flashLikesRepository.findOneBy({ - flashId: flash.id, - userId: me.id, - }); - - if (exist == null) { - throw new ApiError(meta.errors.notLiked); - } - - // Delete like - await this.flashLikesRepository.delete(exist.id); - - this.flashsRepository.decrement({ id: flash.id }, 'likedCount', 1); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/flash/update.ts b/packages/backend/src/server/api/endpoints/flash/update.ts deleted file mode 100644 index 618484e59a..0000000000 --- a/packages/backend/src/server/api/endpoints/flash/update.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import ms from 'ms'; -import { Inject, Injectable } from '@nestjs/common'; -import type { FlashsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['flash'], - - requireCredential: 'always', - - prohibitMoved: true, - - kind: 'write:flash', - - limit: { - duration: ms('1hour'), - max: 300, - }, - - errors: { - noSuchFlash: { - message: 'No such flash.', - code: 'NO_SUCH_FLASH', - id: '611e13d2-309e-419a-a5e4-e0422da39b02', - }, - - accessDenied: { - message: 'Access denied.', - code: 'ACCESS_DENIED', - id: '08e60c88-5948-478e-a132-02ec701d67b2', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - flashId: { type: 'string', format: 'misskey:id' }, - title: { type: 'string' }, - summary: { type: 'string' }, - script: { type: 'string' }, - permissions: { type: 'array', items: { - type: 'string', - } }, - visibility: { type: 'string', enum: ['public', 'private'] }, - }, - required: ['flashId', 'title', 'summary', 'script', 'permissions'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.flashsRepository) - private flashsRepository: FlashsRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const flash = await this.flashsRepository.findOneBy({ id: ps.flashId }); - if (flash == null) { - throw new ApiError(meta.errors.noSuchFlash); - } - if (flash.userId !== me.id) { - throw new ApiError(meta.errors.accessDenied); - } - - await this.flashsRepository.update(flash.id, { - updatedAt: new Date(), - title: ps.title, - summary: ps.summary, - script: ps.script, - permissions: ps.permissions, - visibility: ps.visibility, - }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/following/create.ts b/packages/backend/src/server/api/endpoints/following/create.ts index f58f71c5c1..02a030cd5e 100644 --- a/packages/backend/src/server/api/endpoints/following/create.ts +++ b/packages/backend/src/server/api/endpoints/following/create.ts @@ -1,18 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import ms from 'ms'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { FollowingsRepository } from '@/models/_.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; +import create from '@/services/following/create.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { getUser } from '../../common/getters.js'; +import { Followings, Users } from '@/models/index.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; export const meta = { tags: ['following', 'users'], @@ -22,9 +14,7 @@ export const meta = { max: 100, }, - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:following', @@ -71,58 +61,44 @@ export const paramDef = { type: 'object', properties: { userId: { type: 'string', format: 'misskey:id' }, - withReplies: { type: 'boolean' } }, required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const follower = user; - private userEntityService: UserEntityService, - private getterService: GetterService, - private userFollowingService: UserFollowingService, - ) { - super(meta, paramDef, async (ps, me) => { - const follower = me; - - // 自分自身 - if (me.id === ps.userId) { - throw new ApiError(meta.errors.followeeIsYourself); - } - - // Get followee - const followee = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); - - // Check if already following - const exist = await this.followingsRepository.exist({ - where: { - followerId: follower.id, - followeeId: followee.id, - }, - }); - - if (exist) { - throw new ApiError(meta.errors.alreadyFollowing); - } - - try { - await this.userFollowingService.follow(follower, followee, { withReplies: ps.withReplies }); - } catch (e) { - if (e instanceof IdentifiableError) { - if (e.id === '710e8fb0-b8c3-4922-be49-d5d93d8e6a6e') throw new ApiError(meta.errors.blocking); - if (e.id === '3338392a-f764-498d-8855-db939dcf8c48') throw new ApiError(meta.errors.blocked); - } - throw e; - } - - return await this.userEntityService.pack(followee.id, me); - }); + // 自分自身 + if (user.id === ps.userId) { + throw new ApiError(meta.errors.followeeIsYourself); } -} + + // Get followee + const followee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check if already following + const exist = await Followings.findOneBy({ + followerId: follower.id, + followeeId: followee.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyFollowing); + } + + try { + await create(follower, followee); + } catch (e) { + if (e instanceof IdentifiableError) { + if (e.id === '710e8fb0-b8c3-4922-be49-d5d93d8e6a6e') throw new ApiError(meta.errors.blocking); + if (e.id === '3338392a-f764-498d-8855-db939dcf8c48') throw new ApiError(meta.errors.blocked); + } + throw e; + } + + return await Users.pack(followee.id, user); +}); diff --git a/packages/backend/src/server/api/endpoints/following/delete.ts b/packages/backend/src/server/api/endpoints/following/delete.ts index 77dec19a3d..2f41b16e9a 100644 --- a/packages/backend/src/server/api/endpoints/following/delete.ts +++ b/packages/backend/src/server/api/endpoints/following/delete.ts @@ -1,17 +1,9 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import ms from 'ms'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { FollowingsRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; +import deleteFollowing from '@/services/following/delete.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { getUser } from '../../common/getters.js'; +import { Followings, Users } from '@/models/index.js'; export const meta = { tags: ['following', 'users'], @@ -21,7 +13,7 @@ export const meta = { max: 100, }, - requireCredential: 'always', + requireCredential: true, kind: 'write:following', @@ -60,45 +52,32 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const follower = user; - private userEntityService: UserEntityService, - private getterService: GetterService, - private userFollowingService: UserFollowingService, - ) { - super(meta, paramDef, async (ps, me) => { - const follower = me; - - // Check if the followee is yourself - if (me.id === ps.userId) { - throw new ApiError(meta.errors.followeeIsYourself); - } - - // Get followee - const followee = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); - - // Check not following - const exist = await this.followingsRepository.exist({ - where: { - followerId: follower.id, - followeeId: followee.id, - }, - }); - - if (!exist) { - throw new ApiError(meta.errors.notFollowing); - } - - await this.userFollowingService.unfollow(follower, followee); - - return await this.userEntityService.pack(followee.id, me); - }); + // Check if the followee is yourself + if (user.id === ps.userId) { + throw new ApiError(meta.errors.followeeIsYourself); } -} + + // Get followee + const followee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check not following + const exist = await Followings.findOneBy({ + followerId: follower.id, + followeeId: followee.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notFollowing); + } + + await deleteFollowing(follower, followee); + + return await Users.pack(followee.id, user); +}); diff --git a/packages/backend/src/server/api/endpoints/following/invalidate.ts b/packages/backend/src/server/api/endpoints/following/invalidate.ts index 295bc4d1a4..18ec5affe8 100644 --- a/packages/backend/src/server/api/endpoints/following/invalidate.ts +++ b/packages/backend/src/server/api/endpoints/following/invalidate.ts @@ -1,17 +1,9 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import ms from 'ms'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { FollowingsRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; +import deleteFollowing from '@/services/following/delete.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { getUser } from '../../common/getters.js'; +import { Followings, Users } from '@/models/index.js'; export const meta = { tags: ['following', 'users'], @@ -21,7 +13,7 @@ export const meta = { max: 100, }, - requireCredential: 'always', + requireCredential: true, kind: 'write:following', @@ -29,7 +21,7 @@ export const meta = { noSuchUser: { message: 'No such user.', code: 'NO_SUCH_USER', - id: 'b77e6ae6-a3e5-40da-9cc8-c240115479cc', + id: '5b12c78d-2b28-4dca-99d2-f56139b42ff8', }, followerIsYourself: { @@ -41,7 +33,7 @@ export const meta = { notFollowing: { message: 'The other use is not following you.', code: 'NOT_FOLLOWING', - id: '918faac3-074f-41ae-9c43-ed5d2946770d', + id: '5dbf82f5-c92b-40b1-87d1-6c8c0741fd09', }, }, @@ -60,43 +52,32 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const followee = user; - private userEntityService: UserEntityService, - private getterService: GetterService, - private userFollowingService: UserFollowingService, - ) { - super(meta, paramDef, async (ps, me) => { - const followee = me; - - // Check if the follower is yourself - if (me.id === ps.userId) { - throw new ApiError(meta.errors.followerIsYourself); - } - - // Get follower - const follower = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); - - // Check not following - const exist = await this.followingsRepository.findOneBy({ - followerId: follower.id, - followeeId: followee.id, - }); - - if (exist == null) { - throw new ApiError(meta.errors.notFollowing); - } - - await this.userFollowingService.unfollow(follower, followee); - - return await this.userEntityService.pack(followee.id, me); - }); + // Check if the follower is yourself + if (user.id === ps.userId) { + throw new ApiError(meta.errors.followerIsYourself); } -} + + // Get follower + const follower = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check not following + const exist = await Followings.findOneBy({ + followerId: follower.id, + followeeId: followee.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notFollowing); + } + + await deleteFollowing(follower, followee); + + return await Users.pack(followee.id, user); +}); diff --git a/packages/backend/src/server/api/endpoints/following/requests/accept.ts b/packages/backend/src/server/api/endpoints/following/requests/accept.ts index cadcec3d17..e5df55375e 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/accept.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/accept.ts @@ -1,18 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; +import acceptFollowRequest from '@/services/following/requests/accept.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { getUser } from '../../../common/getters.js'; export const meta = { tags: ['following', 'account'], - requireCredential: 'always', + requireCredential: true, kind: 'write:following', @@ -38,25 +32,18 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private getterService: GetterService, - private userFollowingService: UserFollowingService, - ) { - super(meta, paramDef, async (ps, me) => { - // Fetch follower - const follower = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Fetch follower + const follower = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); - await this.userFollowingService.acceptFollowRequest(me, follower).catch(err => { - if (err.id === '8884c2dd-5795-4ac9-b27e-6a01d38190f9') throw new ApiError(meta.errors.noFollowRequest); - throw err; - }); + await acceptFollowRequest(user, follower).catch(e => { + if (e.id === '8884c2dd-5795-4ac9-b27e-6a01d38190f9') throw new ApiError(meta.errors.noFollowRequest); + throw e; + }); - return; - }); - } -} + return; +}); diff --git a/packages/backend/src/server/api/endpoints/following/requests/cancel.ts b/packages/backend/src/server/api/endpoints/following/requests/cancel.ts index af5ddd0bab..80d37fb075 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/cancel.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/cancel.ts @@ -1,20 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; +import cancelFollowRequest from '@/services/following/requests/cancel.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { getUser } from '../../../common/getters.js'; +import { Users } from '@/models/index.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; export const meta = { tags: ['following', 'account'], - requireCredential: 'always', + requireCredential: true, kind: 'write:following', @@ -47,30 +41,22 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private userEntityService: UserEntityService, - private getterService: GetterService, - private userFollowingService: UserFollowingService, - ) { - super(meta, paramDef, async (ps, me) => { - // Fetch followee - const followee = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Fetch followee + const followee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); - try { - await this.userFollowingService.cancelFollowRequest(followee, me); - } catch (err) { - if (err instanceof IdentifiableError) { - if (err.id === '17447091-ce07-46dd-b331-c1fd4f15b1e7') throw new ApiError(meta.errors.followRequestNotFound); - } - throw err; - } - - return await this.userEntityService.pack(followee.id, me); - }); + try { + await cancelFollowRequest(followee, user); + } catch (e) { + if (e instanceof IdentifiableError) { + if (e.id === '17447091-ce07-46dd-b331-c1fd4f15b1e7') throw new ApiError(meta.errors.followRequestNotFound); + } + throw e; } -} + + return await Users.pack(followee.id, user); +}); diff --git a/packages/backend/src/server/api/endpoints/following/requests/list.ts b/packages/backend/src/server/api/endpoints/following/requests/list.ts index d992b517d5..a8f42c481d 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/list.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/list.ts @@ -1,19 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import type { FollowRequestsRepository } from '@/models/_.js'; -import { FollowRequestEntityService } from '@/core/entities/FollowRequestEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; +import { FollowRequests } from '@/models/index.js'; export const meta = { tags: ['following', 'account'], - requireCredential: 'always', + requireCredential: true, kind: 'read:following', @@ -46,32 +37,15 @@ export const meta = { export const paramDef = { type: 'object', - properties: { - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - }, + properties: {}, required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.followRequestsRepository) - private followRequestsRepository: FollowRequestsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const reqs = await FollowRequests.findBy({ + followeeId: user.id, + }); - private followRequestEntityService: FollowRequestEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.followRequestsRepository.createQueryBuilder('request'), ps.sinceId, ps.untilId) - .andWhere('request.followeeId = :meId', { meId: me.id }); - - const requests = await query - .limit(ps.limit) - .getMany(); - - return await Promise.all(requests.map(req => this.followRequestEntityService.pack(req))); - }); - } -} + return await Promise.all(reqs.map(req => FollowRequests.pack(req))); +}); diff --git a/packages/backend/src/server/api/endpoints/following/requests/reject.ts b/packages/backend/src/server/api/endpoints/following/requests/reject.ts index e4826486ca..cebe604284 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/reject.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/reject.ts @@ -1,18 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { rejectFollowRequest } from '@/services/following/reject.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { getUser } from '../../../common/getters.js'; export const meta = { tags: ['following', 'account'], - requireCredential: 'always', + requireCredential: true, kind: 'write:following', @@ -33,22 +27,15 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private getterService: GetterService, - private userFollowingService: UserFollowingService, - ) { - super(meta, paramDef, async (ps, me) => { - // Fetch follower - const follower = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Fetch follower + const follower = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); - await this.userFollowingService.rejectFollowRequest(me, follower); + await rejectFollowRequest(user, follower); - return; - }); - } -} + return; +}); diff --git a/packages/backend/src/server/api/endpoints/following/update-all.ts b/packages/backend/src/server/api/endpoints/following/update-all.ts deleted file mode 100644 index 69958ba60f..0000000000 --- a/packages/backend/src/server/api/endpoints/following/update-all.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import ms from 'ms'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { FollowingsRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['following', 'users'], - - limit: { - duration: ms('1hour'), - max: 10, - }, - - requireCredential: 'always', - - kind: 'write:following', -} as const; - -export const paramDef = { - type: 'object', - properties: { - notify: { type: 'string', enum: ['normal', 'none'] }, - withReplies: { type: 'boolean' }, - }, -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - ) { - super(meta, paramDef, async (ps, me) => { - await this.followingsRepository.update({ - followerId: me.id, - }, { - notify: ps.notify != null ? (ps.notify === 'none' ? null : ps.notify) : undefined, - withReplies: ps.withReplies != null ? ps.withReplies : undefined, - }); - - return; - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/following/update.ts b/packages/backend/src/server/api/endpoints/following/update.ts deleted file mode 100644 index a6cd220ba7..0000000000 --- a/packages/backend/src/server/api/endpoints/following/update.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import ms from 'ms'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { FollowingsRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['following', 'users'], - - limit: { - duration: ms('1hour'), - max: 100, - }, - - requireCredential: 'always', - - kind: 'write:following', - - errors: { - noSuchUser: { - message: 'No such user.', - code: 'NO_SUCH_USER', - id: '14318698-f67e-492a-99da-5353a5ac52be', - }, - - followeeIsYourself: { - message: 'Followee is yourself.', - code: 'FOLLOWEE_IS_YOURSELF', - id: '4c4cbaf9-962a-463b-8418-a5e365dbf2eb', - }, - - notFollowing: { - message: 'You are not following that user.', - code: 'NOT_FOLLOWING', - id: 'b8dc75cf-1cb5-46c9-b14b-5f1ffbd782c9', - }, - }, - - res: { - type: 'object', - optional: false, nullable: false, - ref: 'UserLite', - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - notify: { type: 'string', enum: ['normal', 'none'] }, - withReplies: { type: 'boolean' }, - }, - required: ['userId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - private userEntityService: UserEntityService, - private getterService: GetterService, - private userFollowingService: UserFollowingService, - ) { - super(meta, paramDef, async (ps, me) => { - const follower = me; - - // Check if the follower is yourself - if (me.id === ps.userId) { - throw new ApiError(meta.errors.followeeIsYourself); - } - - // Get followee - const followee = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); - - // Check not following - const exist = await this.followingsRepository.findOneBy({ - followerId: follower.id, - followeeId: followee.id, - }); - - if (exist == null) { - throw new ApiError(meta.errors.notFollowing); - } - - await this.followingsRepository.update({ - id: exist.id, - }, { - notify: ps.notify != null ? (ps.notify === 'none' ? null : ps.notify) : undefined, - withReplies: ps.withReplies != null ? ps.withReplies : undefined, - }); - - return await this.userEntityService.pack(follower.id, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/gallery/featured.ts b/packages/backend/src/server/api/endpoints/gallery/featured.ts index a83839c755..e6acd36911 100644 --- a/packages/backend/src/server/api/endpoints/gallery/featured.ts +++ b/packages/backend/src/server/api/endpoints/gallery/featured.ts @@ -1,19 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { GalleryPostsRepository } from '@/models/_.js'; -import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { FeaturedService } from '@/core/FeaturedService.js'; +import define from '../../define.js'; +import { GalleryPosts } from '@/models/index.js'; export const meta = { tags: ['gallery'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'array', @@ -28,51 +19,18 @@ export const meta = { export const paramDef = { type: 'object', - properties: { - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - untilId: { type: 'string', format: 'misskey:id' }, - }, + properties: {}, required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - private galleryPostsRankingCache: string[] = []; - private galleryPostsRankingCacheLastFetchedAt = 0; +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = GalleryPosts.createQueryBuilder('post') + .andWhere('post.createdAt > :date', { date: new Date(Date.now() - (1000 * 60 * 60 * 24 * 3)) }) + .andWhere('post.likedCount > 0') + .orderBy('post.likedCount', 'DESC'); - constructor( - @Inject(DI.galleryPostsRepository) - private galleryPostsRepository: GalleryPostsRepository, + const posts = await query.take(10).getMany(); - private galleryPostEntityService: GalleryPostEntityService, - private featuredService: FeaturedService, - ) { - super(meta, paramDef, async (ps, me) => { - let postIds: string[]; - if (this.galleryPostsRankingCacheLastFetchedAt !== 0 && (Date.now() - this.galleryPostsRankingCacheLastFetchedAt < 1000 * 60 * 30)) { - postIds = this.galleryPostsRankingCache; - } else { - postIds = await this.featuredService.getGalleryPostsRanking(100); - this.galleryPostsRankingCache = postIds; - this.galleryPostsRankingCacheLastFetchedAt = Date.now(); - } - - postIds.sort((a, b) => a > b ? -1 : 1); - if (ps.untilId) { - postIds = postIds.filter(id => id < ps.untilId!); - } - postIds = postIds.slice(0, ps.limit); - - if (postIds.length === 0) { - return []; - } - - const query = this.galleryPostsRepository.createQueryBuilder('post') - .where('post.id IN (:...postIds)', { postIds: postIds }); - - const posts = await query.getMany(); - - return await this.galleryPostEntityService.packMany(posts, me); - }); - } -} + return await GalleryPosts.packMany(posts, me); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/popular.ts b/packages/backend/src/server/api/endpoints/gallery/popular.ts index 3648e37b28..c4c8982fcc 100644 --- a/packages/backend/src/server/api/endpoints/gallery/popular.ts +++ b/packages/backend/src/server/api/endpoints/gallery/popular.ts @@ -1,18 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { GalleryPostsRepository } from '@/models/_.js'; -import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { GalleryPosts } from '@/models/index.js'; export const meta = { tags: ['gallery'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'array', @@ -31,22 +23,13 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.galleryPostsRepository) - private galleryPostsRepository: GalleryPostsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = GalleryPosts.createQueryBuilder('post') + .andWhere('post.likedCount > 0') + .orderBy('post.likedCount', 'DESC'); - private galleryPostEntityService: GalleryPostEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.galleryPostsRepository.createQueryBuilder('post') - .andWhere('post.likedCount > 0') - .orderBy('post.likedCount', 'DESC'); + const posts = await query.take(10).getMany(); - const posts = await query.limit(10).getMany(); - - return await this.galleryPostEntityService.packMany(posts, me); - }); - } -} + return await GalleryPosts.packMany(posts, me); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts.ts b/packages/backend/src/server/api/endpoints/gallery/posts.ts index 7a1af7457c..428ba9cc71 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts.ts @@ -1,20 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { GalleryPostsRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { GalleryPosts } from '@/models/index.js'; export const meta = { tags: ['gallery'], - requireCredential: 'conditional', - res: { type: 'array', optional: false, nullable: false, @@ -36,22 +26,12 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.galleryPostsRepository) - private galleryPostsRepository: GalleryPostsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId) + .innerJoinAndSelect('post.user', 'user'); - private galleryPostEntityService: GalleryPostEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.galleryPostsRepository.createQueryBuilder('post'), ps.sinceId, ps.untilId) - .innerJoinAndSelect('post.user', 'user'); + const posts = await query.take(ps.limit).getMany(); - const posts = await query.limit(ps.limit).getMany(); - - return await this.galleryPostEntityService.packMany(posts, me); - }); - } -} + return await GalleryPosts.packMany(posts, me); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts index 37366615bb..8074a3b34f 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts @@ -1,30 +1,21 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import ms from 'ms'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFilesRepository, GalleryPostsRepository } from '@/models/_.js'; -import { MiGalleryPost } from '@/models/GalleryPost.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import { IdService } from '@/core/IdService.js'; -import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; +import { DriveFiles, GalleryPosts } from '@/models/index.js'; +import { genId } from '../../../../../misc/gen-id.js'; +import { GalleryPost } from '@/models/entities/gallery-post.js'; +import { ApiError } from '../../../error.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; export const meta = { tags: ['gallery'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:gallery', limit: { duration: ms('1hour'), - max: 20, + max: 300, }, res: { @@ -51,41 +42,29 @@ export const paramDef = { required: ['title', 'fileIds'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.galleryPostsRepository) - private galleryPostsRepository: GalleryPostsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const files = (await Promise.all(ps.fileIds.map(fileId => + DriveFiles.findOneBy({ + id: fileId, + userId: user.id, + }) + ))).filter((file): file is DriveFile => file != null); - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private galleryPostEntityService: GalleryPostEntityService, - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const files = (await Promise.all(ps.fileIds.map(fileId => - this.driveFilesRepository.findOneBy({ - id: fileId, - userId: me.id, - }), - ))).filter((file): file is MiDriveFile => file != null); - - if (files.length === 0) { - throw new Error(); - } - - const post = await this.galleryPostsRepository.insert(new MiGalleryPost({ - id: this.idService.gen(), - updatedAt: new Date(), - title: ps.title, - description: ps.description, - userId: me.id, - isSensitive: ps.isSensitive, - fileIds: files.map(file => file.id), - })).then(x => this.galleryPostsRepository.findOneByOrFail(x.identifiers[0])); - - return await this.galleryPostEntityService.pack(post, me); - }); + if (files.length === 0) { + throw new Error(); } -} + + const post = await GalleryPosts.insert(new GalleryPost({ + id: genId(), + createdAt: new Date(), + updatedAt: new Date(), + title: ps.title, + description: ps.description, + userId: user.id, + isSensitive: ps.isSensitive, + fileIds: files.map(file => file.id), + })).then(x => GalleryPosts.findOneByOrFail(x.identifiers[0])); + + return await GalleryPosts.pack(post, user); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts index 537cb0ead5..b00ee0e2ae 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts @@ -1,18 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { GalleryPostsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { GalleryPosts } from '@/models/index.js'; export const meta = { tags: ['gallery'], - requireCredential: 'always', + requireCredential: true, kind: 'write:gallery', @@ -33,23 +26,16 @@ export const paramDef = { required: ['postId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.galleryPostsRepository) - private galleryPostsRepository: GalleryPostsRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const post = await this.galleryPostsRepository.findOneBy({ - id: ps.postId, - userId: me.id, - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const post = await GalleryPosts.findOneBy({ + id: ps.postId, + userId: user.id, + }); - if (post == null) { - throw new ApiError(meta.errors.noSuchPost); - } - - await this.galleryPostsRepository.delete(post.id); - }); + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); } -} + + await GalleryPosts.delete(post.id); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts index f019f10156..b858114aec 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts @@ -1,22 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { GalleryLikesRepository, GalleryPostsRepository } from '@/models/_.js'; -import { FeaturedService, GALLERY_POSTS_RANKING_WINDOW } from '@/core/FeaturedService.js'; -import { IdService } from '@/core/IdService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { GalleryPosts, GalleryLikes } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; export const meta = { tags: ['gallery'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:gallery-likes', @@ -49,53 +39,34 @@ export const paramDef = { required: ['postId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.galleryPostsRepository) - private galleryPostsRepository: GalleryPostsRepository, - - @Inject(DI.galleryLikesRepository) - private galleryLikesRepository: GalleryLikesRepository, - - private featuredService: FeaturedService, - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const post = await this.galleryPostsRepository.findOneBy({ id: ps.postId }); - if (post == null) { - throw new ApiError(meta.errors.noSuchPost); - } - - if (post.userId === me.id) { - throw new ApiError(meta.errors.yourPost); - } - - // if already liked - const exist = await this.galleryLikesRepository.exist({ - where: { - postId: post.id, - userId: me.id, - }, - }); - - if (exist) { - throw new ApiError(meta.errors.alreadyLiked); - } - - // Create like - await this.galleryLikesRepository.insert({ - id: this.idService.gen(), - postId: post.id, - userId: me.id, - }); - - // ランキング更新 - if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) { - await this.featuredService.updateGalleryPostsRanking(post.id, 1); - } - - this.galleryPostsRepository.increment({ id: post.id }, 'likedCount', 1); - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const post = await GalleryPosts.findOneBy({ id: ps.postId }); + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); } -} + + if (post.userId === user.id) { + throw new ApiError(meta.errors.yourPost); + } + + // if already liked + const exist = await GalleryLikes.findOneBy({ + postId: post.id, + userId: user.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyLiked); + } + + // Create like + await GalleryLikes.insert({ + id: genId(), + createdAt: new Date(), + postId: post.id, + userId: user.id, + }); + + GalleryPosts.increment({ id: post.id }, 'likedCount', 1); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/show.ts b/packages/backend/src/server/api/endpoints/gallery/posts/show.ts index a683d6658d..4f6dafd7cb 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/show.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/show.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { GalleryPostsRepository } from '@/models/_.js'; -import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { GalleryPosts } from '@/models/index.js'; export const meta = { tags: ['gallery'], - requireCredential: 'conditional', + requireCredential: false, errors: { noSuchPost: { @@ -38,24 +30,15 @@ export const paramDef = { required: ['postId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.galleryPostsRepository) - private galleryPostsRepository: GalleryPostsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const post = await GalleryPosts.findOneBy({ + id: ps.postId, + }); - private galleryPostEntityService: GalleryPostEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const post = await this.galleryPostsRepository.findOneBy({ - id: ps.postId, - }); - - if (post == null) { - throw new ApiError(meta.errors.noSuchPost); - } - - return await this.galleryPostEntityService.pack(post, me); - }); + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); } -} + + return await GalleryPosts.pack(post, me); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts index 3bb577f874..d136239e5e 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts @@ -1,22 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { GalleryPostsRepository, GalleryLikesRepository } from '@/models/_.js'; -import { FeaturedService, GALLERY_POSTS_RANKING_WINDOW } from '@/core/FeaturedService.js'; -import { IdService } from '@/core/IdService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { GalleryPosts, GalleryLikes } from '@/models/index.js'; export const meta = { tags: ['gallery'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:gallery-likes', @@ -43,42 +32,24 @@ export const paramDef = { required: ['postId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.galleryPostsRepository) - private galleryPostsRepository: GalleryPostsRepository, - - @Inject(DI.galleryLikesRepository) - private galleryLikesRepository: GalleryLikesRepository, - - private featuredService: FeaturedService, - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const post = await this.galleryPostsRepository.findOneBy({ id: ps.postId }); - if (post == null) { - throw new ApiError(meta.errors.noSuchPost); - } - - const exist = await this.galleryLikesRepository.findOneBy({ - postId: post.id, - userId: me.id, - }); - - if (exist == null) { - throw new ApiError(meta.errors.notLiked); - } - - // Delete like - await this.galleryLikesRepository.delete(exist.id); - - // ランキング更新 - if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) { - await this.featuredService.updateGalleryPostsRanking(post.id, -1); - } - - this.galleryPostsRepository.decrement({ id: post.id }, 'likedCount', 1); - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const post = await GalleryPosts.findOneBy({ id: ps.postId }); + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); } -} + + const exist = await GalleryLikes.findOneBy({ + postId: post.id, + userId: user.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notLiked); + } + + // Delete like + await GalleryLikes.delete(exist.id); + + GalleryPosts.decrement({ id: post.id }, 'likedCount', 1); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts index c067c707c4..82fe38078e 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts @@ -1,22 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import ms from 'ms'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFilesRepository, GalleryPostsRepository } from '@/models/_.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; +import { DriveFiles, GalleryPosts } from '@/models/index.js'; +import { GalleryPost } from '@/models/entities/gallery-post.js'; +import { ApiError } from '../../../error.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; export const meta = { tags: ['gallery'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:gallery', @@ -50,43 +42,31 @@ export const paramDef = { required: ['postId', 'title', 'fileIds'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.galleryPostsRepository) - private galleryPostsRepository: GalleryPostsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const files = (await Promise.all(ps.fileIds.map(fileId => + DriveFiles.findOneBy({ + id: fileId, + userId: user.id, + }) + ))).filter((file): file is DriveFile => file != null); - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private galleryPostEntityService: GalleryPostEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const files = (await Promise.all(ps.fileIds.map(fileId => - this.driveFilesRepository.findOneBy({ - id: fileId, - userId: me.id, - }), - ))).filter((file): file is MiDriveFile => file != null); - - if (files.length === 0) { - throw new Error(); - } - - await this.galleryPostsRepository.update({ - id: ps.postId, - userId: me.id, - }, { - updatedAt: new Date(), - title: ps.title, - description: ps.description, - isSensitive: ps.isSensitive, - fileIds: files.map(file => file.id), - }); - - const post = await this.galleryPostsRepository.findOneByOrFail({ id: ps.postId }); - - return await this.galleryPostEntityService.pack(post, me); - }); + if (files.length === 0) { + throw new Error(); } -} + + await GalleryPosts.update({ + id: ps.postId, + userId: user.id, + }, { + updatedAt: new Date(), + title: ps.title, + description: ps.description, + isSensitive: ps.isSensitive, + fileIds: files.map(file => file.id), + }); + + const post = await GalleryPosts.findOneByOrFail({ id: ps.postId }); + + return await GalleryPosts.pack(post, user); +}); diff --git a/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts b/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts deleted file mode 100644 index d7572abf34..0000000000 --- a/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { IsNull } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; -import { RoleService } from '@/core/RoleService.js'; - -export const meta = { - tags: ['users'], - - requireCredential: 'conditional', - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - description: { - type: 'string', - optional: false, nullable: false, - }, - url: { - type: 'string', - optional: false, nullable: false, - }, - roleIdsThatCanBeUsedThisDecoration: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - }, - }, - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: {}, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private avatarDecorationService: AvatarDecorationService, - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const decorations = await this.avatarDecorationService.getAll(true); - const allRoles = await this.roleService.getRoles(); - - return decorations.map(decoration => ({ - id: decoration.id, - name: decoration.name, - description: decoration.description, - url: decoration.url, - roleIdsThatCanBeUsedThisDecoration: decoration.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(role => role.id === roleId)), - })); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/get-online-users-count.ts b/packages/backend/src/server/api/endpoints/get-online-users-count.ts index 111dca0b6b..56c5502978 100644 --- a/packages/backend/src/server/api/endpoints/get-online-users-count.ts +++ b/packages/backend/src/server/api/endpoints/get-online-users-count.ts @@ -1,31 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { MoreThan } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; import { USER_ONLINE_THRESHOLD } from '@/const.js'; -import type { UsersRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; +import { Users } from '@/models/index.js'; +import define from '../define.js'; export const meta = { tags: ['meta'], - requireCredential: 'never', - allowGet: true, - cacheSec: 60 * 1, - res: { - type: 'object', - optional: false, nullable: false, - properties: { - count: { - type: 'number', - nullable: false, - }, - }, - }, + requireCredential: false, } as const; export const paramDef = { @@ -34,20 +15,13 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - ) { - super(meta, paramDef, async () => { - const count = await this.usersRepository.countBy({ - lastActiveDate: MoreThan(new Date(Date.now() - USER_ONLINE_THRESHOLD)), - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async () => { + const count = await Users.countBy({ + lastActiveDate: MoreThan(new Date(Date.now() - USER_ONLINE_THRESHOLD)), + }); - return { - count, - }; - }); - } -} + return { + count, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/list.ts b/packages/backend/src/server/api/endpoints/hashtags/list.ts index e869c664cd..50e36386cf 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/list.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/list.ts @@ -1,18 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { HashtagsRepository } from '@/models/_.js'; -import { HashtagEntityService } from '@/core/entities/HashtagEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { Hashtags } from '@/models/index.js'; export const meta = { tags: ['hashtags'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'array', @@ -37,49 +29,40 @@ export const paramDef = { required: ['sort'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.hashtagsRepository) - private hashtagsRepository: HashtagsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = Hashtags.createQueryBuilder('tag'); - private hashtagEntityService: HashtagEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.hashtagsRepository.createQueryBuilder('tag'); + if (ps.attachedToUserOnly) query.andWhere('tag.attachedUsersCount != 0'); + if (ps.attachedToLocalUserOnly) query.andWhere('tag.attachedLocalUsersCount != 0'); + if (ps.attachedToRemoteUserOnly) query.andWhere('tag.attachedRemoteUsersCount != 0'); - if (ps.attachedToUserOnly) query.andWhere('tag.attachedUsersCount != 0'); - if (ps.attachedToLocalUserOnly) query.andWhere('tag.attachedLocalUsersCount != 0'); - if (ps.attachedToRemoteUserOnly) query.andWhere('tag.attachedRemoteUsersCount != 0'); - - switch (ps.sort) { - case '+mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'DESC'); break; - case '-mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'ASC'); break; - case '+mentionedLocalUsers': query.orderBy('tag.mentionedLocalUsersCount', 'DESC'); break; - case '-mentionedLocalUsers': query.orderBy('tag.mentionedLocalUsersCount', 'ASC'); break; - case '+mentionedRemoteUsers': query.orderBy('tag.mentionedRemoteUsersCount', 'DESC'); break; - case '-mentionedRemoteUsers': query.orderBy('tag.mentionedRemoteUsersCount', 'ASC'); break; - case '+attachedUsers': query.orderBy('tag.attachedUsersCount', 'DESC'); break; - case '-attachedUsers': query.orderBy('tag.attachedUsersCount', 'ASC'); break; - case '+attachedLocalUsers': query.orderBy('tag.attachedLocalUsersCount', 'DESC'); break; - case '-attachedLocalUsers': query.orderBy('tag.attachedLocalUsersCount', 'ASC'); break; - case '+attachedRemoteUsers': query.orderBy('tag.attachedRemoteUsersCount', 'DESC'); break; - case '-attachedRemoteUsers': query.orderBy('tag.attachedRemoteUsersCount', 'ASC'); break; - } - - query.select([ - 'tag.name', - 'tag.mentionedUsersCount', - 'tag.mentionedLocalUsersCount', - 'tag.mentionedRemoteUsersCount', - 'tag.attachedUsersCount', - 'tag.attachedLocalUsersCount', - 'tag.attachedRemoteUsersCount', - ]); - - const tags = await query.limit(ps.limit).getMany(); - - return this.hashtagEntityService.packMany(tags); - }); + switch (ps.sort) { + case '+mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'DESC'); break; + case '-mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'ASC'); break; + case '+mentionedLocalUsers': query.orderBy('tag.mentionedLocalUsersCount', 'DESC'); break; + case '-mentionedLocalUsers': query.orderBy('tag.mentionedLocalUsersCount', 'ASC'); break; + case '+mentionedRemoteUsers': query.orderBy('tag.mentionedRemoteUsersCount', 'DESC'); break; + case '-mentionedRemoteUsers': query.orderBy('tag.mentionedRemoteUsersCount', 'ASC'); break; + case '+attachedUsers': query.orderBy('tag.attachedUsersCount', 'DESC'); break; + case '-attachedUsers': query.orderBy('tag.attachedUsersCount', 'ASC'); break; + case '+attachedLocalUsers': query.orderBy('tag.attachedLocalUsersCount', 'DESC'); break; + case '-attachedLocalUsers': query.orderBy('tag.attachedLocalUsersCount', 'ASC'); break; + case '+attachedRemoteUsers': query.orderBy('tag.attachedRemoteUsersCount', 'DESC'); break; + case '-attachedRemoteUsers': query.orderBy('tag.attachedRemoteUsersCount', 'ASC'); break; } -} + + query.select([ + 'tag.name', + 'tag.mentionedUsersCount', + 'tag.mentionedLocalUsersCount', + 'tag.mentionedRemoteUsersCount', + 'tag.attachedUsersCount', + 'tag.attachedLocalUsersCount', + 'tag.attachedRemoteUsersCount', + ]); + + const tags = await query.take(ps.limit).getMany(); + + return Hashtags.packMany(tags); +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/search.ts b/packages/backend/src/server/api/endpoints/hashtags/search.ts index 97881976ba..c289844775 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/search.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/search.ts @@ -1,18 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { HashtagsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import define from '../../define.js'; +import { Hashtags } from '@/models/index.js'; export const meta = { tags: ['hashtags'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'array', @@ -34,22 +26,15 @@ export const paramDef = { required: ['query'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.hashtagsRepository) - private hashtagsRepository: HashtagsRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const hashtags = await this.hashtagsRepository.createQueryBuilder('tag') - .where('tag.name like :q', { q: sqlLikeEscape(ps.query.toLowerCase()) + '%' }) - .orderBy('tag.count', 'DESC') - .groupBy('tag.id') - .limit(ps.limit) - .offset(ps.offset) - .getMany(); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const hashtags = await Hashtags.createQueryBuilder('tag') + .where('tag.name like :q', { q: ps.query.toLowerCase() + '%' }) + .orderBy('tag.count', 'DESC') + .groupBy('tag.id') + .take(ps.limit) + .skip(ps.offset) + .getMany(); - return hashtags.map(tag => tag.name); - }); - } -} + return hashtags.map(tag => tag.name); +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/show.ts b/packages/backend/src/server/api/endpoints/hashtags/show.ts index 71b5876b13..5b78f6ac7f 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/show.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/show.ts @@ -1,20 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { HashtagsRepository } from '@/models/_.js'; -import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { HashtagEntityService } from '@/core/entities/HashtagEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { Hashtags } from '@/models/index.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; export const meta = { tags: ['hashtags'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'object', @@ -39,21 +31,12 @@ export const paramDef = { required: ['tag'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.hashtagsRepository) - private hashtagsRepository: HashtagsRepository, - - private hashtagEntityService: HashtagEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const hashtag = await this.hashtagsRepository.findOneBy({ name: normalizeForSearch(ps.tag) }); - if (hashtag == null) { - throw new ApiError(meta.errors.noSuchHashtag); - } - - return await this.hashtagEntityService.pack(hashtag); - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const hashtag = await Hashtags.findOneBy({ name: normalizeForSearch(ps.tag) }); + if (hashtag == null) { + throw new ApiError(meta.errors.noSuchHashtag); } -} + + return await Hashtags.pack(hashtag); +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/trend.ts b/packages/backend/src/server/api/endpoints/hashtags/trend.ts index abece31bd5..9cdbc8941c 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/trend.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/trend.ts @@ -1,20 +1,29 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ +import { Brackets } from 'typeorm'; +import define from '../../define.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Notes } from '@/models/index.js'; +import { Note } from '@/models/entities/note.js'; +import { safeForSql } from '@/misc/safe-for-sql.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { FeaturedService } from '@/core/FeaturedService.js'; -import { HashtagService } from '@/core/HashtagService.js'; +/* +トレンドに載るためには「『直近a分間のユニーク投稿数が今からa分前~今からb分前の間のユニーク投稿数のn倍以上』のハッシュタグの上位5位以内に入る」ことが必要 +ユニーク投稿数とはそのハッシュタグと投稿ユーザーのペアのカウントで、例えば同じユーザーが複数回同じハッシュタグを投稿してもそのハッシュタグのユニーク投稿数は1とカウントされる + +..が理想だけどPostgreSQLでどうするのか分からないので単に「直近Aの内に投稿されたユニーク投稿数が多いハッシュタグ」で妥協する +*/ + +const rangeA = 1000 * 60 * 60; // 60分 +//const rangeB = 1000 * 60 * 120; // 2時間 +//const coefficient = 1.25; // 「n倍」の部分 +//const requiredUsers = 3; // 最低何人がそのタグを投稿している必要があるか + +const max = 5; export const meta = { tags: ['hashtags'], - requireCredential: 'never', - allowGet: true, - cacheSec: 60 * 1, + requireCredential: false, res: { type: 'array', @@ -50,24 +59,95 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private featuredService: FeaturedService, - private hashtagService: HashtagService, - ) { - super(meta, paramDef, async () => { - const ranking = await this.featuredService.getHashtagsRanking(10); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async () => { + const instance = await fetchMeta(true); + const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t)); - const charts = ranking.length === 0 ? {} : await this.hashtagService.getCharts(ranking, 20); + const now = new Date(); // 5分単位で丸めた現在日時 + now.setMinutes(Math.round(now.getMinutes() / 5) * 5, 0, 0); - const stats = ranking.map((tag, i) => ({ - tag, - chart: charts[tag], - usersCount: Math.max(...charts[tag]), - })); + const tagNotes = await Notes.createQueryBuilder('note') + .where(`note.createdAt > :date`, { date: new Date(now.getTime() - rangeA) }) + .andWhere(new Brackets(qb => { qb + .where(`note.visibility = 'public'`) + .orWhere(`note.visibility = 'home'`); + })) + .andWhere(`note.tags != '{}'`) + .select(['note.tags', 'note.userId']) + .cache(60000) // 1 min + .getMany(); - return stats; - }); + if (tagNotes.length === 0) { + return []; } -} + + const tags: { + name: string; + users: Note['userId'][]; + }[] = []; + + for (const note of tagNotes) { + for (const tag of note.tags) { + if (hiddenTags.includes(tag)) continue; + + const x = tags.find(x => x.name === tag); + if (x) { + if (!x.users.includes(note.userId)) { + x.users.push(note.userId); + } + } else { + tags.push({ + name: tag, + users: [note.userId], + }); + } + } + } + + // タグを人気順に並べ替え + const hots = tags + .sort((a, b) => b.users.length - a.users.length) + .map(tag => tag.name) + .slice(0, max); + + //#region 2(または3)で話題と判定されたタグそれぞれについて過去の投稿数グラフを取得する + const countPromises: Promise[] = []; + + const range = 20; + + // 10分 + const interval = 1000 * 60 * 10; + + for (let i = 0; i < range; i++) { + countPromises.push(Promise.all(hots.map(tag => Notes.createQueryBuilder('note') + .select('count(distinct note.userId)') + .where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`) + .andWhere('note.createdAt < :lt', { lt: new Date(now.getTime() - (interval * i)) }) + .andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - (interval * (i + 1))) }) + .cache(60000) // 1 min + .getRawOne() + .then(x => parseInt(x.count, 10)) + ))); + } + + const countsLog = await Promise.all(countPromises); + //#endregion + + const totalCounts = await Promise.all(hots.map(tag => Notes.createQueryBuilder('note') + .select('count(distinct note.userId)') + .where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`) + .andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - rangeA) }) + .cache(60000 * 60) // 60 min + .getRawOne() + .then(x => parseInt(x.count, 10)) + )); + + const stats = hots.map((tag, i) => ({ + tag, + chart: countsLog.map(counts => counts[i]), + usersCount: totalCounts[i], + })); + + return stats; +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/users.ts b/packages/backend/src/server/api/endpoints/hashtags/users.ts index 8d4e1a3f62..a5df21a7e3 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/users.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/users.ts @@ -1,17 +1,9 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository } from '@/models/_.js'; +import define from '../../define.js'; +import { Users } from '@/models/index.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DI } from '@/di-symbols.js'; export const meta = { - requireCredential: 'conditional', + requireCredential: false, tags: ['hashtags', 'users'], @@ -32,49 +24,39 @@ export const paramDef = { tag: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] }, - state: { type: 'string', enum: ['all', 'alive'], default: 'all' }, - origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, + state: { type: 'string', enum: ['all', 'alive'], default: "all" }, + origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: "local" }, }, required: ['tag', 'sort'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = Users.createQueryBuilder('user') + .where(':tag = ANY(user.tags)', { tag: normalizeForSearch(ps.tag) }); - private userEntityService: UserEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.usersRepository.createQueryBuilder('user') - .where(':tag = ANY(user.tags)', { tag: normalizeForSearch(ps.tag) }) - .andWhere('user.isSuspended = FALSE'); + const recent = new Date(Date.now() - (1000 * 60 * 60 * 24 * 5)); - const recent = new Date(Date.now() - (1000 * 60 * 60 * 24 * 5)); - - if (ps.state === 'alive') { - query.andWhere('user.updatedAt > :date', { date: recent }); - } - - if (ps.origin === 'local') { - query.andWhere('user.host IS NULL'); - } else if (ps.origin === 'remote') { - query.andWhere('user.host IS NOT NULL'); - } - - switch (ps.sort) { - case '+follower': query.orderBy('user.followersCount', 'DESC'); break; - case '-follower': query.orderBy('user.followersCount', 'ASC'); break; - case '+createdAt': query.orderBy('user.id', 'DESC'); break; - case '-createdAt': query.orderBy('user.id', 'ASC'); break; - case '+updatedAt': query.orderBy('user.updatedAt', 'DESC'); break; - case '-updatedAt': query.orderBy('user.updatedAt', 'ASC'); break; - } - - const users = await query.limit(ps.limit).getMany(); - - return await this.userEntityService.packMany(users, me, { detail: true }); - }); + if (ps.state === 'alive') { + query.andWhere('user.updatedAt > :date', { date: recent }); } -} + + if (ps.origin === 'local') { + query.andWhere('user.host IS NULL'); + } else if (ps.origin === 'remote') { + query.andWhere('user.host IS NOT NULL'); + } + + switch (ps.sort) { + case '+follower': query.orderBy('user.followersCount', 'DESC'); break; + case '-follower': query.orderBy('user.followersCount', 'ASC'); break; + case '+createdAt': query.orderBy('user.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('user.createdAt', 'ASC'); break; + case '+updatedAt': query.orderBy('user.updatedAt', 'DESC'); break; + case '-updatedAt': query.orderBy('user.updatedAt', 'ASC'); break; + } + + const users = await query.take(ps.limit).getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts index a7fbc0936e..22aedfeee8 100644 --- a/packages/backend/src/server/api/endpoints/i.ts +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -1,35 +1,16 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UserProfilesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '../error.js'; +import { Users } from '@/models/index.js'; +import define from '../define.js'; export const meta = { tags: ['account'], - requireCredential: 'always', - kind: "read:account", + requireCredential: true, res: { type: 'object', optional: false, nullable: false, ref: 'MeDetailed', }, - - errors: { - userIsDeleted: { - message: 'User is deleted.', - code: 'USER_IS_DELETED', - id: 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a', - kind: 'permission', - }, - }, } as const; export const paramDef = { @@ -38,44 +19,13 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user, token) => { + const isSecure = token == null; - private userEntityService: UserEntityService, - ) { - super(meta, paramDef, async (ps, user, token) => { - const isSecure = token == null; - - const now = new Date(); - const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`; - - // 渡ってきている user はキャッシュされていて古い可能性があるので改めて取得 - const userProfile = await this.userProfilesRepository.findOne({ - where: { - userId: user.id, - }, - relations: ['user'], - }); - - if (userProfile == null) { - throw new ApiError(meta.errors.userIsDeleted); - } - - if (!userProfile.loggedInDates.includes(today)) { - this.userProfilesRepository.update({ userId: user.id }, { - loggedInDates: [...userProfile.loggedInDates, today], - }); - userProfile.loggedInDates = [...userProfile.loggedInDates, today]; - } - - return await this.userEntityService.pack(userProfile.user!, userProfile.user!, { - detail: true, - includeSecrets: isSecure, - userProfile, - }); - }); - } -} + // ここで渡ってきている user はキャッシュされていて古い可能性もあるので id だけ渡す + return await Users.pack(user.id, user, { + detail: true, + includeSecrets: isSecure, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/done.ts b/packages/backend/src/server/api/endpoints/i/2fa/done.ts index 749d13c3fe..35806b2bc3 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/done.ts @@ -1,18 +1,9 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as OTPAuth from 'otpauth'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import type { UserProfilesRepository } from '@/models/_.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; +import * as speakeasy from 'speakeasy'; +import define from '../../../define.js'; +import { UserProfiles } from '@/models/index.js'; export const meta = { - requireCredential: 'always', + requireCredential: true, secure: true, } as const; @@ -25,52 +16,28 @@ export const paramDef = { required: ['token'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const token = ps.token.replace(/\s/g, ''); - private userEntityService: UserEntityService, - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - const token = ps.token.replace(/\s/g, ''); + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); - - if (profile.twoFactorTempSecret == null) { - throw new Error('二段階認証の設定が開始されていません'); - } - - const delta = OTPAuth.TOTP.validate({ - secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret), - digits: 6, - token, - window: 5, - }); - - if (delta === null) { - throw new Error('not verified'); - } - - const backupCodes = Array.from({ length: 5 }, () => new OTPAuth.Secret().base32); - - await this.userProfilesRepository.update(me.id, { - twoFactorSecret: profile.twoFactorTempSecret, - twoFactorBackupSecret: backupCodes, - twoFactorEnabled: true, - }); - - // Publish meUpdated event - this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { - detail: true, - includeSecrets: true, - })); - - return { - backupCodes: backupCodes, - }; - }); + if (profile.twoFactorTempSecret == null) { + throw new Error('二段階認証の設定が開始されていません'); } -} + + const verified = (speakeasy as any).totp.verify({ + secret: profile.twoFactorTempSecret, + encoding: 'base32', + token: token, + }); + + if (!verified) { + throw new Error('not verified'); + } + + await UserProfiles.update(user.id, { + twoFactorSecret: profile.twoFactorTempSecret, + twoFactorEnabled: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts index 6a4d09e235..1afb34bfda 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -1,125 +1,143 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ +import bcrypt from 'bcryptjs'; +import { promisify } from 'node:util'; +import * as cbor from 'cbor'; +import define from '../../../define.js'; +import { + UserProfiles, + UserSecurityKeys, + AttestationChallenges, + Users, +} from '@/models/index.js'; +import config from '@/config/index.js'; +import { procedures, hash } from '../../../2fa.js'; +import { publishMainStream } from '@/services/stream.js'; -//import bcrypt from 'bcryptjs'; -import * as argon2 from 'argon2'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/_.js'; -import { WebAuthnService } from '@/core/WebAuthnService.js'; -import { ApiError } from '@/server/api/error.js'; -import { UserAuthService } from '@/core/UserAuthService.js'; +const cborDecodeFirst = promisify(cbor.decodeFirst) as any; +const rpIdHashReal = hash(Buffer.from(config.hostname, 'utf-8')); export const meta = { - requireCredential: 'always', + requireCredential: true, secure: true, - - errors: { - incorrectPassword: { - message: 'Incorrect password.', - code: 'INCORRECT_PASSWORD', - id: '0d7ec6d2-e652-443e-a7bf-9ee9a0cd77b0', - }, - - twoFactorNotEnabled: { - message: '2fa not enabled.', - code: 'TWO_FACTOR_NOT_ENABLED', - id: '798d6847-b1ed-4f9c-b1f9-163c42655995', - }, - }, - - res: { - type: 'object', - nullable: false, - optional: false, - properties: { - id: { type: 'string' }, - name: { type: 'string' }, - }, - }, } as const; export const paramDef = { type: 'object', properties: { + clientDataJSON: { type: 'string' }, + attestationObject: { type: 'string' }, password: { type: 'string' }, - token: { type: 'string', nullable: true }, - name: { type: 'string', minLength: 1, maxLength: 30 }, - credential: { type: 'object' }, + challengeId: { type: 'string' }, + name: { type: 'string' }, }, - required: ['password', 'name', 'credential'], + required: ['clientDataJSON', 'attestationObject', 'password', 'challengeId', 'name'], } as const; // eslint-disable-next-line import/no-default-export -@Injectable() -export default class extends Endpoint { - constructor( - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, +export default define(meta, paramDef, async (ps, user) => { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - @Inject(DI.userSecurityKeysRepository) - private userSecurityKeysRepository: UserSecurityKeysRepository, + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); - private webAuthnService: WebAuthnService, - private userAuthService: UserAuthService, - private userEntityService: UserEntityService, - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - const token = ps.token; - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); - - if (profile.twoFactorEnabled) { - if (token == null) { - throw new Error('authentication failed'); - } - - try { - await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { - throw new Error('authentication failed'); - } - } - - const passwordMatched = await argon2.verify(profile.password ?? '', ps.password); - if (!passwordMatched) { - throw new ApiError(meta.errors.incorrectPassword); - } - - if (!profile.twoFactorEnabled) { - throw new ApiError(meta.errors.twoFactorNotEnabled); - } - - const keyInfo = await this.webAuthnService.verifyRegistration(me.id, ps.credential); - - const credentialId = Buffer.from(keyInfo.credentialID).toString('base64url'); - await this.userSecurityKeysRepository.insert({ - id: credentialId, - userId: me.id, - name: ps.name, - publicKey: Buffer.from(keyInfo.credentialPublicKey).toString('base64url'), - counter: keyInfo.counter, - credentialDeviceType: keyInfo.credentialDeviceType, - credentialBackedUp: keyInfo.credentialBackedUp, - transports: keyInfo.transports, - }); - - // Publish meUpdated event - this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { - detail: true, - includeSecrets: true, - })); - - return { - id: credentialId, - name: ps.name, - }; - }); + if (!same) { + throw new Error('incorrect password'); } -} + + if (!profile.twoFactorEnabled) { + throw new Error('2fa not enabled'); + } + + const clientData = JSON.parse(ps.clientDataJSON); + + if (clientData.type !== 'webauthn.create') { + throw new Error('not a creation attestation'); + } + if (clientData.origin !== config.scheme + '://' + config.host) { + throw new Error('origin mismatch'); + } + + const clientDataJSONHash = hash(Buffer.from(ps.clientDataJSON, 'utf-8')); + + const attestation = await cborDecodeFirst(ps.attestationObject); + + const rpIdHash = attestation.authData.slice(0, 32); + if (!rpIdHashReal.equals(rpIdHash)) { + throw new Error('rpIdHash mismatch'); + } + + const flags = attestation.authData[32]; + + // eslint:disable-next-line:no-bitwise + if (!(flags & 1)) { + throw new Error('user not present'); + } + + const authData = Buffer.from(attestation.authData); + const credentialIdLength = authData.readUInt16BE(53); + const credentialId = authData.slice(55, 55 + credentialIdLength); + const publicKeyData = authData.slice(55 + credentialIdLength); + const publicKey: Map = await cborDecodeFirst(publicKeyData); + if (publicKey.get(3) !== -7) { + throw new Error('alg mismatch'); + } + + if (!(procedures as any)[attestation.fmt]) { + throw new Error('unsupported fmt'); + } + + const verificationData = (procedures as any)[attestation.fmt].verify({ + attStmt: attestation.attStmt, + authenticatorData: authData, + clientDataHash: clientDataJSONHash, + credentialId, + publicKey, + rpIdHash, + }); + if (!verificationData.valid) throw new Error('signature invalid'); + + const attestationChallenge = await AttestationChallenges.findOneBy({ + userId: user.id, + id: ps.challengeId, + registrationChallenge: true, + challenge: hash(clientData.challenge).toString('hex'), + }); + + if (!attestationChallenge) { + throw new Error('non-existent challenge'); + } + + await AttestationChallenges.delete({ + userId: user.id, + id: ps.challengeId, + }); + + // Expired challenge (> 5min old) + if ( + new Date().getTime() - attestationChallenge.createdAt.getTime() >= + 5 * 60 * 1000 + ) { + throw new Error('expired challenge'); + } + + const credentialIdString = credentialId.toString('hex'); + + await UserSecurityKeys.insert({ + userId: user.id, + id: credentialIdString, + lastUsed: new Date(), + name: ps.name, + publicKey: verificationData.publicKey.toString('hex'), + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, { + detail: true, + includeSecrets: true, + })); + + return { + id: credentialIdString, + name: ps.name, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts index 1b1a9df379..4bfa24f97f 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts @@ -1,28 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/_.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../../error.js'; +import define from '../../../define.js'; +import { UserProfiles } from '@/models/index.js'; export const meta = { - requireCredential: 'always', + requireCredential: true, secure: true, - - errors: { - noKey: { - message: 'No security key.', - code: 'NO_SECURITY_KEY', - id: 'f9c54d7f-d4c2-4d3c-9a8g-a70daac86512', - }, - }, } as const; export const paramDef = { @@ -33,50 +15,9 @@ export const paramDef = { required: ['value'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.userSecurityKeysRepository) - private userSecurityKeysRepository: UserSecurityKeysRepository, - - private userEntityService: UserEntityService, - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - if (ps.value === true) { - // セキュリティキーがなければパスワードレスを有効にはできない - const keyCount = await this.userSecurityKeysRepository.count({ - where: { - userId: me.id, - }, - select: { - id: true, - name: true, - lastUsed: true, - }, - }); - - if (keyCount === 0) { - await this.userProfilesRepository.update(me.id, { - usePasswordLessLogin: false, - }); - - throw new ApiError(meta.errors.noKey); - } - } - - await this.userProfilesRepository.update(me.id, { - usePasswordLessLogin: ps.value, - }); - - // Publish meUpdated event - this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { - detail: true, - includeSecrets: true, - })); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + await UserProfiles.update(user.id, { + usePasswordLessLogin: ps.value, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts index 21b1a94eb8..e906b82043 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts @@ -1,237 +1,61 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ +import bcrypt from 'bcryptjs'; +import define from '../../../define.js'; +import { UserProfiles, AttestationChallenges } from '@/models/index.js'; +import { promisify } from 'node:util'; +import * as crypto from 'node:crypto'; +import { genId } from '@/misc/gen-id.js'; +import { hash } from '../../../2fa.js'; -//import bcrypt from 'bcryptjs'; -import * as argon2 from 'argon2'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UserProfilesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { WebAuthnService } from '@/core/WebAuthnService.js'; -import { ApiError } from '@/server/api/error.js'; -import { UserAuthService } from '@/core/UserAuthService.js'; +const randomBytes = promisify(crypto.randomBytes); export const meta = { - requireCredential: 'always', + requireCredential: true, secure: true, - - errors: { - userNotFound: { - message: 'User not found.', - code: 'USER_NOT_FOUND', - id: '652f899f-66d4-490e-993e-6606c8ec04c3', - }, - - incorrectPassword: { - message: 'Incorrect password.', - code: 'INCORRECT_PASSWORD', - id: '38769596-efe2-4faf-9bec-abbb3f2cd9ba', - }, - - twoFactorNotEnabled: { - message: '2fa not enabled.', - code: 'TWO_FACTOR_NOT_ENABLED', - id: 'bf32b864-449b-47b8-974e-f9a5468546f1', - }, - }, - - res: { - type: 'object', - nullable: false, - optional: false, - properties: { - rp: { - type: 'object', - properties: { - id: { - type: 'string', - nullable: true, - }, - }, - }, - user: { - type: 'object', - properties: { - id: { - type: 'string', - }, - name: { - type: 'string', - }, - displayName: { - type: 'string', - }, - }, - }, - challenge: { - type: 'string', - }, - pubKeyCredParams: { - type: 'array', - items: { - type: 'object', - properties: { - type: { - type: 'string', - }, - alg: { - type: 'number', - }, - }, - }, - }, - timeout: { - type: 'number', - nullable: true, - }, - excludeCredentials: { - type: 'array', - nullable: true, - items: { - type: 'object', - properties: { - id: { - type: 'string', - }, - type: { - type: 'string', - }, - transports: { - type: 'array', - items: { - type: 'string', - enum: [ - "ble", - "cable", - "hybrid", - "internal", - "nfc", - "smart-card", - "usb", - ], - }, - }, - }, - }, - }, - authenticatorSelection: { - type: 'object', - nullable: true, - properties: { - authenticatorAttachment: { - type: 'string', - enum: [ - "cross-platform", - "platform", - ], - }, - requireResidentKey: { - type: 'boolean', - }, - userVerification: { - type: 'string', - enum: [ - "discouraged", - "preferred", - "required", - ], - }, - }, - }, - attestation: { - type: 'string', - nullable: true, - enum: [ - "direct", - "enterprise", - "indirect", - "none", - ], - }, - extensions: { - type: 'object', - nullable: true, - properties: { - appid: { - type: 'string', - nullable: true, - }, - credProps: { - type: 'boolean', - nullable: true, - }, - hmacCreateSecret: { - type: 'boolean', - nullable: true, - }, - }, - }, - }, - }, } as const; export const paramDef = { type: 'object', properties: { password: { type: 'string' }, - token: { type: 'string', nullable: true }, }, required: ['password'], } as const; // eslint-disable-next-line import/no-default-export -@Injectable() -export default class extends Endpoint { - constructor( - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, +export default define(meta, paramDef, async (ps, user) => { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - private webAuthnService: WebAuthnService, - private userAuthService: UserAuthService, - ) { - super(meta, paramDef, async (ps, me) => { - const token = ps.token; - const profile = await this.userProfilesRepository.findOne({ - where: { - userId: me.id, - }, - relations: ['user'], - }); + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); - if (profile == null) { - throw new ApiError(meta.errors.userNotFound); - } - - // Compare password - if (profile.twoFactorEnabled) { - if (token == null) { - throw new Error('authentication failed'); - } - - try { - await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { - throw new Error('authentication failed'); - } - } - - const passwordMatched = await argon2.verify(profile.password ?? '', ps.password); - if (!passwordMatched) { - throw new ApiError(meta.errors.incorrectPassword); - } - - if (!profile.twoFactorEnabled) { - throw new ApiError(meta.errors.twoFactorNotEnabled); - } - - return await this.webAuthnService.initiateRegistration( - me.id, - profile.user?.username ?? me.id, - profile.user?.name ?? undefined, - ); - }); + if (!same) { + throw new Error('incorrect password'); } -} + + if (!profile.twoFactorEnabled) { + throw new Error('2fa not enabled'); + } + + // 32 byte challenge + const entropy = await randomBytes(32); + const challenge = entropy.toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + const challengeId = genId(); + + await AttestationChallenges.insert({ + userId: user.id, + id: challengeId, + challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'), + createdAt: new Date(), + registrationChallenge: true, + }); + + return { + challengeId, + challenge, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts index f359251b44..33f5717728 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -1,112 +1,58 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -//import bcrypt from 'bcryptjs'; -import * as argon2 from 'argon2'; -import * as OTPAuth from 'otpauth'; +import bcrypt from 'bcryptjs'; +import * as speakeasy from 'speakeasy'; import * as QRCode from 'qrcode'; -import { Inject, Injectable } from '@nestjs/common'; -import type { UserProfilesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { ApiError } from '@/server/api/error.js'; -import { UserAuthService } from '@/core/UserAuthService.js'; +import config from '@/config/index.js'; +import { UserProfiles } from '@/models/index.js'; +import define from '../../../define.js'; export const meta = { - requireCredential: 'always', + requireCredential: true, secure: true, - - errors: { - incorrectPassword: { - message: 'Incorrect password.', - code: 'INCORRECT_PASSWORD', - id: '78d6c839-20c9-4c66-b90a-fc0542168b48', - }, - }, - - res: { - type: 'object', - nullable: false, - optional: false, - properties: { - qr: { type: 'string' }, - url: { type: 'string' }, - secret: { type: 'string' }, - label: { type: 'string' }, - issuer: { type: 'string' }, - }, - }, } as const; export const paramDef = { type: 'object', properties: { password: { type: 'string' }, - token: { type: 'string', nullable: true }, }, required: ['password'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.config) - private config: Config, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); - private userAuthService: UserAuthService, - ) { - super(meta, paramDef, async (ps, me) => { - const token = ps.token; - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); - - if (profile.twoFactorEnabled) { - if (token == null) { - throw new Error('authentication failed'); - } - - try { - await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { - throw new Error('authentication failed'); - } - } - - const passwordMatched = await argon2.verify(profile.password ?? '', ps.password); - if (!passwordMatched) { - throw new ApiError(meta.errors.incorrectPassword); - } - - // Generate user's secret key - const secret = new OTPAuth.Secret(); - - await this.userProfilesRepository.update(me.id, { - twoFactorTempSecret: secret.base32, - }); - - // Get the data URL of the authenticator URL - const totp = new OTPAuth.TOTP({ - secret, - digits: 6, - label: me.username, - issuer: this.config.host, - }); - const url = totp.toString(); - const qr = await QRCode.toDataURL(url); - - return { - qr, - url, - secret: secret.base32, - label: me.username, - issuer: this.config.host, - }; - }); + if (!same) { + throw new Error('incorrect password'); } -} + + // Generate user's secret key + const secret = speakeasy.generateSecret({ + length: 32, + }); + + await UserProfiles.update(user.id, { + twoFactorTempSecret: secret.base32, + }); + + // Get the data URL of the authenticator URL + const url = speakeasy.otpauthURL({ + secret: secret.base32, + encoding: 'base32', + label: user.username, + issuer: config.host, + }); + const dataUrl = await QRCode.toDataURL(url); + + return { + qr: dataUrl, + url, + secret: secret.base32, + label: user.username, + issuer: config.host, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts index 74bb53999e..eb2f75308d 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -1,109 +1,45 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -//import bcrypt from 'bcryptjs'; -import * as argon2 from 'argon2'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '@/server/api/error.js'; -import { UserAuthService } from '@/core/UserAuthService.js'; +import bcrypt from 'bcryptjs'; +import define from '../../../define.js'; +import { UserProfiles, UserSecurityKeys, Users } from '@/models/index.js'; +import { publishMainStream } from '@/services/stream.js'; export const meta = { - requireCredential: 'always', + requireCredential: true, secure: true, - - errors: { - incorrectPassword: { - message: 'Incorrect password.', - code: 'INCORRECT_PASSWORD', - id: '141c598d-a825-44c8-9173-cfb9d92be493', - }, - }, } as const; export const paramDef = { type: 'object', properties: { password: { type: 'string' }, - token: { type: 'string', nullable: true }, credentialId: { type: 'string' }, }, required: ['password', 'credentialId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userSecurityKeysRepository) - private userSecurityKeysRepository: UserSecurityKeysRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); - private userEntityService: UserEntityService, - private userAuthService: UserAuthService, - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - const token = ps.token; - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); - - // Compare password - if (profile.twoFactorEnabled) { - if (token == null) { - throw new Error('authentication failed'); - } - - try { - await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { - throw new Error('authentication failed'); - } - } - - const passwordMatched = await argon2.verify(profile.password ?? '', ps.password); - if (!passwordMatched) { - throw new ApiError(meta.errors.incorrectPassword); - } - - // Make sure we only delete the user's own creds - await this.userSecurityKeysRepository.delete({ - userId: me.id, - id: ps.credentialId, - }); - - // 使われているキーがなくなったらパスワードレスログインをやめる - const keyCount = await this.userSecurityKeysRepository.count({ - where: { - userId: me.id, - }, - select: { - id: true, - name: true, - lastUsed: true, - }, - }); - - if (keyCount === 0) { - await this.userProfilesRepository.update(me.id, { - usePasswordLessLogin: false, - }); - } - - // Publish meUpdated event - this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { - detail: true, - includeSecrets: true, - })); - - return {}; - }); + if (!same) { + throw new Error('incorrect password'); } -} + + // Make sure we only delete the user's own creds + await UserSecurityKeys.delete({ + userId: user.id, + id: ps.credentialId, + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, { + detail: true, + includeSecrets: true, + })); + + return {}; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts index 6ef57b83d9..45e7a98639 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -1,85 +1,34 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -//import bcrypt from 'bcryptjs'; -import * as argon2 from 'argon2'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import type { UserProfilesRepository } from '@/models/_.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '@/server/api/error.js'; -import { UserAuthService } from '@/core/UserAuthService.js'; +import bcrypt from 'bcryptjs'; +import define from '../../../define.js'; +import { UserProfiles } from '@/models/index.js'; export const meta = { - requireCredential: 'always', + requireCredential: true, secure: true, - - errors: { - incorrectPassword: { - message: 'Incorrect password.', - code: 'INCORRECT_PASSWORD', - id: '7add0395-9901-4098-82f9-4f67af65f775', - }, - }, } as const; export const paramDef = { type: 'object', properties: { password: { type: 'string' }, - token: { type: 'string', nullable: true }, }, required: ['password'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - private userEntityService: UserEntityService, - private userAuthService: UserAuthService, - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - const token = ps.token; - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); - if (profile.twoFactorEnabled) { - if (token == null) { - throw new Error('authentication failed'); - } - - try { - await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { - throw new Error('authentication failed'); - } - } - - const passwordMatched = await argon2.verify(profile.password ?? '', ps.password); - if (!passwordMatched) { - throw new ApiError(meta.errors.incorrectPassword); - } - - await this.userProfilesRepository.update(me.id, { - twoFactorSecret: null, - twoFactorBackupSecret: null, - twoFactorEnabled: false, - usePasswordLessLogin: false, - }); - - // Publish meUpdated event - this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { - detail: true, - includeSecrets: true, - })); - }); + if (!same) { + throw new Error('incorrect password'); } -} + + await UserProfiles.update(user.id, { + twoFactorSecret: null, + twoFactorEnabled: false, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts deleted file mode 100644 index 6c303909cd..0000000000 --- a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -//import bcrypt from 'bcryptjs'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UserSecurityKeysRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../../error.js'; - -export const meta = { - requireCredential: 'always', - - secure: true, - - errors: { - noSuchKey: { - message: 'No such key.', - code: 'NO_SUCH_KEY', - id: 'f9c5467f-d492-4d3c-9a8g-a70dacc86512', - }, - - accessDenied: { - message: 'You do not have edit privilege of this key.', - code: 'ACCESS_DENIED', - id: '1fb7cb09-d46a-4fff-b8df-057708cce513', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - name: { type: 'string', minLength: 1, maxLength: 30 }, - credentialId: { type: 'string' }, - }, - required: ['name', 'credentialId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userSecurityKeysRepository) - private userSecurityKeysRepository: UserSecurityKeysRepository, - - private userEntityService: UserEntityService, - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - const key = await this.userSecurityKeysRepository.findOneBy({ - id: ps.credentialId, - }); - - if (key == null) { - throw new ApiError(meta.errors.noSuchKey); - } - - if (key.userId !== me.id) { - throw new ApiError(meta.errors.accessDenied); - } - - await this.userSecurityKeysRepository.update(key.id, { - name: ps.name, - }); - - // Publish meUpdated event - this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { - detail: true, - includeSecrets: true, - })); - - return {}; - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/i/apps.ts b/packages/backend/src/server/api/endpoints/i/apps.ts index 3d83918e9d..eca9558847 100644 --- a/packages/backend/src/server/api/endpoints/i/apps.ts +++ b/packages/backend/src/server/api/endpoints/i/apps.ts @@ -1,49 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AccessTokensRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { IdService } from '@/core/IdService.js'; +import define from '../../define.js'; +import { AccessTokens } from '@/models/index.js'; export const meta = { - requireCredential: 'always', + requireCredential: true, secure: true, - - res: { - type: 'array', - items: { - type: 'object', - properties: { - id: { - type: 'string', - format: 'misskey:id', - }, - name: { - type: 'string', - }, - createdAt: { - type: 'string', - format: 'date-time', - }, - lastUsedAt: { - type: 'string', - format: 'date-time', - }, - permission: { - type: 'array', - uniqueItems: true, - items: { - type: 'string' - }, - } - }, - }, - }, } as const; export const paramDef = { @@ -54,36 +15,26 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.accessTokensRepository) - private accessTokensRepository: AccessTokensRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = AccessTokens.createQueryBuilder('token') + .where('token.userId = :userId', { userId: user.id }); - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.accessTokensRepository.createQueryBuilder('token') - .where('token.userId = :userId', { userId: me.id }) - .leftJoinAndSelect('token.app', 'app'); - - switch (ps.sort) { - case '+createdAt': query.orderBy('token.id', 'DESC'); break; - case '-createdAt': query.orderBy('token.id', 'ASC'); break; - case '+lastUsedAt': query.orderBy('token.lastUsedAt', 'DESC'); break; - case '-lastUsedAt': query.orderBy('token.lastUsedAt', 'ASC'); break; - default: query.orderBy('token.id', 'ASC'); break; - } - - const tokens = await query.getMany(); - - return await Promise.all(tokens.map(token => ({ - id: token.id, - name: token.name ?? token.app?.name, - createdAt: this.idService.parse(token.id).date.toISOString(), - lastUsedAt: token.lastUsedAt?.toISOString(), - permission: token.permission, - }))); - }); + switch (ps.sort) { + case '+createdAt': query.orderBy('token.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('token.createdAt', 'ASC'); break; + case '+lastUsedAt': query.orderBy('token.lastUsedAt', 'DESC'); break; + case '-lastUsedAt': query.orderBy('token.lastUsedAt', 'ASC'); break; + default: query.orderBy('token.id', 'ASC'); break; } -} + + const tokens = await query.getMany(); + + return await Promise.all(tokens.map(token => ({ + id: token.id, + name: token.name, + createdAt: token.createdAt, + lastUsedAt: token.lastUsedAt, + permission: token.permission, + }))); +}); diff --git a/packages/backend/src/server/api/endpoints/i/authorized-apps.ts b/packages/backend/src/server/api/endpoints/i/authorized-apps.ts index 597c5894ae..68bd103a6d 100644 --- a/packages/backend/src/server/api/endpoints/i/authorized-apps.ts +++ b/packages/backend/src/server/api/endpoints/i/authorized-apps.ts @@ -1,49 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, Not } from 'typeorm'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AccessTokensRepository } from '@/models/_.js'; -import { AppEntityService } from '@/core/entities/AppEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { AccessTokens, Apps } from '@/models/index.js'; export const meta = { - requireCredential: 'always', + requireCredential: true, secure: true, - - res: { - type: 'array', - items: { - type: 'object', - properties: { - id: { - type: 'string', - format: 'misskey:id', - }, - name: { - type: 'string', - }, - callbackUrl: { - type: 'string', - nullable: true, - }, - permission: { - type: 'array', - uniqueItems: true, - items: { - type: 'string' - }, - }, - isAuthorized: { - type: 'boolean', - }, - }, - }, - }, } as const; export const paramDef = { @@ -51,36 +12,26 @@ export const paramDef = { properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, offset: { type: 'integer', default: 0 }, - sort: { type: 'string', enum: ['desc', 'asc'], default: 'desc' }, + sort: { type: 'string', enum: ['desc', 'asc'], default: "desc" }, }, required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.accessTokensRepository) - private accessTokensRepository: AccessTokensRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Get tokens + const tokens = await AccessTokens.find({ + where: { + userId: user.id, + }, + take: ps.limit, + skip: ps.offset, + order: { + id: ps.sort === 'asc' ? 1 : -1, + }, + }); - private appEntityService: AppEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - // Get tokens - const tokens = await this.accessTokensRepository.find({ - where: { - userId: me.id, - appId: Not(IsNull()), - }, - take: ps.limit, - skip: ps.offset, - order: { - id: ps.sort === 'asc' ? 1 : -1, - }, - }); - - return await Promise.all(tokens.map(token => this.appEntityService.pack(token.appId!, me, { - detail: true, - }))); - }); - } -} + return await Promise.all(tokens.map(token => Apps.pack(token.appId, user, { + detail: true, + }))); +}); diff --git a/packages/backend/src/server/api/endpoints/i/change-password.ts b/packages/backend/src/server/api/endpoints/i/change-password.ts index de7ccf4bd1..f9f6a33a80 100644 --- a/packages/backend/src/server/api/endpoints/i/change-password.ts +++ b/packages/backend/src/server/api/endpoints/i/change-password.ts @@ -1,18 +1,9 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -//import bcrypt from 'bcryptjs'; -import * as argon2 from 'argon2'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UserProfilesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { UserAuthService } from '@/core/UserAuthService.js'; +import bcrypt from 'bcryptjs'; +import define from '../../define.js'; +import { UserProfiles } from '@/models/index.js'; export const meta = { - requireCredential: 'always', + requireCredential: true, secure: true, } as const; @@ -22,48 +13,26 @@ export const paramDef = { properties: { currentPassword: { type: 'string' }, newPassword: { type: 'string', minLength: 1 }, - token: { type: 'string', nullable: true }, }, required: ['currentPassword', 'newPassword'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - private userAuthService: UserAuthService, - ) { - super(meta, paramDef, async (ps, me) => { - const token = ps.token; - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); + // Compare password + const same = await bcrypt.compare(ps.currentPassword, profile.password!); - if (profile.twoFactorEnabled) { - if (token == null) { - throw new Error('authentication failed'); - } - - try { - await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { - throw new Error('authentication failed'); - } - } - - const passwordMatched = await argon2.verify(profile.password!, ps.currentPassword); - - if (!passwordMatched) { - throw new Error('incorrect password'); - } - - // Generate hash of password - //const salt = await bcrypt.genSalt(8); - const hash = await argon2.hash(ps.newPassword); - - await this.userProfilesRepository.update(me.id, { - password: hash, - }); - }); + if (!same) { + throw new Error('incorrect password'); } -} + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(ps.newPassword, salt); + + await UserProfiles.update(user.id, { + password: hash, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts deleted file mode 100644 index 6277ff7af4..0000000000 --- a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { AchievementService, ACHIEVEMENT_TYPES } from '@/core/AchievementService.js'; -import { MetaService } from '@/core/MetaService.js'; - -export const meta = { - requireCredential: 'always', - prohibitMoved: true, - kind: 'write:account', -} as const; - -export const paramDef = { - type: 'object', - properties: { - name: { type: 'string', enum: ACHIEVEMENT_TYPES }, - }, - required: ['name'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private achievementService: AchievementService, - private metaService: MetaService, - ) { - super(meta, paramDef, async (ps, me) => { - const meta = await this.metaService.fetch(); - if (!meta.enableAchievements) return; - - await this.achievementService.create(me.id, ps.name); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts index 16bc5223ed..ede4a9d03b 100644 --- a/packages/backend/src/server/api/endpoints/i/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts @@ -1,19 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -//import bcrypt from 'bcryptjs'; -import * as argon2 from 'argon2'; -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DeleteAccountService } from '@/core/DeleteAccountService.js'; -import { DI } from '@/di-symbols.js'; -import { UserAuthService } from '@/core/UserAuthService.js'; +import bcrypt from 'bcryptjs'; +import { UserProfiles, Users } from '@/models/index.js'; +import { deleteAccount } from '@/services/delete-account.js'; +import define from '../../define.js'; export const meta = { - requireCredential: 'always', + requireCredential: true, secure: true, } as const; @@ -22,50 +13,24 @@ export const paramDef = { type: 'object', properties: { password: { type: 'string' }, - token: { type: 'string', nullable: true }, }, required: ['password'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - private userAuthService: UserAuthService, - private deleteAccountService: DeleteAccountService, - ) { - super(meta, paramDef, async (ps, me) => { - const token = ps.token; - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); - - if (profile.twoFactorEnabled) { - if (token == null) { - throw new Error('authentication failed'); - } - - try { - await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { - throw new Error('authentication failed'); - } - } - - const userDetailed = await this.usersRepository.findOneByOrFail({ id: me.id }); - if (userDetailed.isDeleted) { - return; - } - - const passwordMatched = await argon2.verify(profile.password!, ps.password); - if (!passwordMatched) { - throw new Error('incorrect password'); - } - - await this.deleteAccountService.deleteAccount(me); - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + const userDetailed = await Users.findOneByOrFail({ id: user.id }); + if (userDetailed.isDeleted) { + return; } -} + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + await deleteAccount(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-antennas.ts b/packages/backend/src/server/api/endpoints/i/export-antennas.ts deleted file mode 100644 index 052818a97d..0000000000 --- a/packages/backend/src/server/api/endpoints/i/export-antennas.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * 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 { QueueService } from '@/core/QueueService.js'; - -export const meta = { - secure: true, - requireCredential: 'always', - limit: { - duration: ms('1hour'), - max: 1, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: {}, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor ( - private queueService: QueueService, - ) { - super(meta, paramDef, async (ps, me) => { - this.queueService.createExportAntennasJob(me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/i/export-blocking.ts b/packages/backend/src/server/api/endpoints/i/export-blocking.ts index 85a9ccb3ec..aed4c2e0a3 100644 --- a/packages/backend/src/server/api/endpoints/i/export-blocking.ts +++ b/packages/backend/src/server/api/endpoints/i/export-blocking.ts @@ -1,16 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; +import define from '../../define.js'; +import { createExportBlockingJob } from '@/queue/index.js'; import ms from 'ms'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueueService } from '@/core/QueueService.js'; export const meta = { secure: true, - requireCredential: 'always', + requireCredential: true, limit: { duration: ms('1hour'), max: 1, @@ -23,13 +17,7 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private queueService: QueueService, - ) { - super(meta, paramDef, async (ps, me) => { - this.queueService.createExportBlockingJob(me); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + createExportBlockingJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-data.ts b/packages/backend/src/server/api/endpoints/i/export-data.ts deleted file mode 100644 index dbf7a970e5..0000000000 --- a/packages/backend/src/server/api/endpoints/i/export-data.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * 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 { QueueService } from '@/core/QueueService.js'; - -export const meta = { - secure: true, - requireCredential: 'always', - limit: { - duration: ms('3days'), - max: 1, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: {}, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private queueService: QueueService, - ) { - super(meta, paramDef, async (ps, me) => { - this.queueService.createExportAccountDataJob(me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/i/export-favorites.ts b/packages/backend/src/server/api/endpoints/i/export-favorites.ts deleted file mode 100644 index f9f0a52993..0000000000 --- a/packages/backend/src/server/api/endpoints/i/export-favorites.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * 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 { QueueService } from '@/core/QueueService.js'; - -export const meta = { - secure: true, - requireCredential: 'always', - limit: { - duration: ms('1day'), - max: 1, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: {}, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private queueService: QueueService, - ) { - super(meta, paramDef, async (ps, me) => { - this.queueService.createExportFavoritesJob(me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/i/export-following.ts b/packages/backend/src/server/api/endpoints/i/export-following.ts index 6648c45d21..058d77b3c2 100644 --- a/packages/backend/src/server/api/endpoints/i/export-following.ts +++ b/packages/backend/src/server/api/endpoints/i/export-following.ts @@ -1,16 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; +import define from '../../define.js'; +import { createExportFollowingJob } from '@/queue/index.js'; import ms from 'ms'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueueService } from '@/core/QueueService.js'; export const meta = { secure: true, - requireCredential: 'always', + requireCredential: true, limit: { duration: ms('1hour'), max: 1, @@ -26,13 +20,7 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private queueService: QueueService, - ) { - super(meta, paramDef, async (ps, me) => { - this.queueService.createExportFollowingJob(me, ps.excludeMuting, ps.excludeInactive); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + createExportFollowingJob(user, ps.excludeMuting, ps.excludeInactive); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-mute.ts b/packages/backend/src/server/api/endpoints/i/export-mute.ts index af15b79c83..c0216fac0c 100644 --- a/packages/backend/src/server/api/endpoints/i/export-mute.ts +++ b/packages/backend/src/server/api/endpoints/i/export-mute.ts @@ -1,16 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; +import define from '../../define.js'; +import { createExportMuteJob } from '@/queue/index.js'; import ms from 'ms'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueueService } from '@/core/QueueService.js'; export const meta = { secure: true, - requireCredential: 'always', + requireCredential: true, limit: { duration: ms('1hour'), max: 1, @@ -23,13 +17,7 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private queueService: QueueService, - ) { - super(meta, paramDef, async (ps, me) => { - this.queueService.createExportMuteJob(me); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + createExportMuteJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-notes.ts b/packages/backend/src/server/api/endpoints/i/export-notes.ts index 29b1fc65a2..4b85a45554 100644 --- a/packages/backend/src/server/api/endpoints/i/export-notes.ts +++ b/packages/backend/src/server/api/endpoints/i/export-notes.ts @@ -1,16 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; +import define from '../../define.js'; +import { createExportNotesJob } from '@/queue/index.js'; import ms from 'ms'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueueService } from '@/core/QueueService.js'; export const meta = { secure: true, - requireCredential: 'always', + requireCredential: true, limit: { duration: ms('1day'), max: 1, @@ -23,13 +17,7 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private queueService: QueueService, - ) { - super(meta, paramDef, async (ps, me) => { - this.queueService.createExportNotesJob(me); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + createExportNotesJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-user-lists.ts b/packages/backend/src/server/api/endpoints/i/export-user-lists.ts index 8a478bbe89..fa5c1f5e5a 100644 --- a/packages/backend/src/server/api/endpoints/i/export-user-lists.ts +++ b/packages/backend/src/server/api/endpoints/i/export-user-lists.ts @@ -1,16 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; +import define from '../../define.js'; +import { createExportUserListsJob } from '@/queue/index.js'; import ms from 'ms'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueueService } from '@/core/QueueService.js'; export const meta = { secure: true, - requireCredential: 'always', + requireCredential: true, limit: { duration: ms('1min'), max: 1, @@ -23,13 +17,7 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private queueService: QueueService, - ) { - super(meta, paramDef, async (ps, me) => { - this.queueService.createExportUserListsJob(me); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + createExportUserListsJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/favorites.ts b/packages/backend/src/server/api/endpoints/i/favorites.ts index 0b7b39c5ca..3c420e4d0f 100644 --- a/packages/backend/src/server/api/endpoints/i/favorites.ts +++ b/packages/backend/src/server/api/endpoints/i/favorites.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { NoteFavoritesRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { NoteFavoriteEntityService } from '@/core/entities/NoteFavoriteEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { NoteFavorites } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['account', 'notes', 'favorites'], - requireCredential: 'always', + requireCredential: true, kind: 'read:favorites', @@ -38,25 +30,15 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.noteFavoritesRepository) - private noteFavoritesRepository: NoteFavoritesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery(NoteFavorites.createQueryBuilder('favorite'), ps.sinceId, ps.untilId) + .andWhere(`favorite.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('favorite.note', 'note'); - private noteFavoriteEntityService: NoteFavoriteEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.noteFavoritesRepository.createQueryBuilder('favorite'), ps.sinceId, ps.untilId) - .andWhere('favorite.userId = :meId', { meId: me.id }) - .leftJoinAndSelect('favorite.note', 'note'); + const favorites = await query + .take(ps.limit) + .getMany(); - const favorites = await query - .limit(ps.limit) - .getMany(); - - return await this.noteFavoriteEntityService.packMany(favorites, me); - }); - } -} + return await NoteFavorites.packMany(favorites, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/gallery/likes.ts b/packages/backend/src/server/api/endpoints/i/gallery/likes.ts index 021cf22d73..a38383f30e 100644 --- a/packages/backend/src/server/api/endpoints/i/gallery/likes.ts +++ b/packages/backend/src/server/api/endpoints/i/gallery/likes.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { GalleryLikesRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { GalleryLikeEntityService } from '@/core/entities/GalleryLikeEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; +import { GalleryLikes } from '@/models/index.js'; +import { makePaginationQuery } from '../../../common/make-pagination-query.js'; export const meta = { tags: ['account', 'gallery'], - requireCredential: 'always', + requireCredential: true, kind: 'read:gallery-likes', @@ -35,7 +27,7 @@ export const meta = { ref: 'GalleryPost', }, }, - }, + } }, } as const; @@ -49,25 +41,15 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.galleryLikesRepository) - private galleryLikesRepository: GalleryLikesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery(GalleryLikes.createQueryBuilder('like'), ps.sinceId, ps.untilId) + .andWhere(`like.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('like.post', 'post'); - private galleryLikeEntityService: GalleryLikeEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.galleryLikesRepository.createQueryBuilder('like'), ps.sinceId, ps.untilId) - .andWhere('like.userId = :meId', { meId: me.id }) - .leftJoinAndSelect('like.post', 'post'); + const likes = await query + .take(ps.limit) + .getMany(); - const likes = await query - .limit(ps.limit) - .getMany(); - - return await this.galleryLikeEntityService.packMany(likes, me); - }); - } -} + return await GalleryLikes.packMany(likes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/gallery/posts.ts b/packages/backend/src/server/api/endpoints/i/gallery/posts.ts index ea2fdb53c1..b4edb5f73d 100644 --- a/packages/backend/src/server/api/endpoints/i/gallery/posts.ts +++ b/packages/backend/src/server/api/endpoints/i/gallery/posts.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { GalleryPostsRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; +import { GalleryPosts } from '@/models/index.js'; +import { makePaginationQuery } from '../../../common/make-pagination-query.js'; export const meta = { tags: ['account', 'gallery'], - requireCredential: 'always', + requireCredential: true, kind: 'read:gallery', @@ -38,24 +30,14 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.galleryPostsRepository) - private galleryPostsRepository: GalleryPostsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId) + .andWhere(`post.userId = :meId`, { meId: user.id }); - private galleryPostEntityService: GalleryPostEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.galleryPostsRepository.createQueryBuilder('post'), ps.sinceId, ps.untilId) - .andWhere('post.userId = :meId', { meId: me.id }); + const posts = await query + .take(ps.limit) + .getMany(); - const posts = await query - .limit(ps.limit) - .getMany(); - - return await this.galleryPostEntityService.packMany(posts, me); - }); - } -} + return await GalleryPosts.packMany(posts, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts b/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts new file mode 100644 index 0000000000..e7d7518c5b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts @@ -0,0 +1,37 @@ +import define from '../../define.js'; +import { MutedNotes } from '@/models/index.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'read:account', + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + count: { + type: 'number', + optional: false, nullable: false, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + return { + count: await MutedNotes.countBy({ + userId: user.id, + reason: 'word', + }), + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-antennas.ts b/packages/backend/src/server/api/endpoints/i/import-antennas.ts deleted file mode 100644 index 574b8e71c8..0000000000 --- a/packages/backend/src/server/api/endpoints/i/import-antennas.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import ms from 'ms'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueueService } from '@/core/QueueService.js'; -import type { AntennasRepository, DriveFilesRepository, UsersRepository, MiAntenna as _Antenna } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; -import { DownloadService } from '@/core/DownloadService.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - secure: true, - requireCredential: 'always', - prohibitMoved: true, - - limit: { - duration: ms('1hour'), - max: 1, - }, - errors: { - noSuchFile: { - message: 'No such file.', - code: 'NO_SUCH_FILE', - id: '3b71d086-c3fa-431c-b01d-ded65a777172', - }, - noSuchUser: { - message: 'No such user.', - code: 'NO_SUCH_USER', - id: 'e842c379-8ac7-4cf7-b07a-4d4de7e4671c', - }, - emptyFile: { - message: 'That file is empty.', - code: 'EMPTY_FILE', - id: '7f60115d-8d93-4b0f-bd0e-3815dcbb389f', - }, - tooManyAntennas: { - message: 'You cannot create antenna any more.', - code: 'TOO_MANY_ANTENNAS', - id: '600917d4-a4cb-4cc5-8ba8-7ac8ea3c7779', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - fileId: { type: 'string', format: 'misskey:id' }, - }, - required: ['fileId'], -} as const; - -@Injectable() // eslint-disable-next-line import/no-default-export -export default class extends Endpoint { - constructor ( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - @Inject(DI.antennasRepository) - private antennasRepository: AntennasRepository, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private roleService: RoleService, - private queueService: QueueService, - private downloadService: DownloadService, - ) { - super(meta, paramDef, async (ps, me) => { - const userExist = await this.usersRepository.exist({ where: { id: me.id } }); - if (!userExist) throw new ApiError(meta.errors.noSuchUser); - const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); - if (file === null) throw new ApiError(meta.errors.noSuchFile); - if (file.size === 0) throw new ApiError(meta.errors.emptyFile); - const antennas: (_Antenna & { userListAccts: string[] | null })[] = JSON.parse(await this.downloadService.downloadTextFile(file.url)); - const currentAntennasCount = await this.antennasRepository.countBy({ userId: me.id }); - if (currentAntennasCount + antennas.length > (await this.roleService.getUserPolicies(me.id)).antennaLimit) { - throw new ApiError(meta.errors.tooManyAntennas); - } - this.queueService.createImportAntennasJob(me, antennas); - }); - } -} - -export type Antenna = (_Antenna & { userListAccts: string[] | null })[]; diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts index 8dc2fc467a..0bcbf37ddd 100644 --- a/packages/backend/src/server/api/endpoints/i/import-blocking.ts +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -1,21 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; +import define from '../../define.js'; +import { createImportBlockingJob } from '@/queue/index.js'; import ms from 'ms'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueueService } from '@/core/QueueService.js'; -import { AccountMoveService } from '@/core/AccountMoveService.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; +import { DriveFiles } from '@/models/index.js'; export const meta = { secure: true, - requireCredential: 'always', - prohibitMoved: true, + requireCredential: true, limit: { duration: ms('1hour'), @@ -57,30 +48,14 @@ export const paramDef = { required: ['fileId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const file = await DriveFiles.findOneBy({ id: ps.fileId }); - private queueService: QueueService, - private accountMoveService: AccountMoveService, - ) { - super(meta, paramDef, async (ps, me) => { - const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); - if (file == null) throw new ApiError(meta.errors.noSuchFile); - //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size === 0) throw new ApiError(meta.errors.emptyFile); - - const checkMoving = await this.accountMoveService.validateAlsoKnownAs( - me, - (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), - true, - ); - if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); - - this.queueService.createImportBlockingJob(me, file.id); - }); - } -} + createImportBlockingJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts index e9f161ba7d..ee2abbea19 100644 --- a/packages/backend/src/server/api/endpoints/i/import-following.ts +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -1,21 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; +import define from '../../define.js'; +import { createImportFollowingJob } from '@/queue/index.js'; import ms from 'ms'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueueService } from '@/core/QueueService.js'; -import { AccountMoveService } from '@/core/AccountMoveService.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; +import { DriveFiles } from '@/models/index.js'; export const meta = { secure: true, - requireCredential: 'always', - prohibitMoved: true, + requireCredential: true, limit: { duration: ms('1hour'), max: 1, @@ -52,35 +43,18 @@ export const paramDef = { type: 'object', properties: { fileId: { type: 'string', format: 'misskey:id' }, - withReplies: { type: 'boolean' }, }, required: ['fileId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const file = await DriveFiles.findOneBy({ id: ps.fileId }); - private queueService: QueueService, - private accountMoveService: AccountMoveService, - ) { - super(meta, paramDef, async (ps, me) => { - const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); - if (file == null) throw new ApiError(meta.errors.noSuchFile); - //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size === 0) throw new ApiError(meta.errors.emptyFile); - - const checkMoving = await this.accountMoveService.validateAlsoKnownAs( - me, - (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), - true, - ); - if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); - - this.queueService.createImportFollowingJob(me, file.id, ps.withReplies); - }); - } -} + createImportFollowingJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts index 89d8e0d814..b3b3b39238 100644 --- a/packages/backend/src/server/api/endpoints/i/import-muting.ts +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -1,21 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; +import define from '../../define.js'; +import { createImportMutingJob } from '@/queue/index.js'; import ms from 'ms'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueueService } from '@/core/QueueService.js'; -import { AccountMoveService } from '@/core/AccountMoveService.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; +import { DriveFiles } from '@/models/index.js'; export const meta = { secure: true, - requireCredential: 'always', - prohibitMoved: true, + requireCredential: true, limit: { duration: ms('1hour'), @@ -57,30 +48,14 @@ export const paramDef = { required: ['fileId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const file = await DriveFiles.findOneBy({ id: ps.fileId }); - private queueService: QueueService, - private accountMoveService: AccountMoveService, - ) { - super(meta, paramDef, async (ps, me) => { - const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); - if (file == null) throw new ApiError(meta.errors.noSuchFile); - //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size === 0) throw new ApiError(meta.errors.emptyFile); - - const checkMoving = await this.accountMoveService.validateAlsoKnownAs( - me, - (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), - true, - ); - if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); - - this.queueService.createImportMutingJob(me, file.id); - }); - } -} + createImportMutingJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-notes.ts b/packages/backend/src/server/api/endpoints/i/import-notes.ts deleted file mode 100644 index 1b242b2791..0000000000 --- a/packages/backend/src/server/api/endpoints/i/import-notes.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import ms from 'ms'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueueService } from '@/core/QueueService.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - secure: true, - requireCredential: 'always', - prohibitMoved: true, - limit: { - duration: ms('1hour'), - max: 2, - }, - - errors: { - noSuchFile: { - message: 'No such file.', - code: 'NO_SUCH_FILE', - id: 'b98644cf-a5ac-4277-a502-0b8054a709a3', - }, - - emptyFile: { - message: 'That file is empty.', - code: 'EMPTY_FILE', - id: '31a1b42c-06f7-42ae-8a38-a661c5c9f691', - }, - - notPermitted: { - message: 'You are not allowed to import notes.', - code: 'NO_PERMISSION', - id: '31a1b42c-06f7-42ae-8a38-a661c5c9f692', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - fileId: { type: 'string', format: 'misskey:id' }, - type: { type: 'string', nullable: true }, - }, - required: ['fileId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private queueService: QueueService, - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); - - if (file == null) throw new ApiError(meta.errors.noSuchFile); - - if (file.size === 0) throw new ApiError(meta.errors.emptyFile); - - if ((await this.roleService.getUserPolicies(me.id)).canImportNotes === false) { - throw new ApiError(meta.errors.notPermitted); - } - - this.queueService.createImportNotesJob(me, file.id, ps.type); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts index c793ca708c..64f5ec05fd 100644 --- a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts +++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts @@ -1,21 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; +import define from '../../define.js'; +import { createImportUserListsJob } from '@/queue/index.js'; import ms from 'ms'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueueService } from '@/core/QueueService.js'; -import { AccountMoveService } from '@/core/AccountMoveService.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; +import { DriveFiles } from '@/models/index.js'; export const meta = { secure: true, - requireCredential: 'always', - prohibitMoved: true, + requireCredential: true, limit: { duration: ms('1hour'), max: 1, @@ -56,30 +47,14 @@ export const paramDef = { required: ['fileId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const file = await DriveFiles.findOneBy({ id: ps.fileId }); - private queueService: QueueService, - private accountMoveService: AccountMoveService, - ) { - super(meta, paramDef, async (ps, me) => { - const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 30000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); - if (file == null) throw new ApiError(meta.errors.noSuchFile); - //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size === 0) throw new ApiError(meta.errors.emptyFile); - - const checkMoving = await this.accountMoveService.validateAlsoKnownAs( - me, - (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), - true, - ); - if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); - - this.queueService.createImportUserListsJob(me, file.id); - }); - } -} + createImportUserListsJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts deleted file mode 100644 index 28934072eb..0000000000 --- a/packages/backend/src/server/api/endpoints/i/move.ts +++ /dev/null @@ -1,132 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * 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 { ApiError } from '@/server/api/error.js'; - -import { MiLocalUser, MiRemoteUser } from '@/models/User.js'; - -import { AccountMoveService } from '@/core/AccountMoveService.js'; -import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; -import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; - -import * as Acct from '@/misc/acct.js'; - -export const meta = { - tags: ['users'], - - secure: true, - requireCredential: 'always', - prohibitMoved: true, - limit: { - duration: ms('1day'), - max: 5, - }, - - errors: { - destinationAccountForbids: { - message: - 'Destination account doesn\'t have proper \'Known As\' alias, or has already moved.', - code: 'DESTINATION_ACCOUNT_FORBIDS', - id: 'b5c90186-4ab0-49c8-9bba-a1f766282ba4', - }, - rootForbidden: { - message: 'The root can\'t migrate.', - code: 'NOT_ROOT_FORBIDDEN', - id: '4362e8dc-731f-4ad8-a694-be2a88922a24', - }, - noSuchUser: { - message: 'No such user.', - code: 'NO_SUCH_USER', - id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', - }, - uriNull: { - message: 'User ActivityPup URI is null.', - code: 'URI_NULL', - id: 'bf326f31-d430-4f97-9933-5d61e4d48a23', - }, - localUriNull: { - message: 'Local User ActivityPup URI is null.', - code: 'URI_NULL', - id: '95ba11b9-90e8-43a5-ba16-7acc1ab32e71', - }, - alreadyMoved: { - message: 'Account was already moved to another account.', - code: 'ALREADY_MOVED', - id: 'b234a14e-9ebe-4581-8000-074b3c215962', - }, - }, - - res: { - type: 'object', - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - moveToAccount: { type: 'string' }, - }, - required: ['moveToAccount'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private remoteUserResolveService: RemoteUserResolveService, - private apiLoggerService: ApiLoggerService, - private accountMoveService: AccountMoveService, - private getterService: GetterService, - private apPersonService: ApPersonService, - private userEntityService: UserEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - // check parameter - if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchUser); - // abort if user is the root - if (me.isRoot) throw new ApiError(meta.errors.rootForbidden); - // abort if user has already moved - if (me.movedToUri) throw new ApiError(meta.errors.alreadyMoved); - - // parse user's input into the destination account - const { username, host } = Acct.parse(ps.moveToAccount); - // retrieve the destination account - let moveTo = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => { - this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`); - throw new ApiError(meta.errors.noSuchUser); - }); - const destination = await this.getterService.getUser(moveTo.id) as MiLocalUser | MiRemoteUser; - const newUri = this.userEntityService.getUserUri(destination); - - // update local db - await this.apPersonService.updatePerson(newUri); - // retrieve updated user - moveTo = await this.apPersonService.resolvePerson(newUri); - - // make sure that the user has indicated the old account as an alias - const fromUrl = this.userEntityService.genLocalUserUri(me.id); - let allowed = false; - if (moveTo.alsoKnownAs) { - for (const knownAs of moveTo.alsoKnownAs) { - if (knownAs.includes(fromUrl)) { - allowed = true; - break; - } - } - } - - // abort if unintended - if (!allowed || moveTo.movedToUri) throw new ApiError(meta.errors.destinationAccountForbids); - - return await this.accountMoveService.moveFromLocal(me, moveTo); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts deleted file mode 100644 index 03b8b3953f..0000000000 --- a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Brackets, In } from 'typeorm'; -import * as Redis from 'ioredis'; -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; -import { obsoleteNotificationTypes, notificationTypes, FilterUnionByProperty } from '@/types.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; -import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; -import { NotificationService } from '@/core/NotificationService.js'; -import { DI } from '@/di-symbols.js'; -import { IdService } from '@/core/IdService.js'; -import { MiGroupedNotification, MiNotification } from '@/models/Notification.js'; - -export const meta = { - tags: ['account', 'notifications'], - - requireCredential: 'always', - - limit: { - duration: 30000, - max: 30, - }, - - kind: 'read:notifications', - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'Notification', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - markAsRead: { type: 'boolean', default: true }, - // 後方互換のため、廃止された通知タイプも受け付ける - includeTypes: { type: 'array', items: { - type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], - } }, - excludeTypes: { type: 'array', items: { - type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], - } }, - }, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - private idService: IdService, - private notificationEntityService: NotificationEntityService, - private notificationService: NotificationService, - private noteReadService: NoteReadService, - ) { - super(meta, paramDef, async (ps, me) => { - const EXTRA_LIMIT = 100; - - // includeTypes が空の場合はクエリしない - if (ps.includeTypes && ps.includeTypes.length === 0) { - return []; - } - // excludeTypes に全指定されている場合はクエリしない - if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) { - return []; - } - - const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; - const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; - - const limit = (ps.limit + EXTRA_LIMIT) + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 - const notificationsRes = await this.redisClient.xrevrange( - `notificationTimeline:${me.id}`, - ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', - ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : '-', - 'COUNT', limit); - - if (notificationsRes.length === 0) { - return []; - } - - let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId && x !== ps.sinceId) as MiNotification[]; - - if (includeTypes && includeTypes.length > 0) { - notifications = notifications.filter(notification => includeTypes.includes(notification.type)); - } else if (excludeTypes && excludeTypes.length > 0) { - notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); - } - - if (notifications.length === 0) { - return []; - } - - // Mark all as read - if (ps.markAsRead) { - this.notificationService.readAllNotification(me.id); - } - - // grouping - let groupedNotifications = [notifications[0]] as MiGroupedNotification[]; - for (let i = 1; i < notifications.length; i++) { - const notification = notifications[i]; - const prev = notifications[i - 1]; - let prevGroupedNotification = groupedNotifications.at(-1)!; - - if (prev.type === 'reaction' && notification.type === 'reaction' && prev.noteId === notification.noteId) { - if (prevGroupedNotification.type !== 'reaction:grouped') { - groupedNotifications[groupedNotifications.length - 1] = { - type: 'reaction:grouped', - id: '', - createdAt: prev.createdAt, - noteId: prev.noteId!, - reactions: [{ - userId: prev.notifierId!, - reaction: prev.reaction!, - }], - }; - prevGroupedNotification = groupedNotifications.at(-1)!; - } - (prevGroupedNotification as FilterUnionByProperty).reactions.push({ - userId: notification.notifierId!, - reaction: notification.reaction!, - }); - prevGroupedNotification.id = notification.id; - continue; - } - if (prev.type === 'renote' && notification.type === 'renote' && prev.targetNoteId === notification.targetNoteId) { - if (prevGroupedNotification.type !== 'renote:grouped') { - groupedNotifications[groupedNotifications.length - 1] = { - type: 'renote:grouped', - id: '', - createdAt: notification.createdAt, - noteId: prev.noteId!, - userIds: [prev.notifierId!], - }; - prevGroupedNotification = groupedNotifications.at(-1)!; - } - (prevGroupedNotification as FilterUnionByProperty).userIds.push(notification.notifierId!); - prevGroupedNotification.id = notification.id; - continue; - } - - groupedNotifications.push(notification); - } - - groupedNotifications = groupedNotifications.slice(0, ps.limit); - - const noteIds = groupedNotifications - .filter((notification): notification is FilterUnionByProperty => ['mention', 'reply', 'quote'].includes(notification.type)) - .map(notification => notification.noteId!); - - if (noteIds.length > 0) { - const notes = await this.notesRepository.findBy({ id: In(noteIds) }); - this.noteReadService.read(me.id, notes); - } - - return await this.notificationEntityService.packGroupedMany(groupedNotifications, me.id); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index abf636c05e..2b343dabdd 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -1,29 +1,19 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Brackets, In } from 'typeorm'; -import * as Redis from 'ioredis'; -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; -import { obsoleteNotificationTypes, notificationTypes, FilterUnionByProperty } from '@/types.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; -import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; -import { NotificationService } from '@/core/NotificationService.js'; -import { DI } from '@/di-symbols.js'; -import { IdService } from '@/core/IdService.js'; -import { MiNotification } from '@/models/Notification.js'; +import { Brackets } from 'typeorm'; +import { Notifications, Followings, Mutings, Users, UserProfiles } from '@/models/index.js'; +import { notificationTypes } from '@/types.js'; +import read from '@/services/note/read.js'; +import { readNotification } from '../../common/read-notification.js'; +import define from '../../define.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['account', 'notifications'], - requireCredential: 'always', + requireCredential: true, limit: { - duration: 30000, - max: 30, + duration: 60000, + max: 15, }, kind: 'read:notifications', @@ -45,83 +35,110 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + following: { type: 'boolean', default: false }, + unreadOnly: { type: 'boolean', default: false }, markAsRead: { type: 'boolean', default: true }, - // 後方互換のため、廃止された通知タイプも受け付ける includeTypes: { type: 'array', items: { - type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], + type: 'string', enum: notificationTypes, } }, excludeTypes: { type: 'array', items: { - type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], + type: 'string', enum: notificationTypes, } }, }, required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - private idService: IdService, - private notificationEntityService: NotificationEntityService, - private notificationService: NotificationService, - private noteReadService: NoteReadService, - ) { - super(meta, paramDef, async (ps, me) => { - // includeTypes が空の場合はクエリしない - if (ps.includeTypes && ps.includeTypes.length === 0) { - return []; - } - // excludeTypes に全指定されている場合はクエリしない - if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) { - return []; - } - - const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; - const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; - - const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 - const notificationsRes = await this.redisClient.xrevrange( - `notificationTimeline:${me.id}`, - ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', - ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : '-', - 'COUNT', limit); - - if (notificationsRes.length === 0) { - return []; - } - - let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId && x !== ps.sinceId) as MiNotification[]; - - if (includeTypes && includeTypes.length > 0) { - notifications = notifications.filter(notification => includeTypes.includes(notification.type)); - } else if (excludeTypes && excludeTypes.length > 0) { - notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); - } - - if (notifications.length === 0) { - return []; - } - - // Mark all as read - if (ps.markAsRead) { - this.notificationService.readAllNotification(me.id); - } - - const noteIds = notifications - .filter((notification): notification is FilterUnionByProperty => ['mention', 'reply', 'quote'].includes(notification.type)) - .map(notification => notification.noteId); - - if (noteIds.length > 0) { - const notes = await this.notesRepository.findBy({ id: In(noteIds) }); - this.noteReadService.read(me.id, notes); - } - - return await this.notificationEntityService.packMany(notifications, me.id); - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // includeTypes が空の場合はクエリしない + if (ps.includeTypes && ps.includeTypes.length === 0) { + return []; } -} + // excludeTypes に全指定されている場合はクエリしない + if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) { + return []; + } + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: user.id }); + + const mutingQuery = Mutings.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: user.id }); + + const mutingInstanceQuery = UserProfiles.createQueryBuilder('user_profile') + .select('user_profile.mutedInstances') + .where('user_profile.userId = :muterId', { muterId: user.id }); + + const suspendedQuery = Users.createQueryBuilder('users') + .select('users.id') + .where('users.isSuspended = TRUE'); + + const query = makePaginationQuery(Notifications.createQueryBuilder('notification'), ps.sinceId, ps.untilId) + .andWhere('notification.notifieeId = :meId', { meId: user.id }) + .leftJoinAndSelect('notification.notifier', 'notifier') + .leftJoinAndSelect('notification.note', 'note') + .leftJoinAndSelect('notifier.avatar', 'notifierAvatar') + .leftJoinAndSelect('notifier.banner', 'notifierBanner') + .leftJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + + // muted users + query.andWhere(new Brackets(qb => { qb + .where(`notification.notifierId NOT IN (${ mutingQuery.getQuery() })`) + .orWhere('notification.notifierId IS NULL'); + })); + query.setParameters(mutingQuery.getParameters()); + + // muted instances + query.andWhere(new Brackets(qb => { qb + .andWhere('notifier.host IS NULL') + .orWhere(`NOT (( ${mutingInstanceQuery.getQuery()} )::jsonb ? notifier.host)`); + })); + query.setParameters(mutingInstanceQuery.getParameters()); + + // suspended users + query.andWhere(new Brackets(qb => { qb + .where(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`) + .orWhere('notification.notifierId IS NULL'); + })); + + if (ps.following) { + query.andWhere(`((notification.notifierId IN (${ followingQuery.getQuery() })) OR (notification.notifierId = :meId))`, { meId: user.id }); + query.setParameters(followingQuery.getParameters()); + } + + if (ps.includeTypes && ps.includeTypes.length > 0) { + query.andWhere('notification.type IN (:...includeTypes)', { includeTypes: ps.includeTypes }); + } else if (ps.excludeTypes && ps.excludeTypes.length > 0) { + query.andWhere('notification.type NOT IN (:...excludeTypes)', { excludeTypes: ps.excludeTypes }); + } + + if (ps.unreadOnly) { + query.andWhere('notification.isRead = false'); + } + + const notifications = await query.take(ps.limit).getMany(); + + // Mark all as read + if (notifications.length > 0 && ps.markAsRead) { + readNotification(user.id, notifications.map(x => x.id)); + } + + const notes = notifications.filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)).map(notification => notification.note!); + + if (notes.length > 0) { + read(user.id, notes); + } + + return await Notifications.packMany(notifications, user.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/page-likes.ts b/packages/backend/src/server/api/endpoints/i/page-likes.ts index 385b48cd20..71e326e2f0 100644 --- a/packages/backend/src/server/api/endpoints/i/page-likes.ts +++ b/packages/backend/src/server/api/endpoints/i/page-likes.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { PageLikesRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { PageLikeEntityService } from '@/core/entities/PageLikeEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { PageLikes } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['account', 'pages'], - requireCredential: 'always', + requireCredential: true, kind: 'read:page-likes', @@ -34,7 +26,7 @@ export const meta = { ref: 'Page', }, }, - }, + } }, } as const; @@ -48,25 +40,15 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.pageLikesRepository) - private pageLikesRepository: PageLikesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery(PageLikes.createQueryBuilder('like'), ps.sinceId, ps.untilId) + .andWhere(`like.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('like.page', 'page'); - private pageLikeEntityService: PageLikeEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.pageLikesRepository.createQueryBuilder('like'), ps.sinceId, ps.untilId) - .andWhere('like.userId = :meId', { meId: me.id }) - .leftJoinAndSelect('like.page', 'page'); + const likes = await query + .take(ps.limit) + .getMany(); - const likes = await query - .limit(ps.limit) - .getMany(); - - return this.pageLikeEntityService.packMany(likes, me); - }); - } -} + return PageLikes.packMany(likes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/pages.ts b/packages/backend/src/server/api/endpoints/i/pages.ts index 5770f56ea7..f28aed3fd4 100644 --- a/packages/backend/src/server/api/endpoints/i/pages.ts +++ b/packages/backend/src/server/api/endpoints/i/pages.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { PagesRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { PageEntityService } from '@/core/entities/PageEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { Pages } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['account', 'pages'], - requireCredential: 'always', + requireCredential: true, kind: 'read:pages', @@ -38,24 +30,14 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.pagesRepository) - private pagesRepository: PagesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery(Pages.createQueryBuilder('page'), ps.sinceId, ps.untilId) + .andWhere(`page.userId = :meId`, { meId: user.id }); - private pageEntityService: PageEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.pagesRepository.createQueryBuilder('page'), ps.sinceId, ps.untilId) - .andWhere('page.userId = :meId', { meId: me.id }); + const pages = await query + .take(ps.limit) + .getMany(); - const pages = await query - .limit(ps.limit) - .getMany(); - - return await this.pageEntityService.packMany(pages); - }); - } -} + return await Pages.packMany(pages); +}); diff --git a/packages/backend/src/server/api/endpoints/i/pin.ts b/packages/backend/src/server/api/endpoints/i/pin.ts index 4e7d821bba..67b7026be1 100644 --- a/packages/backend/src/server/api/endpoints/i/pin.ts +++ b/packages/backend/src/server/api/endpoints/i/pin.ts @@ -1,19 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { NotePiningService } from '@/core/NotePiningService.js'; +import { addPinned } from '@/services/i/pin.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { Users } from '@/models/index.js'; export const meta = { tags: ['account', 'notes'], - requireCredential: 'always', - prohibitMoved: true, + requireCredential: true, kind: 'write:account', @@ -52,23 +45,16 @@ export const paramDef = { required: ['noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private userEntityService: UserEntityService, - private notePiningService: NotePiningService, - ) { - super(meta, paramDef, async (ps, me) => { - await this.notePiningService.addPinned(me, ps.noteId).catch(err => { - if (err.id === '70c4e51f-5bea-449c-a030-53bee3cce202') throw new ApiError(meta.errors.noSuchNote); - if (err.id === '15a018eb-58e5-4da1-93be-330fcc5e4e1a') throw new ApiError(meta.errors.pinLimitExceeded); - if (err.id === '23f0cf4e-59a3-4276-a91d-61a5891c1514') throw new ApiError(meta.errors.alreadyPinned); - throw err; - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + await addPinned(user, ps.noteId).catch(e => { + if (e.id === '70c4e51f-5bea-449c-a030-53bee3cce202') throw new ApiError(meta.errors.noSuchNote); + if (e.id === '15a018eb-58e5-4da1-93be-330fcc5e4e1a') throw new ApiError(meta.errors.pinLimitExceeded); + if (e.id === '23f0cf4e-59a3-4276-a91d-61a5891c1514') throw new ApiError(meta.errors.alreadyPinned); + throw e; + }); - return await this.userEntityService.pack(me.id, me, { - detail: true, - }); - }); - } -} + return await Users.pack(user.id, user, { + detail: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts b/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts new file mode 100644 index 0000000000..7ff6409caf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts @@ -0,0 +1,41 @@ +import { publishMainStream } from '@/services/stream.js'; +import define from '../../define.js'; +import { MessagingMessages, UserGroupJoinings } from '@/models/index.js'; + +export const meta = { + tags: ['account', 'messaging'], + + requireCredential: true, + + kind: 'write:account', +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Update documents + await MessagingMessages.update({ + recipientId: user.id, + isRead: false, + }, { + isRead: true, + }); + + const joinings = await UserGroupJoinings.findBy({ userId: user.id }); + + await Promise.all(joinings.map(j => MessagingMessages.createQueryBuilder().update() + .set({ + reads: (() => `array_append("reads", '${user.id}')`) as any, + }) + .where(`groupId = :groupId`, { groupId: j.userGroupId }) + .andWhere('userId != :userId', { userId: user.id }) + .andWhere('NOT (:userId = ANY(reads))', { userId: user.id }) + .execute())); + + publishMainStream(user.id, 'readAllMessagingMessages'); +}); diff --git a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts b/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts index 22f63b8bf8..49f3deb331 100644 --- a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts +++ b/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts @@ -1,18 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { NoteUnreadsRepository } from '@/models/_.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; +import { publishMainStream } from '@/services/stream.js'; +import define from '../../define.js'; +import { NoteUnreads } from '@/models/index.js'; export const meta = { tags: ['account'], - requireCredential: 'always', + requireCredential: true, kind: 'write:account', } as const; @@ -23,23 +16,14 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.noteUnreadsRepository) - private noteUnreadsRepository: NoteUnreadsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Remove documents + await NoteUnreads.delete({ + userId: user.id, + }); - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - // Remove documents - await this.noteUnreadsRepository.delete({ - userId: me.id, - }); - - // 全て既読になったイベントを発行 - this.globalEventService.publishMainStream(me.id, 'readAllUnreadMentions'); - this.globalEventService.publishMainStream(me.id, 'readAllUnreadSpecifiedNotes'); - }); - } -} + // 全て既読になったイベントを発行 + publishMainStream(user.id, 'readAllUnreadMentions'); + publishMainStream(user.id, 'readAllUnreadSpecifiedNotes'); +}); diff --git a/packages/backend/src/server/api/endpoints/i/read-announcement.ts b/packages/backend/src/server/api/endpoints/i/read-announcement.ts index 07c94bf681..45b6e98c86 100644 --- a/packages/backend/src/server/api/endpoints/i/read-announcement.ts +++ b/packages/backend/src/server/api/endpoints/i/read-announcement.ts @@ -1,20 +1,22 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { AnnouncementService } from '@/core/AnnouncementService.js'; +import define from '../../define.js'; +import { ApiError } from '../../error.js'; +import { genId } from '@/misc/gen-id.js'; +import { AnnouncementReads, Announcements, Users } from '@/models/index.js'; +import { publishMainStream } from '@/services/stream.js'; export const meta = { tags: ['account'], - requireCredential: 'always', + requireCredential: true, kind: 'write:account', errors: { + noSuchAnnouncement: { + message: 'No such announcement.', + code: 'NO_SUCH_ANNOUNCEMENT', + id: '184663db-df88-4bc2-8b52-fb85f0681939', + }, }, } as const; @@ -26,13 +28,34 @@ export const paramDef = { required: ['announcementId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private announcementService: AnnouncementService, - ) { - super(meta, paramDef, async (ps, me) => { - await this.announcementService.read(me, ps.announcementId); - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Check if announcement exists + const announcement = await Announcements.findOneBy({ id: ps.announcementId }); + + if (announcement == null) { + throw new ApiError(meta.errors.noSuchAnnouncement); } -} + + // Check if already read + const read = await AnnouncementReads.findOneBy({ + announcementId: ps.announcementId, + userId: user.id, + }); + + if (read != null) { + return; + } + + // Create read + await AnnouncementReads.insert({ + id: genId(), + createdAt: new Date(), + announcementId: ps.announcementId, + userId: user.id, + }); + + if (!await Users.getHasUnreadAnnouncement(user.id)) { + publishMainStream(user.id, 'readAllAnnouncements'); + } +}); diff --git a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts index 73d8277768..af929b04e8 100644 --- a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -//import bcrypt from 'bcryptjs'; -import * as argon2 from 'argon2'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; -import generateUserToken from '@/misc/generate-native-user-token.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; +import bcrypt from 'bcryptjs'; +import { publishInternalEvent, publishMainStream, publishUserEvent } from '@/services/stream.js'; +import generateUserToken from '../../common/generate-native-user-token.js'; +import define from '../../define.js'; +import { Users, UserProfiles } from '@/models/index.js'; export const meta = { - requireCredential: 'always', + requireCredential: true, secure: true, } as const; @@ -26,39 +18,32 @@ export const paramDef = { required: ['password'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const freshUser = await Users.findOneByOrFail({ id: user.id }); + const oldToken = freshUser.token; - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - const freshUser = await this.usersRepository.findOneByOrFail({ id: me.id }); - const oldToken = freshUser.token!; + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); - - // Compare password - const same = await argon2.verify(profile.password!, ps.password); - - if (!same) { - throw new Error('incorrect password'); - } - - const newToken = generateUserToken(); - - await this.usersRepository.update(me.id, { - token: newToken, - }); - - // Publish event - this.globalEventService.publishInternalEvent('userTokenRegenerated', { id: me.id, oldToken, newToken }); - this.globalEventService.publishMainStream(me.id, 'myTokenRegenerated'); - }); + if (!same) { + throw new Error('incorrect password'); } -} + + const newToken = generateUserToken(); + + await Users.update(user.id, { + token: newToken, + }); + + // Publish event + publishInternalEvent('userTokenRegenerated', { id: user.id, oldToken, newToken }); + publishMainStream(user.id, 'myTokenRegenerated'); + + // Terminate streaming + setTimeout(() => { + publishUserEvent(user.id, 'terminate', {}); + }, 5000); +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-all.ts b/packages/backend/src/server/api/endpoints/i/registry/get-all.ts index 1ddaf806e4..d0b16dbc48 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/get-all.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/get-all.ts @@ -1,19 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { RegistryApiService } from '@/core/RegistryApiService.js'; +import define from '../../../define.js'; +import { RegistryItems } from '@/models/index.js'; export const meta = { - requireCredential: 'always', - kind: 'read:account', + requireCredential: true, - res: { - type: 'object', - }, + secure: true, } as const; export const paramDef = { @@ -22,26 +13,24 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, - domain: { type: 'string', nullable: true }, }, - required: ['scope'], + required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private registryApiService: RegistryApiService, - ) { - super(meta, paramDef, async (ps, me, accessToken) => { - const items = await this.registryApiService.getAllItemsOfScope(me.id, accessToken != null ? accessToken.id : (ps.domain ?? null), ps.scope); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.scope = :scope', { scope: ps.scope }); - const res = {} as Record; + const items = await query.getMany(); - for (const item of items) { - res[item.key] = item.value; - } + const res = {} as Record; - return res; - }); + for (const item of items) { + res[item.key] = item.value; } -} + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts b/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts index e6448edb87..cc5d5a8c6f 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts @@ -1,16 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { RegistryApiService } from '@/core/RegistryApiService.js'; +import define from '../../../define.js'; +import { RegistryItems } from '@/models/index.js'; import { ApiError } from '../../../error.js'; export const meta = { - requireCredential: 'always', - kind: 'read:account', + requireCredential: true, + + secure: true, errors: { noSuchKey: { @@ -19,10 +14,6 @@ export const meta = { id: '97a1e8e7-c0f7-47d2-957a-92e61256e01a', }, }, - - res: { - type: 'object', - } } as const; export const paramDef = { @@ -32,27 +23,26 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, - domain: { type: 'string', nullable: true }, }, - required: ['key', 'scope'], + required: ['key'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private registryApiService: RegistryApiService, - ) { - super(meta, paramDef, async (ps, me, accessToken) => { - const item = await this.registryApiService.getItem(me.id, accessToken != null ? accessToken.id : (ps.domain ?? null), ps.scope, ps.key); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); - if (item == null) { - throw new ApiError(meta.errors.noSuchKey); - } + const item = await query.getOne(); - return { - updatedAt: item.updatedAt, - value: item.value, - }; - }); + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); } -} + + return { + updatedAt: item.updatedAt, + value: item.value, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-unsecure.ts b/packages/backend/src/server/api/endpoints/i/registry/get-unsecure.ts deleted file mode 100644 index ac9f90bc0d..0000000000 --- a/packages/backend/src/server/api/endpoints/i/registry/get-unsecure.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistryItemsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../../error.js'; - -export const meta = { - requireCredential: 'always', - kind: 'read:account', - - secure: false, - - errors: { - noSuchKey: { - message: 'No such key.', - code: 'NO_SUCH_KEY', - id: 'ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - key: { type: 'string' }, - scope: { type: 'array', default: [], items: { - type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), - } }, - }, - required: ['key'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.registryItemsRepository) - private registryItemsRepository: RegistryItemsRepository, - ) { - super(meta, paramDef, async (ps, me) => { - if (ps.key !== "reactions" && ps.key !== "defaultNoteVisibility") return; - const query = this.registryItemsRepository.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: me.id }) - .andWhere('item.key = :key', { key: ps.key }) - .andWhere('item.scope = :scope', { scope: ps.scope }); - - const item = await query.getOne(); - - if (item == null) { - throw new ApiError(meta.errors.noSuchKey); - } - - return item.value; - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/i/registry/get.ts b/packages/backend/src/server/api/endpoints/i/registry/get.ts index 3754ef0b88..a79319744c 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/get.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/get.ts @@ -1,16 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { RegistryApiService } from '@/core/RegistryApiService.js'; +import define from '../../../define.js'; +import { RegistryItems } from '@/models/index.js'; import { ApiError } from '../../../error.js'; export const meta = { - requireCredential: 'always', - kind: 'read:account', + requireCredential: true, + + secure: true, errors: { noSuchKey: { @@ -19,10 +14,6 @@ export const meta = { id: 'ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a', }, }, - - res: { - type: 'object', - } } as const; export const paramDef = { @@ -32,24 +23,23 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, - domain: { type: 'string', nullable: true }, }, - required: ['key', 'scope'], + required: ['key'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private registryApiService: RegistryApiService, - ) { - super(meta, paramDef, async (ps, me, accessToken) => { - const item = await this.registryApiService.getItem(me.id, accessToken != null ? accessToken.id : (ps.domain ?? null), ps.scope, ps.key); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); - if (item == null) { - throw new ApiError(meta.errors.noSuchKey); - } + const item = await query.getOne(); - return item.value; - }); + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); } -} + + return item.value; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts b/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts index 155ecd5868..ac209c06a6 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts @@ -1,19 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { RegistryApiService } from '@/core/RegistryApiService.js'; +import define from '../../../define.js'; +import { RegistryItems } from '@/models/index.js'; export const meta = { - requireCredential: 'always', - kind: 'read:account', + requireCredential: true, - res: { - type: 'object', - }, + secure: true, } as const; export const paramDef = { @@ -22,34 +13,32 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, - domain: { type: 'string', nullable: true }, }, - required: ['scope'], + required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private registryApiService: RegistryApiService, - ) { - super(meta, paramDef, async (ps, me, accessToken) => { - const items = await this.registryApiService.getAllItemsOfScope(me.id, accessToken != null ? accessToken.id : (ps.domain ?? null), ps.scope); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.scope = :scope', { scope: ps.scope }); - const res = {} as Record; + const items = await query.getMany(); - for (const item of items) { - const type = typeof item.value; - res[item.key] = - item.value === null ? 'null' : - Array.isArray(item.value) ? 'array' : - type === 'number' ? 'number' : - type === 'string' ? 'string' : - type === 'boolean' ? 'boolean' : - type === 'object' ? 'object' : - null as never; - } + const res = {} as Record; - return res; - }); + for (const item of items) { + const type = typeof item.value; + res[item.key] = + item.value === null ? 'null' : + Array.isArray(item.value) ? 'array' : + type === 'number' ? 'number' : + type === 'string' ? 'string' : + type === 'boolean' ? 'boolean' : + type === 'object' ? 'object' : + null as never; } -} + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/keys.ts b/packages/backend/src/server/api/endpoints/i/registry/keys.ts index d78f4fe208..5ea1a9d344 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/keys.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/keys.ts @@ -1,15 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { RegistryApiService } from '@/core/RegistryApiService.js'; +import define from '../../../define.js'; +import { RegistryItems } from '@/models/index.js'; export const meta = { - requireCredential: 'always', - kind: 'read:account', + requireCredential: true, + + secure: true, } as const; export const paramDef = { @@ -18,18 +13,19 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, - domain: { type: 'string', nullable: true }, }, - required: ['scope'], + required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private registryApiService: RegistryApiService, - ) { - super(meta, paramDef, async (ps, me, accessToken) => { - return await this.registryApiService.getAllKeysOfScope(me.id, accessToken != null ? accessToken.id : (ps.domain ?? null), ps.scope); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .select('item.key') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const items = await query.getMany(); + + return items.map(x => x.key); +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/remove.ts b/packages/backend/src/server/api/endpoints/i/registry/remove.ts index 4327030844..92473654c6 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/remove.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/remove.ts @@ -1,18 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistryItemsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { RegistryApiService } from '@/core/RegistryApiService.js'; +import define from '../../../define.js'; +import { RegistryItems } from '@/models/index.js'; import { ApiError } from '../../../error.js'; export const meta = { - requireCredential: 'always', - kind: 'write:account', + requireCredential: true, + + secure: true, errors: { noSuchKey: { @@ -30,18 +23,23 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, - domain: { type: 'string', nullable: true }, }, - required: ['key', 'scope'], + required: ['key'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private registryApiService: RegistryApiService, - ) { - super(meta, paramDef, async (ps, me, accessToken) => { - await this.registryApiService.remove(me.id, accessToken != null ? accessToken.id : (ps.domain ?? null), ps.scope, ps.key); - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const item = await query.getOne(); + + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); } -} + + await RegistryItems.remove(item); +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/scopes-with-domain.ts b/packages/backend/src/server/api/endpoints/i/registry/scopes-with-domain.ts deleted file mode 100644 index c380ace117..0000000000 --- a/packages/backend/src/server/api/endpoints/i/registry/scopes-with-domain.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { RegistryApiService } from '@/core/RegistryApiService.js'; - -export const meta = { - requireCredential: 'always', - secure: true, - - res: { - type: 'array', - items: { - type: 'object', - properties: { - scopes: { - type: 'array', - items: { - type: 'array', - items: { - type: 'string', - } - } - }, - domain: { - type: 'string', - nullable: true, - }, - }, - }, - } -} as const; - -export const paramDef = { - type: 'object', - properties: {}, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private registryApiService: RegistryApiService, - ) { - super(meta, paramDef, async (ps, me) => { - return await this.registryApiService.getAllScopeAndDomains(me.id); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/i/registry/scopes.ts b/packages/backend/src/server/api/endpoints/i/registry/scopes.ts new file mode 100644 index 0000000000..de4b313e25 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/scopes.ts @@ -0,0 +1,33 @@ +import define from '../../../define.js'; +import { RegistryItems } from '@/models/index.js'; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .select('item.scope') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }); + + const items = await query.getMany(); + + const res = [] as string[][]; + + for (const item of items) { + if (res.some(scope => scope.join('.') === item.scope.join('.'))) continue; + res.push(item.scope); + } + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/set.ts b/packages/backend/src/server/api/endpoints/i/registry/set.ts index 8f95b084a3..d380b428a3 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/set.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/set.ts @@ -1,15 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { RegistryApiService } from '@/core/RegistryApiService.js'; +import { publishMainStream } from '@/services/stream.js'; +import define from '../../../define.js'; +import { RegistryItems } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; export const meta = { - requireCredential: 'always', - kind: 'write:account', + requireCredential: true, + + secure: true, } as const; export const paramDef = { @@ -20,18 +17,42 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, - domain: { type: 'string', nullable: true }, }, - required: ['key', 'value', 'scope'], + required: ['key', 'value'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private registryApiService: RegistryApiService, - ) { - super(meta, paramDef, async (ps, me, accessToken) => { - await this.registryApiService.set(me.id, accessToken ? accessToken.id : (ps.domain ?? null), ps.scope, ps.key, ps.value); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const existingItem = await query.getOne(); + + if (existingItem) { + await RegistryItems.update(existingItem.id, { + updatedAt: new Date(), + value: ps.value, + }); + } else { + await RegistryItems.insert({ + id: genId(), + createdAt: new Date(), + updatedAt: new Date(), + userId: user.id, + domain: null, + scope: ps.scope, + key: ps.key, + value: ps.value, }); } -} + + // TODO: サードパーティアプリが傍受出来てしまうのでどうにかする + publishMainStream(user.id, 'registryUpdated', { + scope: ps.scope, + key: ps.key, + value: ps.value, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/revoke-token.ts b/packages/backend/src/server/api/endpoints/i/revoke-token.ts index 195fb744fb..c692453794 100644 --- a/packages/backend/src/server/api/endpoints/i/revoke-token.ts +++ b/packages/backend/src/server/api/endpoints/i/revoke-token.ts @@ -1,15 +1,9 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AccessTokensRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { AccessTokens } from '@/models/index.js'; +import { publishUserEvent } from '@/services/stream.js'; export const meta = { - requireCredential: 'always', + requireCredential: true, secure: true, } as const; @@ -18,40 +12,21 @@ export const paramDef = { type: 'object', properties: { tokenId: { type: 'string', format: 'misskey:id' }, - token: { type: 'string', nullable: true }, }, - anyOf: [ - { required: ['tokenId'] }, - { required: ['token'] }, - ], + required: ['tokenId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.accessTokensRepository) - private accessTokensRepository: AccessTokensRepository, - ) { - super(meta, paramDef, async (ps, me) => { - if (ps.tokenId) { - const tokenExist = await this.accessTokensRepository.exist({ where: { id: ps.tokenId } }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const token = await AccessTokens.findOneBy({ id: ps.tokenId }); - if (tokenExist) { - await this.accessTokensRepository.delete({ - id: ps.tokenId, - userId: me.id, - }); - } - } else if (ps.token) { - const tokenExist = await this.accessTokensRepository.exist({ where: { token: ps.token } }); - - if (tokenExist) { - await this.accessTokensRepository.delete({ - token: ps.token, - userId: me.id, - }); - } - } + if (token) { + await AccessTokens.delete({ + id: ps.tokenId, + userId: user.id, }); + + // Terminate streaming + publishUserEvent(user.id, 'terminate'); } -} +}); diff --git a/packages/backend/src/server/api/endpoints/i/signin-history.ts b/packages/backend/src/server/api/endpoints/i/signin-history.ts index e7eaebe874..ca37411662 100644 --- a/packages/backend/src/server/api/endpoints/i/signin-history.ts +++ b/packages/backend/src/server/api/endpoints/i/signin-history.ts @@ -1,28 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { SigninsRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { SigninEntityService } from '@/core/entities/SigninEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { Signins } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { - requireCredential: 'always', - secure: true, + requireCredential: true, - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'Signin', - }, - }, + secure: true, } as const; export const paramDef = { @@ -35,22 +18,12 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.signinsRepository) - private signinsRepository: SigninsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery(Signins.createQueryBuilder('signin'), ps.sinceId, ps.untilId) + .andWhere(`signin.userId = :meId`, { meId: user.id }); - private signinEntityService: SigninEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.signinsRepository.createQueryBuilder('signin'), ps.sinceId, ps.untilId) - .andWhere('signin.userId = :meId', { meId: me.id }); + const history = await query.take(ps.limit).getMany(); - const history = await query.limit(ps.limit).getMany(); - - return await Promise.all(history.map(record => this.signinEntityService.pack(record))); - }); - } -} + return await Promise.all(history.map(record => Signins.pack(record))); +}); diff --git a/packages/backend/src/server/api/endpoints/i/unpin.ts b/packages/backend/src/server/api/endpoints/i/unpin.ts index 273c067603..9912689da5 100644 --- a/packages/backend/src/server/api/endpoints/i/unpin.ts +++ b/packages/backend/src/server/api/endpoints/i/unpin.ts @@ -1,18 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { NotePiningService } from '@/core/NotePiningService.js'; +import { removePinned } from '@/services/i/pin.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { Users } from '@/models/index.js'; export const meta = { tags: ['account', 'notes'], - requireCredential: 'always', + requireCredential: true, kind: 'write:account', @@ -39,21 +33,14 @@ export const paramDef = { required: ['noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private userEntityService: UserEntityService, - private notePiningService: NotePiningService, - ) { - super(meta, paramDef, async (ps, me) => { - await this.notePiningService.removePinned(me, ps.noteId).catch(err => { - if (err.id === 'b302d4cf-c050-400a-bbb3-be208681f40c') throw new ApiError(meta.errors.noSuchNote); - throw err; - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + await removePinned(user, ps.noteId).catch(e => { + if (e.id === 'b302d4cf-c050-400a-bbb3-be208681f40c') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); - return await this.userEntityService.pack(me.id, me, { - detail: true, - }); - }); - } -} + return await Users.pack(user.id, user, { + detail: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts index a57fd3b608..3318078523 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -1,25 +1,16 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; +import { publishMainStream } from '@/services/stream.js'; +import define from '../../define.js'; +import rndstr from 'rndstr'; +import config from '@/config/index.js'; import ms from 'ms'; -//import bcrypt from 'bcryptjs'; -import * as argon2 from 'argon2'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UserProfilesRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { EmailService } from '@/core/EmailService.js'; -import type { Config } from '@/config.js'; -import { DI } from '@/di-symbols.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js'; -import { UserAuthService } from '@/core/UserAuthService.js'; +import bcrypt from 'bcryptjs'; +import { Users, UserProfiles } from '@/models/index.js'; +import { sendEmail } from '@/services/send-email.js'; import { ApiError } from '../../error.js'; +import { validateEmailForAccount } from '@/services/validate-email-for-account.js'; export const meta = { - requireCredential: 'always', + requireCredential: true, secure: true, @@ -41,11 +32,6 @@ export const meta = { id: 'a2defefb-f220-8849-0af6-17f816099323', }, }, - - res: { - type: 'object', - ref: 'UserDetailed', - }, } as const; export const paramDef = { @@ -53,82 +39,55 @@ export const paramDef = { properties: { password: { type: 'string' }, email: { type: 'string', nullable: true }, - token: { type: 'string', nullable: true }, }, required: ['password'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.config) - private config: Config, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); - private userEntityService: UserEntityService, - private emailService: EmailService, - private userAuthService: UserAuthService, - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - const token = ps.token; - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); - - if (profile.twoFactorEnabled) { - if (token == null) { - throw new Error('authentication failed'); - } - - try { - await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { - throw new Error('authentication failed'); - } - } - - const passwordMatched = await argon2.verify(profile.password!, ps.password); - if (!passwordMatched) { - throw new ApiError(meta.errors.incorrectPassword); - } - - if (ps.email != null) { - const res = await this.emailService.validateEmailForAccount(ps.email); - if (!res.available) { - throw new ApiError(meta.errors.unavailable); - } - } - - await this.userProfilesRepository.update(me.id, { - email: ps.email, - emailVerified: false, - emailVerifyCode: null, - }); - - const iObj = await this.userEntityService.pack(me.id, me, { - detail: true, - includeSecrets: true, - }); - - // Publish meUpdated event - this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj); - - if (ps.email != null) { - const code = secureRndstr(16, { chars: L_CHARS }); - - await this.userProfilesRepository.update(me.id, { - emailVerifyCode: code, - }); - - const link = `${this.config.url}/verify-email/${code}`; - - this.emailService.sendEmail(ps.email, 'Email verification', - `To verify email, please click this link:
${link}`, - `To verify email, please click this link: ${link}`); - } - - return iObj; - }); + if (!same) { + throw new ApiError(meta.errors.incorrectPassword); } -} + + if (ps.email != null) { + const available = await validateEmailForAccount(ps.email); + if (!available) { + throw new ApiError(meta.errors.unavailable); + } + } + + await UserProfiles.update(user.id, { + email: ps.email, + emailVerified: false, + emailVerifyCode: null, + }); + + const iObj = await Users.pack(user.id, user, { + detail: true, + includeSecrets: true, + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', iObj); + + if (ps.email != null) { + const code = rndstr('a-z0-9', 16); + + await UserProfiles.update(user.id, { + emailVerifyCode: code, + }); + + const link = `${config.url}/verify-email/${code}`; + + sendEmail(ps.email, 'Email verification', + `To verify email, please click this link:
${link}`, + `To verify email, please click this link: ${link}`); + } + + return iObj; +}); diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 18d80b8743..3c2f1cea0d 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -1,53 +1,27 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import RE2 from 're2'; -import * as mfm from '@sharkey/sfm-js'; -import { Inject, Injectable } from '@nestjs/common'; -import ms from 'ms'; -import { JSDOM } from 'jsdom'; +import * as mfm from 'mfm-js'; +import { publishMainStream, publishUserEvent } from '@/services/stream.js'; +import acceptAllFollowRequests from '@/services/following/requests/accept-all.js'; +import { publishToFollowers } from '@/services/i/update.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; -import * as Acct from '@/misc/acct.js'; -import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/_.js'; -import type { MiLocalUser, MiUser } from '@/models/User.js'; -import { birthdaySchema, listenbrainzSchema, descriptionSchema, locationSchema, nameSchema } from '@/models/User.js'; -import type { MiUserProfile } from '@/models/UserProfile.js'; +import { updateUsertags } from '@/services/update-hashtag.js'; +import { Users, DriveFiles, UserProfiles, Pages } from '@/models/index.js'; +import { User } from '@/models/entities/user.js'; +import { UserProfile } from '@/models/entities/user-profile.js'; import { notificationTypes } from '@/types.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { langmap } from '@/misc/langmap.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; -import { AccountUpdateService } from '@/core/AccountUpdateService.js'; -import { HashtagService } from '@/core/HashtagService.js'; -import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; -import { CacheService } from '@/core/CacheService.js'; -import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; -import type { Config } from '@/config.js'; -import { safeForSql } from '@/misc/safe-for-sql.js'; -import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; -import { ApiLoggerService } from '../../ApiLoggerService.js'; import { ApiError } from '../../error.js'; +import define from '../../define.js'; export const meta = { tags: ['account'], - requireCredential: 'always', + requireCredential: true, kind: 'write:account', - limit: { - duration: ms('1hour'), - max: 20, - }, - errors: { noSuchAvatar: { message: 'No such avatar file.', @@ -61,12 +35,6 @@ export const meta = { id: '0d8f5629-f210-41c2-9433-735831a58595', }, - noSuchBackground: { - message: 'No such background file.', - code: 'NO_SUCH_BACKGROUND', - id: '0d8f5629-f210-41c2-9433-735831a58582', - }, - avatarNotAnImage: { message: 'The file specified as an avatar is not an image.', code: 'AVATAR_NOT_AN_IMAGE', @@ -79,12 +47,6 @@ export const meta = { id: '75aedb19-2afd-4e6d-87fc-67941256fa60', }, - backgroundNotAnImage: { - message: 'The file specified as a background is not an image.', - code: 'BACKGROUND_NOT_AN_IMAGE', - id: '75aedb19-2afd-4e6d-87fc-67941256fa40', - }, - noSuchPage: { message: 'No such page.', code: 'NO_SUCH_PAGE', @@ -96,36 +58,6 @@ export const meta = { code: 'INVALID_REGEXP', id: '0d786918-10df-41cd-8f33-8dec7d9a89a5', }, - - tooManyMutedWords: { - message: 'Too many muted words.', - code: 'TOO_MANY_MUTED_WORDS', - id: '010665b1-a211-42d2-bc64-8f6609d79785', - }, - - noSuchUser: { - message: 'No such user.', - code: 'NO_SUCH_USER', - id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', - }, - - uriNull: { - message: 'User ActivityPup URI is null.', - code: 'URI_NULL', - id: 'bf326f31-d430-4f97-9933-5d61e4d48a23', - }, - - forbiddenToSetYourself: { - message: 'You can\'t set yourself as your own alias.', - code: 'FORBIDDEN_TO_SET_YOURSELF', - id: '25c90186-4ab0-49c8-9bba-a1fa6c202ba4', - }, - - restrictedByRole: { - message: 'This feature is restricted by your role.', - code: 'RESTRICTED_BY_ROLE', - id: '8feff0ba-5ab5-585b-31f4-4df816663fad', - }, }, res: { @@ -135,34 +67,16 @@ export const meta = { }, } as const; -const muteWords = { type: 'array', items: { oneOf: [ - { type: 'array', items: { type: 'string' } }, - { type: 'string' }, -] } } as const; - export const paramDef = { type: 'object', properties: { - name: { ...nameSchema, nullable: true }, - description: { ...descriptionSchema, nullable: true }, - location: { ...locationSchema, nullable: true }, - birthday: { ...birthdaySchema, nullable: true }, - listenbrainz: { ...listenbrainzSchema, nullable: true }, - lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true }, + name: { ...Users.nameSchema, nullable: true }, + description: { ...Users.descriptionSchema, nullable: true }, + location: { ...Users.locationSchema, nullable: true }, + birthday: { ...Users.birthdaySchema, nullable: true }, + lang: { type: 'string', enum: [null, ...Object.keys(langmap)], nullable: true }, avatarId: { type: 'string', format: 'misskey:id', nullable: true }, - avatarDecorations: { type: 'array', maxItems: 16, items: { - type: 'object', - properties: { - id: { type: 'string', format: 'misskey:id' }, - angle: { type: 'number', nullable: true, maximum: 0.5, minimum: -0.5 }, - flipH: { type: 'boolean', nullable: true }, - offsetX: { type: 'number', nullable: true, maximum: 0.25, minimum: -0.25 }, - offsetY: { type: 'number', nullable: true, maximum: 0.25, minimum: -0.25 }, - }, - required: ['id'], - } }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, - backgroundId: { type: 'string', format: 'misskey:id', nullable: true }, fields: { type: 'array', minItems: 0, @@ -183,353 +97,159 @@ export const paramDef = { carefulBot: { type: 'boolean' }, autoAcceptFollowed: { type: 'boolean' }, noCrawle: { type: 'boolean' }, - preventAiLearning: { type: 'boolean' }, - noindex: { type: 'boolean' }, isBot: { type: 'boolean' }, isCat: { type: 'boolean' }, - speakAsCat: { type: 'boolean' }, + showTimelineReplies: { type: 'boolean' }, injectFeaturedNote: { type: 'boolean' }, receiveAnnouncementEmail: { type: 'boolean' }, alwaysMarkNsfw: { type: 'boolean' }, autoSensitive: { type: 'boolean' }, - followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, - followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, - pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true }, - mutedWords: muteWords, - hardMutedWords: muteWords, + ffVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, + pinnedPageId: { type: 'array', items: { + type: 'string', format: 'misskey:id', + } }, + mutedWords: { type: 'array' }, mutedInstances: { type: 'array', items: { type: 'string', } }, - notificationRecieveConfig: { type: 'object' }, + mutingNotificationTypes: { type: 'array', items: { + type: 'string', enum: notificationTypes, + } }, emailNotificationTypes: { type: 'array', items: { type: 'string', } }, - alsoKnownAs: { - type: 'array', - maxItems: 10, - uniqueItems: true, - items: { type: 'string' }, - }, }, } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.config) - private config: Config, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, _user, token) => { + const user = await Users.findOneByOrFail({ id: _user.id }); + const isSecure = token == null; - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, + const updates = {} as Partial; + const profileUpdates = {} as Partial; - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, + if (ps.name !== undefined) updates.name = ps.name; + if (ps.description !== undefined) profileUpdates.description = ps.description; + if (ps.lang !== undefined) profileUpdates.lang = ps.lang; + if (ps.location !== undefined) profileUpdates.location = ps.location; + if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; + if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility; + if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; + if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId; + if (ps.mutedWords !== undefined) { + // validate regular expression syntax + ps.mutedWords.filter(x => !Array.isArray(x)).forEach(x => { + const regexp = x.match(/^\/(.+)\/(.*)$/); + if (!regexp) throw new ApiError(meta.errors.invalidRegexp); - @Inject(DI.pagesRepository) - private pagesRepository: PagesRepository, - - private userEntityService: UserEntityService, - private driveFileEntityService: DriveFileEntityService, - private globalEventService: GlobalEventService, - private userFollowingService: UserFollowingService, - private accountUpdateService: AccountUpdateService, - private remoteUserResolveService: RemoteUserResolveService, - private apiLoggerService: ApiLoggerService, - private hashtagService: HashtagService, - private roleService: RoleService, - private cacheService: CacheService, - private httpRequestService: HttpRequestService, - private avatarDecorationService: AvatarDecorationService, - ) { - super(meta, paramDef, async (ps, _user, token) => { - const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser; - const isSecure = token == null; - - const updates = {} as Partial; - const profileUpdates = {} as Partial; - - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - - if (ps.name !== undefined) updates.name = ps.name; - if (ps.description !== undefined) profileUpdates.description = ps.description; - if (ps.lang !== undefined) profileUpdates.lang = ps.lang; - if (ps.location !== undefined) profileUpdates.location = ps.location; - if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; - if (ps.listenbrainz !== undefined) profileUpdates.listenbrainz = ps.listenbrainz; - if (ps.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility; - if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility; - - function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) { - // TODO: ちゃんと数える - const length = JSON.stringify(mutedWords).length; - if (length > limit) { - throw new ApiError(meta.errors.tooManyMutedWords); - } + try { + new RE2(regexp[1], regexp[2]); + } catch (err) { + throw new ApiError(meta.errors.invalidRegexp); } - - function validateMuteWordRegex(mutedWords: (string[] | string)[]) { - for (const mutedWord of mutedWords) { - if (typeof mutedWord !== 'string') continue; - - const regexp = mutedWord.match(/^\/(.+)\/(.*)$/); - if (!regexp) throw new ApiError(meta.errors.invalidRegexp); - - try { - new RE2(regexp[1], regexp[2]); - } catch (err) { - throw new ApiError(meta.errors.invalidRegexp); - } - } - } - - if (ps.mutedWords !== undefined) { - checkMuteWordCount(ps.mutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit); - validateMuteWordRegex(ps.mutedWords); - - profileUpdates.mutedWords = ps.mutedWords; - profileUpdates.enableWordMute = ps.mutedWords.length > 0; - } - if (ps.hardMutedWords !== undefined) { - checkMuteWordCount(ps.hardMutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit); - validateMuteWordRegex(ps.hardMutedWords); - profileUpdates.hardMutedWords = ps.hardMutedWords; - } - if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances; - if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig; - if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; - if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable; - if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus; - if (typeof ps.publicReactions === 'boolean') profileUpdates.publicReactions = ps.publicReactions; - if (typeof ps.noindex === 'boolean') updates.noindex = ps.noindex; - if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot; - if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; - if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; - if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle; - if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat; - if (typeof ps.speakAsCat === 'boolean') updates.speakAsCat = ps.speakAsCat; - if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; - if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; - if (typeof ps.alwaysMarkNsfw === 'boolean') { - if ((await roleService.getUserPolicies(user.id)).alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole); - profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; - } - if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; - - if (ps.avatarId) { - const avatar = await this.driveFilesRepository.findOneBy({ id: ps.avatarId }); - - if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); - if (!avatar.type.startsWith('image/')) throw new ApiError(meta.errors.avatarNotAnImage); - - updates.avatarId = avatar.id; - updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); - updates.avatarBlurhash = avatar.blurhash; - } else if (ps.avatarId === null) { - updates.avatarId = null; - updates.avatarUrl = null; - updates.avatarBlurhash = null; - } - - if (ps.bannerId) { - const banner = await this.driveFilesRepository.findOneBy({ id: ps.bannerId }); - - if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); - if (!banner.type.startsWith('image/')) throw new ApiError(meta.errors.bannerNotAnImage); - - updates.bannerId = banner.id; - updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); - updates.bannerBlurhash = banner.blurhash; - } else if (ps.bannerId === null) { - updates.bannerId = null; - updates.bannerUrl = null; - updates.bannerBlurhash = null; - } - - if (ps.backgroundId) { - const background = await this.driveFilesRepository.findOneBy({ id: ps.backgroundId }); - - if (background == null || background.userId !== user.id) throw new ApiError(meta.errors.noSuchBackground); - if (!background.type.startsWith('image/')) throw new ApiError(meta.errors.backgroundNotAnImage); - - updates.backgroundId = background.id; - updates.backgroundUrl = this.driveFileEntityService.getPublicUrl(background); - updates.backgroundBlurhash = background.blurhash; - } else if (ps.backgroundId === null) { - updates.backgroundId = null; - updates.backgroundUrl = null; - updates.backgroundBlurhash = null; - } - - if (ps.avatarDecorations) { - const decorations = await this.avatarDecorationService.getAll(true); - const [myRoles, myPolicies] = await Promise.all([this.roleService.getUserRoles(user.id), this.roleService.getUserPolicies(user.id)]); - const allRoles = await this.roleService.getRoles(); - const decorationIds = decorations - .filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id))) - .map(d => d.id); - - if (ps.avatarDecorations.length > myPolicies.avatarDecorationLimit) throw new ApiError(meta.errors.restrictedByRole); - - updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({ - id: d.id, - angle: d.angle ?? 0, - flipH: d.flipH ?? false, - offsetX: d.offsetX ?? 0, - offsetY: d.offsetY ?? 0, - })); - } - - if (ps.pinnedPageId) { - const page = await this.pagesRepository.findOneBy({ id: ps.pinnedPageId }); - - if (page == null || page.userId !== user.id) throw new ApiError(meta.errors.noSuchPage); - - profileUpdates.pinnedPageId = page.id; - } else if (ps.pinnedPageId === null) { - profileUpdates.pinnedPageId = null; - } - - if (ps.fields) { - profileUpdates.fields = ps.fields - .filter(x => typeof x.name === 'string' && x.name.trim() !== '' && typeof x.value === 'string' && x.value.trim() !== '') - .map(x => { - return { name: x.name.trim(), value: x.value.trim() }; - }); - } - - if (ps.alsoKnownAs) { - if (_user.movedToUri) { - throw new ApiError({ - message: 'You have moved your account.', - code: 'YOUR_ACCOUNT_MOVED', - id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31', - httpStatusCode: 403, - }); - } - - // Parse user's input into the old account - const newAlsoKnownAs = new Set(); - for (const line of ps.alsoKnownAs) { - if (!line) throw new ApiError(meta.errors.noSuchUser); - const { username, host } = Acct.parse(line); - - // Retrieve the old account - const knownAs = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => { - this.apiLoggerService.logger.warn(`failed to resolve dstination user: ${e}`); - throw new ApiError(meta.errors.noSuchUser); - }); - if (knownAs.id === _user.id) throw new ApiError(meta.errors.forbiddenToSetYourself); - - const toUrl = this.userEntityService.getUserUri(knownAs); - if (!toUrl) throw new ApiError(meta.errors.uriNull); - - newAlsoKnownAs.add(toUrl); - } - - updates.alsoKnownAs = newAlsoKnownAs.size > 0 ? Array.from(newAlsoKnownAs) : null; - } - - //#region emojis/tags - - let emojis = [] as string[]; - let tags = [] as string[]; - - const newName = updates.name === undefined ? user.name : updates.name; - const newDescription = profileUpdates.description === undefined ? profile.description : profileUpdates.description; - const newFields = profileUpdates.fields === undefined ? profile.fields : profileUpdates.fields; - - if (newName != null) { - const tokens = mfm.parseSimple(newName); - emojis = emojis.concat(extractCustomEmojisFromMfm(tokens)); - } - - if (newDescription != null) { - const tokens = mfm.parse(newDescription); - emojis = emojis.concat(extractCustomEmojisFromMfm(tokens)); - tags = extractHashtags(tokens).map(tag => normalizeForSearch(tag)).splice(0, 32); - } - - for (const field of newFields) { - const nameTokens = mfm.parseSimple(field.name); - const valueTokens = mfm.parseSimple(field.value); - emojis = emojis.concat([ - ...extractCustomEmojisFromMfm(nameTokens), - ...extractCustomEmojisFromMfm(valueTokens), - ]); - } - - updates.emojis = emojis; - updates.tags = tags; - - // ハッシュタグ更新 - this.hashtagService.updateUsertags(user, tags); - //#endregion - - if (Object.keys(updates).length > 0) await this.usersRepository.update(user.id, updates); - if (Object.keys(updates).includes('alsoKnownAs')) { - this.cacheService.uriPersonCache.set(this.userEntityService.genLocalUserUri(user.id), { ...user, ...updates }); - } - - await this.userProfilesRepository.update(user.id, { - ...profileUpdates, - verifiedLinks: [], - }); - - const iObj = await this.userEntityService.pack(user.id, user, { - detail: true, - includeSecrets: isSecure, - }); - - const updatedProfile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - - this.cacheService.userProfileCache.set(user.id, updatedProfile); - - // Publish meUpdated event - this.globalEventService.publishMainStream(user.id, 'meUpdated', iObj); - - // 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認 - if (user.isLocked && ps.isLocked === false) { - this.userFollowingService.acceptAllFollowRequests(user); - } - - // フォロワーにUpdateを配信 - this.accountUpdateService.publishToFollowers(user.id); - - const urls = updatedProfile.fields.filter(x => x.value.startsWith('https://')); - for (const url of urls) { - this.verifyLink(url.value, user); - } - - return iObj; }); + + profileUpdates.mutedWords = ps.mutedWords; + profileUpdates.enableWordMute = ps.mutedWords.length > 0; + } + if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances; + if (ps.mutingNotificationTypes !== undefined) profileUpdates.mutingNotificationTypes = ps.mutingNotificationTypes as typeof notificationTypes[number][]; + if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; + if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable; + if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus; + if (typeof ps.publicReactions === 'boolean') profileUpdates.publicReactions = ps.publicReactions; + if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot; + if (typeof ps.showTimelineReplies === 'boolean') updates.showTimelineReplies = ps.showTimelineReplies; + if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; + if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; + if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle; + if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat; + if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; + if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; + if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; + if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive; + if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; + + if (ps.avatarId) { + const avatar = await DriveFiles.findOneBy({ id: ps.avatarId }); + + if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); + if (!avatar.type.startsWith('image/')) throw new ApiError(meta.errors.avatarNotAnImage); } - private async verifyLink(url: string, user: MiLocalUser) { - if (!safeForSql(url)) return; + if (ps.bannerId) { + const banner = await DriveFiles.findOneBy({ id: ps.bannerId }); - const html = await this.httpRequestService.getHtml(url); - - const { window } = new JSDOM(html); - const doc = window.document; - - const myLink = `${this.config.url}/@${user.username}`; - - const aEls = Array.from(doc.getElementsByTagName('a')); - const linkEls = Array.from(doc.getElementsByTagName('link')); - - const includesMyLink = aEls.some(a => a.href === myLink); - const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.rel === 'me' && link.href === myLink); - - if (includesMyLink || includesRelMeLinks) { - await this.userProfilesRepository.createQueryBuilder('profile').update() - .where('userId = :userId', { userId: user.id }) - .set({ - verifiedLinks: () => `array_append("verifiedLinks", '${url}')`, // ここでSQLインジェクションされそうなのでとりあえず safeForSql で弾いている - }) - .execute(); - } + if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); + if (!banner.type.startsWith('image/')) throw new ApiError(meta.errors.bannerNotAnImage); } -} + + if (ps.pinnedPageId) { + const page = await Pages.findOneBy({ id: ps.pinnedPageId }); + + if (page == null || page.userId !== user.id) throw new ApiError(meta.errors.noSuchPage); + + profileUpdates.pinnedPageId = page.id; + } else if (ps.pinnedPageId === null) { + profileUpdates.pinnedPageId = null; + } + + if (ps.fields) { + profileUpdates.fields = ps.fields + .filter(x => typeof x.name === 'string' && x.name !== '' && typeof x.value === 'string' && x.value !== '') + .map(x => { + return { name: x.name, value: x.value }; + }); + } + + //#region emojis/tags + + let emojis = [] as string[]; + let tags = [] as string[]; + + const newName = updates.name === undefined ? user.name : updates.name; + const newDescription = profileUpdates.description === undefined ? profile.description : profileUpdates.description; + + if (newName != null) { + const tokens = mfm.parseSimple(newName); + emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!)); + } + + if (newDescription != null) { + const tokens = mfm.parse(newDescription); + emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!)); + tags = extractHashtags(tokens!).map(tag => normalizeForSearch(tag)).splice(0, 32); + } + + updates.emojis = emojis; + updates.tags = tags; + + // ハッシュタグ更新 + updateUsertags(user, tags); + //#endregion + + if (Object.keys(updates).length > 0) await Users.update(user.id, updates); + if (Object.keys(profileUpdates).length > 0) await UserProfiles.update(user.id, profileUpdates); + + const iObj = await Users.pack(user.id, user, { + detail: true, + includeSecrets: isSecure, + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', iObj); + publishUserEvent(user.id, 'updateUserProfile', await UserProfiles.findOneBy({ userId: user.id })); + + // 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認 + if (user.isLocked && ps.isLocked === false) { + acceptAllFollowRequests(user); + } + + // フォロワーにUpdateを配信 + publishToFollowers(user.id); + + return iObj; +}); diff --git a/packages/backend/src/server/api/endpoints/i/user-group-invites.ts b/packages/backend/src/server/api/endpoints/i/user-group-invites.ts new file mode 100644 index 0000000000..1d7e4a16b3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/user-group-invites.ts @@ -0,0 +1,55 @@ +import define from '../../define.js'; +import { UserGroupInvitations } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; + +export const meta = { + tags: ['account', 'groups'], + + requireCredential: true, + + kind: 'read:user-groups', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + group: { + type: 'object', + optional: false, nullable: false, + ref: 'UserGroup', + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery(UserGroupInvitations.createQueryBuilder('invitation'), ps.sinceId, ps.untilId) + .andWhere(`invitation.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('invitation.userGroup', 'user_group'); + + const invitations = await query + .take(ps.limit) + .getMany(); + + return await UserGroupInvitations.packMany(invitations); +}); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts index bc261712a9..2e2fd00b8c 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts @@ -1,59 +1,15 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { IdService } from '@/core/IdService.js'; -import type { WebhooksRepository } from '@/models/_.js'; -import { webhookEventTypes } from '@/models/Webhook.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; -import { ApiError } from '@/server/api/error.js'; +import define from '../../../define.js'; +import { genId } from '@/misc/gen-id.js'; +import { Webhooks } from '@/models/index.js'; +import { publishInternalEvent } from '@/services/stream.js'; +import { webhookEventTypes } from '@/models/entities/webhook.js'; export const meta = { tags: ['webhooks'], - requireCredential: 'always', + requireCredential: true, kind: 'write:account', - - errors: { - tooManyWebhooks: { - message: 'You cannot create webhook any more.', - code: 'TOO_MANY_WEBHOOKS', - id: '87a9bb19-111e-4e37-81d3-a3e7426453b0', - }, - }, - - res: { - type: 'object', - properties: { - id: { - type: 'string', - format: 'misskey:id' - }, - userId: { - type: 'string', - format: 'misskey:id', - }, - name: { type: 'string' }, - on: { - type: 'array', - items: { - type: 'string', - enum: webhookEventTypes, - } - }, - url: { type: 'string' }, - secret: { type: 'string' }, - active: { type: 'boolean' }, - latestSentAt: { type: 'string', format: 'date-time', nullable: true }, - latestStatus: { type: 'integer', nullable: true }, - }, - }, } as const; export const paramDef = { @@ -61,56 +17,27 @@ export const paramDef = { properties: { name: { type: 'string', minLength: 1, maxLength: 100 }, url: { type: 'string', minLength: 1, maxLength: 1024 }, - secret: { type: 'string', maxLength: 1024, default: '' }, + secret: { type: 'string', minLength: 1, maxLength: 1024 }, on: { type: 'array', items: { type: 'string', enum: webhookEventTypes, } }, }, - required: ['name', 'url', 'on'], + required: ['name', 'url', 'secret', 'on'], } as const; -// TODO: ロジックをサービスに切り出す +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const webhook = await Webhooks.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + url: ps.url, + secret: ps.secret, + on: ps.on, + }).then(x => Webhooks.findOneByOrFail(x.identifiers[0])); -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.webhooksRepository) - private webhooksRepository: WebhooksRepository, + publishInternalEvent('webhookCreated', webhook); - private idService: IdService, - private globalEventService: GlobalEventService, - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const currentWebhooksCount = await this.webhooksRepository.countBy({ - userId: me.id, - }); - if (currentWebhooksCount > (await this.roleService.getUserPolicies(me.id)).webhookLimit) { - throw new ApiError(meta.errors.tooManyWebhooks); - } - - const webhook = await this.webhooksRepository.insert({ - id: this.idService.gen(), - userId: me.id, - name: ps.name, - url: ps.url, - secret: ps.secret, - on: ps.on, - }).then(x => this.webhooksRepository.findOneByOrFail(x.identifiers[0])); - - this.globalEventService.publishInternalEvent('webhookCreated', webhook); - - return { - id: webhook.id, - userId: webhook.userId, - name: webhook.name, - on: webhook.on, - url: webhook.url, - secret: webhook.secret, - active: webhook.active, - latestSentAt: webhook.latestSentAt?.toISOString(), - latestStatus: webhook.latestStatus, - }; - }); - } -} + return webhook; +}); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/delete.ts b/packages/backend/src/server/api/endpoints/i/webhooks/delete.ts index bb841f99a2..2821eaa5f1 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/delete.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/delete.ts @@ -1,19 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { WebhooksRepository } from '@/models/_.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { Webhooks } from '@/models/index.js'; +import { publishInternalEvent } from '@/services/stream.js'; export const meta = { tags: ['webhooks'], - requireCredential: 'always', + requireCredential: true, kind: 'write:account', @@ -34,29 +27,18 @@ export const paramDef = { required: ['webhookId'], } as const; -// TODO: ロジックをサービスに切り出す +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const webhook = await Webhooks.findOneBy({ + id: ps.webhookId, + userId: user.id, + }); -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.webhooksRepository) - private webhooksRepository: WebhooksRepository, - - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - const webhook = await this.webhooksRepository.findOneBy({ - id: ps.webhookId, - userId: me.id, - }); - - if (webhook == null) { - throw new ApiError(meta.errors.noSuchWebhook); - } - - await this.webhooksRepository.delete(webhook.id); - - this.globalEventService.publishInternalEvent('webhookDeleted', webhook); - }); + if (webhook == null) { + throw new ApiError(meta.errors.noSuchWebhook); } -} + + await Webhooks.delete(webhook.id); + + publishInternalEvent('webhookDeleted', webhook); +}); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/list.ts b/packages/backend/src/server/api/endpoints/i/webhooks/list.ts index a95a4ebbd0..54e4563732 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/list.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/list.ts @@ -1,50 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { webhookEventTypes } from '@/models/Webhook.js'; -import type { WebhooksRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; +import { Webhooks } from '@/models/index.js'; export const meta = { tags: ['webhooks', 'account'], - requireCredential: 'always', + requireCredential: true, kind: 'read:account', - - res: { - type: 'array', - items: { - type: 'object', - properties: { - id: { - type: 'string', - format: 'misskey:id' - }, - userId: { - type: 'string', - format: 'misskey:id', - }, - name: { type: 'string' }, - on: { - type: 'array', - items: { - type: 'string', - enum: webhookEventTypes, - } - }, - url: { type: 'string' }, - secret: { type: 'string' }, - active: { type: 'boolean' }, - latestSentAt: { type: 'string', format: 'date-time', nullable: true }, - latestStatus: { type: 'integer', nullable: true }, - }, - } - } } as const; export const paramDef = { @@ -53,30 +15,11 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.webhooksRepository) - private webhooksRepository: WebhooksRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const webhooks = await this.webhooksRepository.findBy({ - userId: me.id, - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const webhooks = await Webhooks.findBy({ + userId: me.id, + }); - return webhooks.map(webhook => ( - { - id: webhook.id, - userId: webhook.userId, - name: webhook.name, - on: webhook.on, - url: webhook.url, - secret: webhook.secret, - active: webhook.active, - latestSentAt: webhook.latestSentAt?.toISOString(), - latestStatus: webhook.latestStatus, - } - )); - }); - } -} + return webhooks; +}); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/show.ts b/packages/backend/src/server/api/endpoints/i/webhooks/show.ts index ceb393b418..02fa1edb5e 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/show.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/show.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { webhookEventTypes } from '@/models/Webhook.js'; -import type { WebhooksRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { Webhooks } from '@/models/index.js'; export const meta = { tags: ['webhooks'], - requireCredential: 'always', + requireCredential: true, kind: 'read:account', @@ -24,33 +16,6 @@ export const meta = { id: '50f614d9-3047-4f7e-90d8-ad6b2d5fb098', }, }, - - res: { - type: 'object', - properties: { - id: { - type: 'string', - format: 'misskey:id' - }, - userId: { - type: 'string', - format: 'misskey:id', - }, - name: { type: 'string' }, - on: { - type: 'array', - items: { - type: 'string', - enum: webhookEventTypes, - } - }, - url: { type: 'string' }, - secret: { type: 'string' }, - active: { type: 'boolean' }, - latestSentAt: { type: 'string', format: 'date-time', nullable: true }, - latestStatus: { type: 'integer', nullable: true }, - }, - }, } as const; export const paramDef = { @@ -61,33 +26,16 @@ export const paramDef = { required: ['webhookId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.webhooksRepository) - private webhooksRepository: WebhooksRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const webhook = await this.webhooksRepository.findOneBy({ - id: ps.webhookId, - userId: me.id, - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const webhook = await Webhooks.findOneBy({ + id: ps.webhookId, + userId: user.id, + }); - if (webhook == null) { - throw new ApiError(meta.errors.noSuchWebhook); - } - - return { - id: webhook.id, - userId: webhook.userId, - name: webhook.name, - on: webhook.on, - url: webhook.url, - secret: webhook.secret, - active: webhook.active, - latestSentAt: webhook.latestSentAt?.toISOString(), - latestStatus: webhook.latestStatus, - }; - }); + if (webhook == null) { + throw new ApiError(meta.errors.noSuchWebhook); } -} + + return webhook; +}); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/update.ts b/packages/backend/src/server/api/endpoints/i/webhooks/update.ts index da6ad7ba54..f87b9753fb 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/update.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/update.ts @@ -1,20 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { WebhooksRepository } from '@/models/_.js'; -import { webhookEventTypes } from '@/models/Webhook.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { Webhooks } from '@/models/index.js'; +import { publishInternalEvent } from '@/services/stream.js'; +import { webhookEventTypes } from '@/models/entities/webhook.js'; export const meta = { tags: ['webhooks'], - requireCredential: 'always', + requireCredential: true, kind: 'write:account', @@ -34,48 +27,33 @@ export const paramDef = { webhookId: { type: 'string', format: 'misskey:id' }, name: { type: 'string', minLength: 1, maxLength: 100 }, url: { type: 'string', minLength: 1, maxLength: 1024 }, - secret: { type: 'string', maxLength: 1024, default: '' }, + secret: { type: 'string', minLength: 1, maxLength: 1024 }, on: { type: 'array', items: { type: 'string', enum: webhookEventTypes, } }, active: { type: 'boolean' }, }, - required: ['webhookId', 'name', 'url', 'on', 'active'], + required: ['webhookId', 'name', 'url', 'secret', 'on', 'active'], } as const; -// TODO: ロジックをサービスに切り出す +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const webhook = await Webhooks.findOneBy({ + id: ps.webhookId, + userId: user.id, + }); -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.webhooksRepository) - private webhooksRepository: WebhooksRepository, - - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - const webhook = await this.webhooksRepository.findOneBy({ - id: ps.webhookId, - userId: me.id, - }); - - if (webhook == null) { - throw new ApiError(meta.errors.noSuchWebhook); - } - - await this.webhooksRepository.update(webhook.id, { - name: ps.name, - url: ps.url, - secret: ps.secret, - on: ps.on, - active: ps.active, - }); - - const updated = await this.webhooksRepository.findOneByOrFail({ - id: ps.webhookId, - }); - - this.globalEventService.publishInternalEvent('webhookUpdated', updated); - }); + if (webhook == null) { + throw new ApiError(meta.errors.noSuchWebhook); } -} + + await Webhooks.update(webhook.id, { + name: ps.name, + url: ps.url, + secret: ps.secret, + on: ps.on, + active: ps.active, + }); + + publishInternalEvent('webhookUpdated', webhook); +}); diff --git a/packages/backend/src/server/api/endpoints/invite/create.ts b/packages/backend/src/server/api/endpoints/invite/create.ts deleted file mode 100644 index e1538a163f..0000000000 --- a/packages/backend/src/server/api/endpoints/invite/create.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { MoreThan } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistrationTicketsRepository } from '@/models/_.js'; -import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js'; -import { IdService } from '@/core/IdService.js'; -import { RoleService } from '@/core/RoleService.js'; -import { DI } from '@/di-symbols.js'; -import { generateInviteCode } from '@/misc/generate-invite-code.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['meta'], - - requireCredential: 'always', - requireRolePolicy: 'canInvite', - kind: 'write:invite-codes', - - errors: { - exceededCreateLimit: { - message: 'You have exceeded the limit for creating an invitation code.', - code: 'EXCEEDED_LIMIT_OF_CREATE_INVITE_CODE', - id: '8b165dd3-6f37-4557-8db1-73175d63c641', - }, - }, - - res: { - type: 'object', - optional: false, nullable: false, - ref: 'InviteCode', - }, -} as const; - -export const paramDef = { - type: 'object', - properties: {}, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.registrationTicketsRepository) - private registrationTicketsRepository: RegistrationTicketsRepository, - - private inviteCodeEntityService: InviteCodeEntityService, - private idService: IdService, - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const policies = await this.roleService.getUserPolicies(me.id); - - if (policies.inviteLimit) { - const count = await this.registrationTicketsRepository.countBy({ - id: MoreThan(this.idService.gen(Date.now() - (policies.inviteLimitCycle * 1000 * 60))), - createdById: me.id, - }); - - if (count >= policies.inviteLimit) { - throw new ApiError(meta.errors.exceededCreateLimit); - } - } - - const ticket = await this.registrationTicketsRepository.insert({ - id: this.idService.gen(), - createdBy: me, - createdById: me.id, - expiresAt: policies.inviteExpirationTime ? new Date(Date.now() + (policies.inviteExpirationTime * 1000 * 60)) : null, - code: generateInviteCode(), - }).then(x => this.registrationTicketsRepository.findOneByOrFail(x.identifiers[0])); - - return await this.inviteCodeEntityService.pack(ticket, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/invite/delete.ts b/packages/backend/src/server/api/endpoints/invite/delete.ts deleted file mode 100644 index 8040e9fe4b..0000000000 --- a/packages/backend/src/server/api/endpoints/invite/delete.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistrationTicketsRepository } from '@/models/_.js'; -import { RoleService } from '@/core/RoleService.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['meta'], - - requireCredential: 'always', - requireRolePolicy: 'canInvite', - kind: 'write:invite-codes', - - errors: { - noSuchCode: { - message: 'No such invite code.', - code: 'NO_SUCH_INVITE_CODE', - id: 'cd4f9ae4-7854-4e3e-8df9-c296f051e634', - }, - - cantDelete: { - message: 'You can\'t delete this invite code.', - code: 'CAN_NOT_DELETE_INVITE_CODE', - id: 'ff17af39-000c-4d4e-abdf-848fa30fc1ce', - }, - - accessDenied: { - message: 'Access denied.', - code: 'ACCESS_DENIED', - id: '5eb8d909-2540-4970-90b8-dd6f86088121', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - inviteId: { type: 'string', format: 'misskey:id' }, - }, - required: ['inviteId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.registrationTicketsRepository) - private registrationTicketsRepository: RegistrationTicketsRepository, - - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const ticket = await this.registrationTicketsRepository.findOneBy({ id: ps.inviteId }); - const isModerator = await this.roleService.isModerator(me); - - if (ticket == null) { - throw new ApiError(meta.errors.noSuchCode); - } - - if (ticket.createdById !== me.id && !isModerator) { - throw new ApiError(meta.errors.accessDenied); - } - - if (ticket.usedAt && !isModerator) { - throw new ApiError(meta.errors.cantDelete); - } - - await this.registrationTicketsRepository.delete(ticket.id); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/invite/limit.ts b/packages/backend/src/server/api/endpoints/invite/limit.ts deleted file mode 100644 index 413e467136..0000000000 --- a/packages/backend/src/server/api/endpoints/invite/limit.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { MoreThan } from 'typeorm'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistrationTicketsRepository } from '@/models/_.js'; -import { RoleService } from '@/core/RoleService.js'; -import { DI } from '@/di-symbols.js'; -import { IdService } from '@/core/IdService.js'; - -export const meta = { - tags: ['meta'], - - requireCredential: 'always', - requireRolePolicy: 'canInvite', - kind: 'read:invite-codes', - - res: { - type: 'object', - optional: false, nullable: false, - properties: { - remaining: { - type: 'integer', - optional: false, nullable: true, - }, - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: {}, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.registrationTicketsRepository) - private registrationTicketsRepository: RegistrationTicketsRepository, - - private roleService: RoleService, - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const policies = await this.roleService.getUserPolicies(me.id); - - const count = policies.inviteLimit ? await this.registrationTicketsRepository.countBy({ - id: MoreThan(this.idService.gen(Date.now() - (policies.inviteExpirationTime * 60 * 1000))), - createdById: me.id, - }) : null; - - return { - remaining: count !== null ? Math.max(0, policies.inviteLimit - count) : null, - }; - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/invite/list.ts b/packages/backend/src/server/api/endpoints/invite/list.ts deleted file mode 100644 index e38f585386..0000000000 --- a/packages/backend/src/server/api/endpoints/invite/list.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistrationTicketsRepository } from '@/models/_.js'; -import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js'; -import { QueryService } from '@/core/QueryService.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['meta'], - - requireCredential: 'always', - requireRolePolicy: 'canInvite', - kind: 'read:invite-codes', - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'InviteCode', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - }, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.registrationTicketsRepository) - private registrationTicketsRepository: RegistrationTicketsRepository, - - private inviteCodeEntityService: InviteCodeEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.registrationTicketsRepository.createQueryBuilder('ticket'), ps.sinceId, ps.untilId) - .andWhere('ticket.createdById = :meId', { meId: me.id }) - .leftJoinAndSelect('ticket.createdBy', 'createdBy') - .leftJoinAndSelect('ticket.usedBy', 'usedBy'); - - const tickets = await query - .limit(ps.limit) - .getMany(); - - return await this.inviteCodeEntityService.packMany(tickets, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/messaging/history.ts b/packages/backend/src/server/api/endpoints/messaging/history.ts new file mode 100644 index 0000000000..ea0600d0e4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/history.ts @@ -0,0 +1,91 @@ +import define from '../../define.js'; +import { MessagingMessage } from '@/models/entities/messaging-message.js'; +import { MessagingMessages, Mutings, UserGroupJoinings } from '@/models/index.js'; +import { Brackets } from 'typeorm'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true, + + kind: 'read:messaging', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'MessagingMessage', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + group: { type: 'boolean', default: false }, + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const mute = await Mutings.findBy({ + muterId: user.id, + }); + + const groups = ps.group ? await UserGroupJoinings.findBy({ + userId: user.id, + }).then(xs => xs.map(x => x.userGroupId)) : []; + + if (ps.group && groups.length === 0) { + return []; + } + + const history: MessagingMessage[] = []; + + for (let i = 0; i < ps.limit; i++) { + const found = ps.group + ? history.map(m => m.groupId!) + : history.map(m => (m.userId === user.id) ? m.recipientId! : m.userId!); + + const query = MessagingMessages.createQueryBuilder('message') + .orderBy('message.createdAt', 'DESC'); + + if (ps.group) { + query.where(`message.groupId IN (:...groups)`, { groups: groups }); + + if (found.length > 0) { + query.andWhere(`message.groupId NOT IN (:...found)`, { found: found }); + } + } else { + query.where(new Brackets(qb => { qb + .where(`message.userId = :userId`, { userId: user.id }) + .orWhere(`message.recipientId = :userId`, { userId: user.id }); + })); + query.andWhere(`message.groupId IS NULL`); + + if (found.length > 0) { + query.andWhere(`message.userId NOT IN (:...found)`, { found: found }); + query.andWhere(`message.recipientId NOT IN (:...found)`, { found: found }); + } + + if (mute.length > 0) { + query.andWhere(`message.userId NOT IN (:...mute)`, { mute: mute.map(m => m.muteeId) }); + query.andWhere(`message.recipientId NOT IN (:...mute)`, { mute: mute.map(m => m.muteeId) }); + } + } + + const message = await query.getOne(); + + if (message) { + history.push(message); + } else { + break; + } + } + + return await Promise.all(history.map(h => MessagingMessages.pack(h.id, user))); +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/messages.ts b/packages/backend/src/server/api/endpoints/messaging/messages.ts new file mode 100644 index 0000000000..dbf1f6c868 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages.ts @@ -0,0 +1,141 @@ +import define from '../../define.js'; +import { ApiError } from '../../error.js'; +import { getUser } from '../../common/getters.js'; +import { MessagingMessages, UserGroups, UserGroupJoinings, Users } from '@/models/index.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Brackets } from 'typeorm'; +import { readUserMessagingMessage, readGroupMessagingMessage, deliverReadActivity } from '../../common/read-messaging-message.js'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true, + + kind: 'read:messaging', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'MessagingMessage', + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '11795c64-40ea-4198-b06e-3c873ed9039d', + }, + + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: 'c4d9f88c-9270-4632-b032-6ed8cee36f7f', + }, + + groupAccessDenied: { + message: 'You can not read messages of groups that you have not joined.', + code: 'GROUP_ACCESS_DENIED', + id: 'a053a8dd-a491-4718-8f87-50775aad9284', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + markAsRead: { type: 'boolean', default: true }, + }, + anyOf: [ + { + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], + }, + { + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + }, + required: ['groupId'], + }, + ], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + if (ps.userId != null) { + // Fetch recipient (user) + const recipient = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + const query = makePaginationQuery(MessagingMessages.createQueryBuilder('message'), ps.sinceId, ps.untilId) + .andWhere(new Brackets(qb => { qb + .where(new Brackets(qb => { qb + .where('message.userId = :meId') + .andWhere('message.recipientId = :recipientId'); + })) + .orWhere(new Brackets(qb => { qb + .where('message.userId = :recipientId') + .andWhere('message.recipientId = :meId'); + })); + })) + .setParameter('meId', user.id) + .setParameter('recipientId', recipient.id); + + const messages = await query.take(ps.limit).getMany(); + + // Mark all as read + if (ps.markAsRead) { + readUserMessagingMessage(user.id, recipient.id, messages.filter(m => m.recipientId === user.id).map(x => x.id)); + + // リモートユーザーとのメッセージだったら既読配信 + if (Users.isLocalUser(user) && Users.isRemoteUser(recipient)) { + deliverReadActivity(user, recipient, messages); + } + } + + return await Promise.all(messages.map(message => MessagingMessages.pack(message, user, { + populateRecipient: false, + }))); + } else if (ps.groupId != null) { + // Fetch recipient (group) + const recipientGroup = await UserGroups.findOneBy({ id: ps.groupId }); + + if (recipientGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // check joined + const joining = await UserGroupJoinings.findOneBy({ + userId: user.id, + userGroupId: recipientGroup.id, + }); + + if (joining == null) { + throw new ApiError(meta.errors.groupAccessDenied); + } + + const query = makePaginationQuery(MessagingMessages.createQueryBuilder('message'), ps.sinceId, ps.untilId) + .andWhere(`message.groupId = :groupId`, { groupId: recipientGroup.id }); + + const messages = await query.take(ps.limit).getMany(); + + // Mark all as read + if (ps.markAsRead) { + readGroupMessagingMessage(user.id, recipientGroup.id, messages.map(x => x.id)); + } + + return await Promise.all(messages.map(message => MessagingMessages.pack(message, user, { + populateGroup: false, + }))); + } +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/create.ts b/packages/backend/src/server/api/endpoints/messaging/messages/create.ts new file mode 100644 index 0000000000..405af5ec17 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages/create.ts @@ -0,0 +1,151 @@ +import define from '../../../define.js'; +import { ApiError } from '../../../error.js'; +import { getUser } from '../../../common/getters.js'; +import { MessagingMessages, DriveFiles, UserGroups, UserGroupJoinings, Blockings } from '@/models/index.js'; +import { User } from '@/models/entities/user.js'; +import { UserGroup } from '@/models/entities/user-group.js'; +import { createMessage } from '@/services/messages/create.js'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true, + + kind: 'write:messaging', + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'MessagingMessage', + }, + + errors: { + recipientIsYourself: { + message: 'You can not send a message to yourself.', + code: 'RECIPIENT_IS_YOURSELF', + id: '17e2ba79-e22a-4cbc-bf91-d327643f4a7e', + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '11795c64-40ea-4198-b06e-3c873ed9039d', + }, + + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: 'c94e2a5d-06aa-4914-8fa6-6a42e73d6537', + }, + + groupAccessDenied: { + message: 'You can not send messages to groups that you have not joined.', + code: 'GROUP_ACCESS_DENIED', + id: 'd96b3cca-5ad1-438b-ad8b-02f931308fbd', + }, + + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: '4372b8e2-185d-4146-8749-2f68864a3e5f', + }, + + contentRequired: { + message: 'Content required. You need to set text or fileId.', + code: 'CONTENT_REQUIRED', + id: '25587321-b0e6-449c-9239-f8925092942c', + }, + + youHaveBeenBlocked: { + message: 'You cannot send a message because you have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: 'c15a5199-7422-4968-941a-2a462c478f7d', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + text: { type: 'string', nullable: true, maxLength: 3000 }, + fileId: { type: 'string', format: 'misskey:id' }, + }, + anyOf: [ + { + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], + }, + { + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + }, + required: ['groupId'], + }, + ], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + let recipientUser: User | null; + let recipientGroup: UserGroup | null; + + if (ps.userId != null) { + // Myself + if (ps.userId === user.id) { + throw new ApiError(meta.errors.recipientIsYourself); + } + + // Fetch recipient (user) + recipientUser = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check blocking + const block = await Blockings.findOneBy({ + blockerId: recipientUser.id, + blockeeId: user.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } else if (ps.groupId != null) { + // Fetch recipient (group) + recipientGroup = await UserGroups.findOneBy({ id: ps.groupId! }); + + if (recipientGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // check joined + const joining = await UserGroupJoinings.findOneBy({ + userId: user.id, + userGroupId: recipientGroup.id, + }); + + if (joining == null) { + throw new ApiError(meta.errors.groupAccessDenied); + } + } + + let file = null; + if (ps.fileId != null) { + file = await DriveFiles.findOneBy({ + id: ps.fileId, + userId: user.id, + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + // テキストが無いかつ添付ファイルも無かったらエラー + if (ps.text == null && file == null) { + throw new ApiError(meta.errors.contentRequired); + } + + return await createMessage(user, recipientUser, recipientGroup, ps.text, file); +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts b/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts new file mode 100644 index 0000000000..f66d75873c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts @@ -0,0 +1,49 @@ +import define from '../../../define.js'; +import ms from 'ms'; +import { ApiError } from '../../../error.js'; +import { MessagingMessages } from '@/models/index.js'; +import { deleteMessage } from '@/services/messages/delete.js'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true, + + kind: 'write:messaging', + + limit: { + duration: ms('1hour'), + max: 300, + minInterval: ms('1sec'), + }, + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: '54b5b326-7925-42cf-8019-130fda8b56af', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + messageId: { type: 'string', format: 'misskey:id' }, + }, + required: ['messageId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const message = await MessagingMessages.findOneBy({ + id: ps.messageId, + userId: user.id, + }); + + if (message == null) { + throw new ApiError(meta.errors.noSuchMessage); + } + + await deleteMessage(message); +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/read.ts b/packages/backend/src/server/api/endpoints/messaging/messages/read.ts new file mode 100644 index 0000000000..db12ae922c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages/read.ts @@ -0,0 +1,49 @@ +import define from '../../../define.js'; +import { ApiError } from '../../../error.js'; +import { MessagingMessages } from '@/models/index.js'; +import { readUserMessagingMessage, readGroupMessagingMessage } from '../../../common/read-messaging-message.js'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true, + + kind: 'write:messaging', + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: '86d56a2f-a9c3-4afb-b13c-3e9bfef9aa14', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + messageId: { type: 'string', format: 'misskey:id' }, + }, + required: ['messageId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const message = await MessagingMessages.findOneBy({ id: ps.messageId }); + + if (message == null) { + throw new ApiError(meta.errors.noSuchMessage); + } + + if (message.recipientId) { + await readUserMessagingMessage(user.id, message.userId, [message.id]).catch(e => { + if (e.id === 'e140a4bf-49ce-4fb6-b67c-b78dadf6b52f') throw new ApiError(meta.errors.noSuchMessage); + throw e; + }); + } else if (message.groupId) { + await readGroupMessagingMessage(user.id, message.groupId, [message.id]).catch(e => { + if (e.id === '930a270c-714a-46b2-b776-ad27276dc569') throw new ApiError(meta.errors.noSuchMessage); + throw e; + }); + } +}); diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 7dd14a795c..5b624842c3 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -1,23 +1,15 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { IsNull, LessThanOrEqual, MoreThan, Brackets } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import JSON5 from 'json5'; -import type { AdsRepository, UsersRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { MetaService } from '@/core/MetaService.js'; -import type { Config } from '@/config.js'; -import { DI } from '@/di-symbols.js'; -import { DEFAULT_POLICIES } from '@/core/RoleService.js'; +import { IsNull, MoreThan } from 'typeorm'; +import config from '@/config/index.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Ads, Emojis, Users } from '@/models/index.js'; +import { DB_MAX_NOTE_TEXT_LENGTH } from '@/misc/hard-limits.js'; +import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import define from '../define.js'; export const meta = { tags: ['meta'], - requireCredential: 'never', + requireCredential: false, res: { type: 'object', @@ -34,15 +26,12 @@ export const meta = { version: { type: 'string', optional: false, nullable: false, + example: config.version, }, name: { type: 'string', optional: false, nullable: false, }, - shortName: { - type: 'string', - optional: false, nullable: true, - }, uri: { type: 'string', optional: false, nullable: false, @@ -83,30 +72,34 @@ export const meta = { type: 'string', optional: false, nullable: true, }, - defaultLike: { - type: 'string', - optional: false, nullable: true, - }, disableRegistration: { type: 'boolean', optional: false, nullable: false, }, + disableLocalTimeline: { + type: 'boolean', + optional: false, nullable: false, + }, + disableGlobalTimeline: { + type: 'boolean', + optional: false, nullable: false, + }, + driveCapacityPerLocalUserMb: { + type: 'number', + optional: false, nullable: false, + }, + driveCapacityPerRemoteUserMb: { + type: 'number', + optional: false, nullable: false, + }, cacheRemoteFiles: { type: 'boolean', optional: false, nullable: false, }, - cacheRemoteSensitiveFiles: { - type: 'boolean', - optional: false, nullable: false, - }, emailRequiredForSignup: { type: 'boolean', optional: false, nullable: false, }, - approvalRequiredForSignup: { - type: 'boolean', - optional: false, nullable: false, - }, enableHcaptcha: { type: 'boolean', optional: false, nullable: false, @@ -123,14 +116,6 @@ export const meta = { type: 'string', optional: false, nullable: true, }, - enableTurnstile: { - type: 'boolean', - optional: false, nullable: false, - }, - turnstileSiteKey: { - type: 'string', - optional: false, nullable: true, - }, swPublickey: { type: 'string', optional: false, nullable: true, @@ -144,17 +129,10 @@ export const meta = { type: 'string', optional: false, nullable: false, }, - serverErrorImageUrl: { + errorImageUrl: { type: 'string', - optional: false, nullable: true, - }, - infoImageUrl: { - type: 'string', - optional: false, nullable: true, - }, - notFoundImageUrl: { - type: 'string', - optional: false, nullable: true, + optional: false, nullable: false, + default: 'https://xn--931a.moe/aiart/yubitun.png', }, iconUrl: { type: 'string', @@ -164,7 +142,7 @@ export const meta = { type: 'number', optional: false, nullable: false, }, - ads: { + emojis: { type: 'array', optional: false, nullable: false, items: { @@ -175,38 +153,56 @@ export const meta = { type: 'string', optional: false, nullable: false, format: 'id', - example: 'xxxxxxxxxx', + }, + aliases: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, + category: { + type: 'string', + optional: false, nullable: true, + }, + host: { + type: 'string', + optional: false, nullable: true, + description: 'The local host is represented with `null`.', }, url: { type: 'string', optional: false, nullable: false, format: 'url', }, + }, + }, + }, + ads: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { place: { type: 'string', optional: false, nullable: false, }, - ratio: { - type: 'number', + url: { + type: 'string', optional: false, nullable: false, + format: 'url', }, imageUrl: { type: 'string', optional: false, nullable: false, format: 'url', }, - dayOfWeek: { - type: 'integer', - optional: false, nullable: false, - }, }, }, }, - notesPerOneAd: { - type: 'number', - optional: false, nullable: false, - default: 0, - }, requireSetup: { type: 'boolean', optional: false, nullable: false, @@ -216,6 +212,18 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + enableTwitterIntegration: { + type: 'boolean', + optional: false, nullable: false, + }, + enableGithubIntegration: { + type: 'boolean', + optional: false, nullable: false, + }, + enableDiscordIntegration: { + type: 'boolean', + optional: false, nullable: false, + }, enableServiceWorker: { type: 'boolean', optional: false, nullable: false, @@ -228,10 +236,6 @@ export const meta = { type: 'string', optional: false, nullable: true, }, - mediaProxy: { - type: 'string', - optional: false, nullable: false, - }, features: { type: 'object', optional: true, nullable: false, @@ -240,11 +244,15 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, - localTimeline: { + localTimeLine: { type: 'boolean', optional: false, nullable: false, }, - globalTimeline: { + globalTimeLine: { + type: 'boolean', + optional: false, nullable: false, + }, + elasticsearch: { type: 'boolean', optional: false, nullable: false, }, @@ -260,6 +268,18 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + twitter: { + type: 'boolean', + optional: false, nullable: false, + }, + github: { + type: 'boolean', + optional: false, nullable: false, + }, + discord: { + type: 'boolean', + optional: false, nullable: false, + }, serviceWorker: { type: 'boolean', optional: false, nullable: false, @@ -271,33 +291,6 @@ export const meta = { }, }, }, - backgroundImageUrl: { - type: 'string', - optional: false, nullable: true, - }, - impressumUrl: { - type: 'string', - optional: false, nullable: true, - }, - logoImageUrl: { - type: 'string', - optional: false, nullable: true, - }, - privacyPolicyUrl: { - type: 'string', - optional: false, nullable: true, - }, - serverRules: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - }, - }, - themeColor: { - type: 'string', - optional: false, nullable: true, - }, }, }, } as const; @@ -310,121 +303,112 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.config) - private config: Config, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const instance = await fetchMeta(true); - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, + const emojis = await Emojis.find({ + where: { + host: IsNull(), + }, + order: { + category: 'ASC', + name: 'ASC', + }, + cache: { + id: 'meta_emojis', + milliseconds: 3600000, // 1 hour + }, + }); - @Inject(DI.adsRepository) - private adsRepository: AdsRepository, + const ads = await Ads.find({ + where: { + expiresAt: MoreThan(new Date()), + }, + }); - private userEntityService: UserEntityService, - private metaService: MetaService, - ) { - super(meta, paramDef, async (ps, me) => { - const instance = await this.metaService.fetch(true); + const response: any = { + maintainerName: instance.maintainerName, + maintainerEmail: instance.maintainerEmail, - const ads = await this.adsRepository.createQueryBuilder('ads') - .where('ads.expiresAt > :now', { now: new Date() }) - .andWhere('ads.startsAt <= :now', { now: new Date() }) - .andWhere(new Brackets(qb => { - // 曜日のビットフラグを確認する - qb.where('ads.dayOfWeek & :dayOfWeek > 0', { dayOfWeek: 1 << new Date().getDay() }) - .orWhere('ads.dayOfWeek = 0'); - })) - .getMany(); + version: config.version, - const response: any = { - maintainerName: instance.maintainerName, - maintainerEmail: instance.maintainerEmail, + name: instance.name, + uri: config.url, + description: instance.description, + langs: instance.langs, + tosUrl: instance.ToSUrl, + repositoryUrl: instance.repositoryUrl, + feedbackUrl: instance.feedbackUrl, + disableRegistration: instance.disableRegistration, + disableLocalTimeline: instance.disableLocalTimeline, + disableGlobalTimeline: instance.disableGlobalTimeline, + driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, + driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, + emailRequiredForSignup: instance.emailRequiredForSignup, + enableHcaptcha: instance.enableHcaptcha, + hcaptchaSiteKey: instance.hcaptchaSiteKey, + enableRecaptcha: instance.enableRecaptcha, + recaptchaSiteKey: instance.recaptchaSiteKey, + swPublickey: instance.swPublicKey, + themeColor: instance.themeColor, + mascotImageUrl: instance.mascotImageUrl, + bannerUrl: instance.bannerUrl, + errorImageUrl: instance.errorImageUrl, + iconUrl: instance.iconUrl, + backgroundImageUrl: instance.backgroundImageUrl, + logoImageUrl: instance.logoImageUrl, + maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため + emojis: await Emojis.packMany(emojis), + defaultLightTheme: instance.defaultLightTheme, + defaultDarkTheme: instance.defaultDarkTheme, + ads: ads.map(ad => ({ + id: ad.id, + url: ad.url, + place: ad.place, + ratio: ad.ratio, + imageUrl: ad.imageUrl, + })), + enableEmail: instance.enableEmail, - version: this.config.version, + enableTwitterIntegration: instance.enableTwitterIntegration, + enableGithubIntegration: instance.enableGithubIntegration, + enableDiscordIntegration: instance.enableDiscordIntegration, - name: instance.name, - shortName: instance.shortName, - uri: this.config.url, - description: instance.description, - langs: instance.langs, - tosUrl: instance.termsOfServiceUrl, - repositoryUrl: instance.repositoryUrl, - feedbackUrl: instance.feedbackUrl, - impressumUrl: instance.impressumUrl, - privacyPolicyUrl: instance.privacyPolicyUrl, - disableRegistration: instance.disableRegistration, - emailRequiredForSignup: instance.emailRequiredForSignup, - approvalRequiredForSignup: instance.approvalRequiredForSignup, - enableHcaptcha: instance.enableHcaptcha, - hcaptchaSiteKey: instance.hcaptchaSiteKey, - enableRecaptcha: instance.enableRecaptcha, - enableAchievements: instance.enableAchievements, - recaptchaSiteKey: instance.recaptchaSiteKey, - enableTurnstile: instance.enableTurnstile, - turnstileSiteKey: instance.turnstileSiteKey, - swPublickey: instance.swPublicKey, - themeColor: instance.themeColor, - mascotImageUrl: instance.mascotImageUrl, - bannerUrl: instance.bannerUrl, - infoImageUrl: instance.infoImageUrl, - serverErrorImageUrl: instance.serverErrorImageUrl, - notFoundImageUrl: instance.notFoundImageUrl, - iconUrl: instance.iconUrl, - backgroundImageUrl: instance.backgroundImageUrl, - logoImageUrl: instance.logoImageUrl, - maxNoteTextLength: this.config.maxNoteLength, - // クライアントの手間を減らすためあらかじめJSONに変換しておく - defaultLightTheme: instance.defaultLightTheme ? JSON.stringify(JSON5.parse(instance.defaultLightTheme)) : null, - defaultDarkTheme: instance.defaultDarkTheme ? JSON.stringify(JSON5.parse(instance.defaultDarkTheme)) : null, - defaultLike: instance.defaultLike, - ads: ads.map(ad => ({ - id: ad.id, - url: ad.url, - place: ad.place, - ratio: ad.ratio, - imageUrl: ad.imageUrl, - dayOfWeek: ad.dayOfWeek, - })), - notesPerOneAd: instance.notesPerOneAd, - enableEmail: instance.enableEmail, - enableServiceWorker: instance.enableServiceWorker, + enableServiceWorker: instance.enableServiceWorker, - translatorAvailable: instance.deeplAuthKey != null, + translatorAvailable: instance.deeplAuthKey != null, - serverRules: instance.serverRules, + ...(ps.detail ? { + pinnedPages: instance.pinnedPages, + pinnedClipId: instance.pinnedClipId, + cacheRemoteFiles: instance.cacheRemoteFiles, + requireSetup: (await Users.countBy({ + host: IsNull(), + })) === 0, + } : {}), + }; - policies: { ...DEFAULT_POLICIES, ...instance.policies }, + if (ps.detail) { + const proxyAccount = instance.proxyAccountId ? await Users.pack(instance.proxyAccountId).catch(() => null) : null; - mediaProxy: this.config.mediaProxy, - - ...(ps.detail ? { - cacheRemoteFiles: instance.cacheRemoteFiles, - cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles, - requireSetup: (await this.usersRepository.countBy({ - host: IsNull(), - })) === 0, - } : {}), - }; - - if (ps.detail) { - const proxyAccount = instance.proxyAccountId ? await this.userEntityService.pack(instance.proxyAccountId).catch(() => null) : null; - - response.proxyAccountName = proxyAccount ? proxyAccount.username : null; - response.features = { - registration: !instance.disableRegistration, - emailRequiredForSignup: instance.emailRequiredForSignup, - hcaptcha: instance.enableHcaptcha, - recaptcha: instance.enableRecaptcha, - turnstile: instance.enableTurnstile, - objectStorage: instance.useObjectStorage, - serviceWorker: instance.enableServiceWorker, - miauth: true, - }; - } - - return response; - }); + response.proxyAccountName = proxyAccount ? proxyAccount.username : null; + response.features = { + registration: !instance.disableRegistration, + localTimeLine: !instance.disableLocalTimeline, + globalTimeLine: !instance.disableGlobalTimeline, + emailRequiredForSignup: instance.emailRequiredForSignup, + elasticsearch: config.elasticsearch ? true : false, + hcaptcha: instance.enableHcaptcha, + recaptcha: instance.enableRecaptcha, + objectStorage: instance.useObjectStorage, + twitter: instance.enableTwitterIntegration, + github: instance.enableGithubIntegration, + discord: instance.enableDiscordIntegration, + serviceWorker: instance.enableServiceWorker, + miauth: true, + }; } -} + + return response; +}); diff --git a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts index 22edc0be8c..73ecdaeb03 100644 --- a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts +++ b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts @@ -1,19 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AccessTokensRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; +import define from '../../define.js'; +import { AccessTokens } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; -import { DI } from '@/di-symbols.js'; export const meta = { tags: ['auth'], - requireCredential: 'always', + requireCredential: true, secure: true, @@ -43,37 +36,29 @@ export const paramDef = { required: ['session', 'permission'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.accessTokensRepository) - private accessTokensRepository: AccessTokensRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Generate access token + const accessToken = secureRndstr(32, true); - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - // Generate access token - const accessToken = secureRndstr(32); + const now = new Date(); - const now = new Date(); + // Insert access token doc + await AccessTokens.insert({ + id: genId(), + createdAt: now, + lastUsedAt: now, + session: ps.session, + userId: user.id, + token: accessToken, + hash: accessToken, + name: ps.name, + description: ps.description, + iconUrl: ps.iconUrl, + permission: ps.permission, + }); - // Insert access token doc - await this.accessTokensRepository.insert({ - id: this.idService.gen(now.getTime()), - lastUsedAt: now, - session: ps.session, - userId: me.id, - token: accessToken, - hash: accessToken, - name: ps.name, - description: ps.description, - iconUrl: ps.iconUrl, - permission: ps.permission, - }); - - return { - token: accessToken, - }; - }); - } -} + return { + token: accessToken, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/mute/create.ts b/packages/backend/src/server/api/endpoints/mute/create.ts index 335c486d5d..7e857e6731 100644 --- a/packages/backend/src/server/api/endpoints/mute/create.ts +++ b/packages/backend/src/server/api/endpoints/mute/create.ts @@ -1,30 +1,18 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import ms from 'ms'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { MutingsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { UserMutingService } from '@/core/UserMutingService.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { getUser } from '../../common/getters.js'; +import { genId } from '@/misc/gen-id.js'; +import { Mutings, NoteWatchings } from '@/models/index.js'; +import { Muting } from '@/models/entities/muting.js'; +import { publishUserEvent } from '@/services/stream.js'; export const meta = { tags: ['account'], - requireCredential: 'always', - prohibitMoved: true, + requireCredential: true, kind: 'write:mutes', - limit: { - duration: ms('1hour'), - max: 20, - }, - errors: { noSuchUser: { message: 'No such user.', @@ -59,46 +47,48 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const muter = user; - private getterService: GetterService, - private userMutingService: UserMutingService, - ) { - super(meta, paramDef, async (ps, me) => { - const muter = me; - - // 自分自身 - if (me.id === ps.userId) { - throw new ApiError(meta.errors.muteeIsYourself); - } - - // Get mutee - const mutee = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); - - // Check if already muting - const exist = await this.mutingsRepository.exist({ - where: { - muterId: muter.id, - muteeId: mutee.id, - }, - }); - - if (exist) { - throw new ApiError(meta.errors.alreadyMuting); - } - - if (ps.expiresAt && ps.expiresAt <= Date.now()) { - return; - } - - await this.userMutingService.mute(muter, mutee, ps.expiresAt ? new Date(ps.expiresAt) : null); - }); + // 自分自身 + if (user.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); } -} + + // Get mutee + const mutee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check if already muting + const exist = await Mutings.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyMuting); + } + + if (ps.expiresAt && ps.expiresAt <= Date.now()) { + return; + } + + // Create mute + await Mutings.insert({ + id: genId(), + createdAt: new Date(), + expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null, + muterId: muter.id, + muteeId: mutee.id, + } as Muting); + + publishUserEvent(user.id, 'mute', mutee); + + NoteWatchings.delete({ + userId: muter.id, + noteUserId: mutee.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/mute/delete.ts b/packages/backend/src/server/api/endpoints/mute/delete.ts index 0fcd3da228..0b173dbe24 100644 --- a/packages/backend/src/server/api/endpoints/mute/delete.ts +++ b/packages/backend/src/server/api/endpoints/mute/delete.ts @@ -1,20 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { MutingsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { UserMutingService } from '@/core/UserMutingService.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { getUser } from '../../common/getters.js'; +import { Mutings } from '@/models/index.js'; +import { publishUserEvent } from '@/services/stream.js'; export const meta = { tags: ['account'], - requireCredential: 'always', + requireCredential: true, kind: 'write:mutes', @@ -47,40 +40,35 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const muter = user; - private userMutingService: UserMutingService, - private getterService: GetterService, - ) { - super(meta, paramDef, async (ps, me) => { - const muter = me; - - // Check if the mutee is yourself - if (me.id === ps.userId) { - throw new ApiError(meta.errors.muteeIsYourself); - } - - // Get mutee - const mutee = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); - - // Check not muting - const exist = await this.mutingsRepository.findOneBy({ - muterId: muter.id, - muteeId: mutee.id, - }); - - if (exist == null) { - throw new ApiError(meta.errors.notMuting); - } - - await this.userMutingService.unmute([exist]); - }); + // Check if the mutee is yourself + if (user.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); } -} + + // Get mutee + const mutee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check not muting + const exist = await Mutings.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notMuting); + } + + // Delete mute + await Mutings.delete({ + id: exist.id, + }); + + publishUserEvent(user.id, 'unmute', mutee); +}); diff --git a/packages/backend/src/server/api/endpoints/mute/list.ts b/packages/backend/src/server/api/endpoints/mute/list.ts index f6131fdf33..31283cf4c1 100644 --- a/packages/backend/src/server/api/endpoints/mute/list.ts +++ b/packages/backend/src/server/api/endpoints/mute/list.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { MutingsRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { MutingEntityService } from '@/core/entities/MutingEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { Mutings } from '@/models/index.js'; export const meta = { tags: ['account'], - requireCredential: 'always', + requireCredential: true, kind: 'read:mutes', @@ -38,24 +30,14 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery(Mutings.createQueryBuilder('muting'), ps.sinceId, ps.untilId) + .andWhere(`muting.muterId = :meId`, { meId: me.id }); - private mutingEntityService: MutingEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.mutingsRepository.createQueryBuilder('muting'), ps.sinceId, ps.untilId) - .andWhere('muting.muterId = :meId', { meId: me.id }); + const mutings = await query + .take(ps.limit) + .getMany(); - const mutings = await query - .limit(ps.limit) - .getMany(); - - return await this.mutingEntityService.packMany(mutings, me); - }); - } -} + return await Mutings.packMany(mutings, me); +}); diff --git a/packages/backend/src/server/api/endpoints/my/apps.ts b/packages/backend/src/server/api/endpoints/my/apps.ts index 4facda797a..85b75c15df 100644 --- a/packages/backend/src/server/api/endpoints/my/apps.ts +++ b/packages/backend/src/server/api/endpoints/my/apps.ts @@ -1,19 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AppsRepository } from '@/models/_.js'; -import { AppEntityService } from '@/core/entities/AppEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../define.js'; +import { Apps } from '@/models/index.js'; export const meta = { tags: ['account', 'app'], - requireCredential: 'always', - kind: 'read:account', + requireCredential: true, res: { type: 'array', @@ -35,28 +26,19 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.appsRepository) - private appsRepository: AppsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = { + userId: user.id, + }; - private appEntityService: AppEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = { - userId: me.id, - }; + const apps = await Apps.find({ + where: query, + take: ps.limit, + skip: ps.offset, + }); - const apps = await this.appsRepository.find({ - where: query, - take: ps.limit, - skip: ps.offset, - }); - - return await Promise.all(apps.map(app => this.appEntityService.pack(app, me, { - detail: true, - }))); - }); - } -} + return await Promise.all(apps.map(app => Apps.pack(app, user, { + detail: true, + }))); +}); diff --git a/packages/backend/src/server/api/endpoints/notes.ts b/packages/backend/src/server/api/endpoints/notes.ts index ec8903d364..015b0338e3 100644 --- a/packages/backend/src/server/api/endpoints/notes.ts +++ b/packages/backend/src/server/api/endpoints/notes.ts @@ -1,20 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { DI } from '@/di-symbols.js'; +import { Notes } from '@/models/index.js'; +import define from '../define.js'; +import { makePaginationQuery } from '../common/make-pagination-query.js'; export const meta = { tags: ['notes'], - requireCredential: 'conditional', - res: { type: 'array', optional: false, nullable: false, @@ -41,53 +31,49 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.visibility = \'public\'') + .andWhere('note.localOnly = FALSE') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - private noteEntityService: NoteEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere('note.visibility = \'public\'') - .andWhere('note.localOnly = FALSE') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - if (ps.local) { - query.andWhere('note.userHost IS NULL'); - } - - if (ps.reply !== undefined) { - query.andWhere(ps.reply ? 'note.replyId IS NOT NULL' : 'note.replyId IS NULL'); - } - - if (ps.renote !== undefined) { - query.andWhere(ps.renote ? 'note.renoteId IS NOT NULL' : 'note.renoteId IS NULL'); - } - - if (ps.withFiles !== undefined) { - query.andWhere(ps.withFiles ? 'note.fileIds != \'{}\'' : 'note.fileIds = \'{}\''); - } - - if (ps.poll !== undefined) { - query.andWhere(ps.poll ? 'note.hasPoll = TRUE' : 'note.hasPoll = FALSE'); - } - - // TODO - //if (bot != undefined) { - // query.isBot = bot; - //} - - const notes = await query.limit(ps.limit).getMany(); - - return await this.noteEntityService.packMany(notes); - }); + if (ps.local) { + query.andWhere('note.userHost IS NULL'); } -} + + if (ps.reply !== undefined) { + query.andWhere(ps.reply ? 'note.replyId IS NOT NULL' : 'note.replyId IS NULL'); + } + + if (ps.renote !== undefined) { + query.andWhere(ps.renote ? 'note.renoteId IS NOT NULL' : 'note.renoteId IS NULL'); + } + + if (ps.withFiles !== undefined) { + query.andWhere(ps.withFiles ? 'note.fileIds != \'{}\'' : 'note.fileIds = \'{}\''); + } + + if (ps.poll !== undefined) { + query.andWhere(ps.poll ? 'note.hasPoll = TRUE' : 'note.hasPoll = FALSE'); + } + + // TODO + //if (bot != undefined) { + // query.isBot = bot; + //} + + const notes = await query.take(ps.limit).getMany(); + + return await Notes.packMany(notes); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts deleted file mode 100644 index ca9eaf2cfa..0000000000 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Brackets } from 'typeorm'; -import type { NotesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import ActiveUsersChart from '@/core/chart/charts/active-users.js'; -import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; -import { ApiError } from '../../error.js'; -import { CacheService } from '@/core/CacheService.js'; -import { MetaService } from '@/core/MetaService.js'; - -export const meta = { - tags: ['notes'], - - requireCredential: 'conditional', - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'Note', - }, - }, - - errors: { - btlDisabled: { - message: 'Bubble timeline has been disabled.', - code: 'BTL_DISABLED', - id: '0332fc13-6ab2-4427-ae80-a9fadffd1a6c', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - withFiles: { type: 'boolean', default: false }, - withBots: { type: 'boolean', default: true }, - withRenotes: { type: 'boolean', default: true }, - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - sinceDate: { type: 'integer' }, - untilDate: { type: 'integer' }, - }, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - private noteEntityService: NoteEntityService, - private queryService: QueryService, - private roleService: RoleService, - private activeUsersChart: ActiveUsersChart, - private cacheService: CacheService, - private metaService: MetaService, - ) { - super(meta, paramDef, async (ps, me) => { - const policies = await this.roleService.getUserPolicies(me ? me.id : null); - const instance = await this.metaService.fetch(); - if (!policies.btlAvailable) { - throw new ApiError(meta.errors.btlDisabled); - } - - const [ - followings, - ] = me ? await Promise.all([ - this.cacheService.userFollowingsCache.fetch(me.id), - ]) : [undefined]; - - //#region Construct query - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.visibility = \'public\'') - .andWhere('note.channelId IS NULL') - .andWhere('note.userHost IN (:...hosts)', { hosts: instance.bubbleInstances }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - } - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - - if (!ps.withBots) query.andWhere('user.isBot = FALSE'); - - if (ps.withRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.where('note.renoteId IS NULL'); - qb.orWhere(new Brackets(qb => { - qb.where('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - })); - })); - } - //#endregion - - let timeline = await query.limit(ps.limit).getMany(); - - timeline = timeline.filter(note => { - if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false; - return true; - }); - - process.nextTick(() => { - if (me) { - this.activeUsersChart.read(me); - } - }); - - return await this.noteEntityService.packMany(timeline, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts index a22a68bed2..efc109105c 100644 --- a/packages/backend/src/server/api/endpoints/notes/children.ts +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -1,20 +1,15 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { Brackets } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { DI } from '@/di-symbols.js'; +import { Notes } from '@/models/index.js'; +import define from '../../define.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; +import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; export const meta = { tags: ['notes'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'array', @@ -34,53 +29,43 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, - showQuotes: { type: 'boolean', default: true }, }, required: ['noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(new Brackets(qb => { qb + .where('note.replyId = :noteId', { noteId: ps.noteId }) + .orWhere(new Brackets(qb => { qb + .where('note.renoteId = :noteId', { noteId: ps.noteId }) + .andWhere(new Brackets(qb => { qb + .where('note.text IS NOT NULL') + .orWhere('note.fileIds != \'{}\'') + .orWhere('note.hasPoll = TRUE'); + })); + })); + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - private noteEntityService: NoteEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere(new Brackets(qb => { - qb - .where('note.replyId = :noteId', { noteId: ps.noteId }); - if (ps.showQuotes) { - qb.orWhere(new Brackets(qb => { - qb - .where('note.renoteId = :noteId', { noteId: ps.noteId }) - .andWhere(new Brackets(qb => { - qb - .where('note.text IS NOT NULL') - .orWhere('note.fileIds != \'{}\'') - .orWhere('note.hasPoll = TRUE'); - })); - })); - } - })) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - this.queryService.generateVisibilityQuery(query, me); - if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - } - - const notes = await query.limit(ps.limit).getMany(); - - return await this.noteEntityService.packMany(notes, me); - }); + generateVisibilityQuery(query, user); + if (user) { + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); } -} + + const notes = await query.take(ps.limit).getMany(); + + return await Notes.packMany(notes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/clips.ts b/packages/backend/src/server/api/endpoints/notes/clips.ts index d2123620ca..e79f8563e8 100644 --- a/packages/backend/src/server/api/endpoints/notes/clips.ts +++ b/packages/backend/src/server/api/endpoints/notes/clips.ts @@ -1,21 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { In } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { ClipNotesRepository, ClipsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; +import { ClipNotes, Clips } from '@/models/index.js'; +import define from '../../define.js'; +import { getNote } from '../../common/getters.js'; import { ApiError } from '../../error.js'; export const meta = { tags: ['clips', 'notes'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'array', @@ -44,34 +36,21 @@ export const paramDef = { required: ['noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.clipsRepository) - private clipsRepository: ClipsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); - @Inject(DI.clipNotesRepository) - private clipNotesRepository: ClipNotesRepository, + const clipNotes = await ClipNotes.findBy({ + noteId: note.id, + }); - private clipEntityService: ClipEntityService, - private getterService: GetterService, - ) { - super(meta, paramDef, async (ps, me) => { - const note = await this.getterService.getNote(ps.noteId).catch(err => { - if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw err; - }); + const clips = await Clips.findBy({ + id: In(clipNotes.map(x => x.clipId)), + isPublic: true, + }); - const clipNotes = await this.clipNotesRepository.findBy({ - noteId: note.id, - }); - - const clips = await this.clipsRepository.findBy({ - id: In(clipNotes.map(x => x.clipId)), - isPublic: true, - }); - - return await this.clipEntityService.packMany(clips, me); - }); - } -} + return await Promise.all(clips.map(x => Clips.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/conversation.ts b/packages/backend/src/server/api/endpoints/notes/conversation.ts index 673610bf96..b731d18248 100644 --- a/packages/backend/src/server/api/endpoints/notes/conversation.ts +++ b/packages/backend/src/server/api/endpoints/notes/conversation.ts @@ -1,21 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { MiNote } from '@/models/Note.js'; -import type { NotesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; +import { Note } from '@/models/entities/note.js'; +import { Notes } from '@/models/index.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { getNote } from '../../common/getters.js'; export const meta = { tags: ['notes'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'array', @@ -46,51 +38,37 @@ export const paramDef = { required: ['noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); - private noteEntityService: NoteEntityService, - private getterService: GetterService, - ) { - super(meta, paramDef, async (ps, me) => { - const note = await this.getterService.getNote(ps.noteId).catch(err => { - if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw err; - }); + const conversation: Note[] = []; + let i = 0; - const conversation: MiNote[] = []; - let i = 0; + async function get(id: any) { + i++; + const p = await Notes.findOneBy({ id }); + if (p == null) return; - const get = async (id: any) => { - i++; - const p = await this.notesRepository.findOneBy({ id }); - if (p == null) return; + if (i > ps.offset!) { + conversation.push(p); + } - if (i > ps.offset!) { - conversation.push(p); - } + if (conversation.length === ps.limit) { + return; + } - if (conversation.length === ps.limit) { - return; - } - - if (p.replyId) { - await get(p.replyId); - } - }; - - if (note.replyId) { - await get(note.replyId); - } - - if (note.hasPoll) { - return await this.noteEntityService.packMany(conversation, me, { detail: true }); - } - - return await this.noteEntityService.packMany(conversation, me); - }); + if (p.replyId) { + await get(p.replyId); + } } -} + + if (note.replyId) { + await get(note.replyId); + } + + return await Notes.packMany(conversation, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/create.test.ts b/packages/backend/src/server/api/endpoints/notes/create.test.ts deleted file mode 100644 index 0de5a14a93..0000000000 --- a/packages/backend/src/server/api/endpoints/notes/create.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import { readFile } from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; -import { describe, test, expect } from '@jest/globals'; -import { getValidator } from '../../../../../test/prelude/get-api-validator.js'; -import { paramDef } from './create.js'; - -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); - -const VALID = true; -const INVALID = false; - -describe('api:notes/create', () => { - describe('validation', () => { - const v = getValidator(paramDef); - const tooLong = readFile(_dirname + '/../../../../../test/resources/misskey.svg', 'utf-8'); - - test('reject empty', () => { - const valid = v({ }); - expect(valid).toBe(INVALID); - }); - - describe('text', () => { - test('simple post', () => { - expect(v({ text: 'Hello, world!' })) - .toBe(VALID); - }); - - // TODO - //test('null post', () => { - // expect(v({ text: null })) - // .toBe(INVALID); - //}); - - test('0 characters post', () => { - expect(v({ text: '' })) - .toBe(INVALID); - }); - - test('over 3000 characters post', async () => { - expect(v({ text: await tooLong })) - .toBe(INVALID); - }); - }); - - describe('cw', () => { - test('simple cw', () => { - expect(v({ text: 'Hello, world!', cw: 'Hello, world!' })) - .toBe(VALID); - }); - - test('null cw', () => { - expect(v({ text: 'Body', cw: null })) - .toBe(VALID); - }); - - test('0 characters cw', () => { - expect(v({ text: 'Body', cw: '' })) - .toBe(INVALID); - }); - - test('reject only cw', () => { - expect(v({ cw: 'Hello, world!' })) - .toBe(INVALID); - }); - - test('over 500 characters cw', async () => { - expect(v({ text: 'Body', cw: await tooLong })) - .toBe(INVALID); - }); - }); - - describe('visibility', () => { - test('public', () => { - expect(v({ text: 'Hello, world!', visibility: 'public' })) - .toBe(VALID); - }); - - test('home', () => { - expect(v({ text: 'Hello, world!', visibility: 'home' })) - .toBe(VALID); - }); - - test('followers', () => { - expect(v({ text: 'Hello, world!', visibility: 'followers' })) - .toBe(VALID); - }); - - test('reject only visibility', () => { - expect(v({ visibility: 'public' })) - .toBe(INVALID); - }); - - test('reject invalid visibility', () => { - expect(v({ text: 'Hello, world!', visibility: 'invalid' })) - .toBe(INVALID); - }); - - test('reject null visibility', () => { - expect(v({ text: 'Hello, world!', visibility: null })) - .toBe(INVALID); - }); - - describe('visibility:specified', () => { - test('specified without visibleUserIds', () => { - expect(v({ text: 'Hello, world!', visibility: 'specified' })) - .toBe(VALID); - }); - - test('specified with empty visibleUserIds', () => { - expect(v({ text: 'Hello, world!', visibility: 'specified', visibleUserIds: [] })) - .toBe(VALID); - }); - - test('reject specified with non unique visibleUserIds', () => { - expect(v({ text: 'Hello, world!', visibility: 'specified', visibleUserIds: ['1', '1', '2'] })) - .toBe(INVALID); - }); - - test('reject specified with null visibleUserIds', () => { - expect(v({ text: 'Hello, world!', visibility: 'specified', visibleUserIds: null })) - .toBe(INVALID); - }); - }); - }); - - describe('fileIds', () => { - test('only fileIds', () => { - expect(v({ fileIds: ['1', '2', '3'] })) - .toBe(VALID); - }); - - test('text and fileIds', () => { - expect(v({ text: 'Hello, world!', fileIds: ['1', '2', '3'] })) - .toBe(VALID); - }); - - test('reject null fileIds', () => { - expect(v({ fileIds: null })) - .toBe(INVALID); - }); - - test('reject text and null fileIds (複合的なanyOfのバリデーションが正しく動作する)', () => { - expect(v({ text: 'Hello, world!', fileIds: null })) - .toBe(INVALID); - }); - - test('reject 0 files', () => { - expect(v({ fileIds: [] })) - .toBe(INVALID); - }); - - test('reject non unique', () => { - expect(v({ fileIds: ['1', '1', '2'] })) - .toBe(INVALID); - }); - - test('reject invalid id', () => { - expect(v({ fileIds: ['あ'] })) - .toBe(INVALID); - }); - - test('reject over 17 files', () => { - const valid = v({ text: 'Hello, world!', fileIds: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18'] }); - expect(valid).toBe(INVALID); - }); - }); - - describe('poll', () => { - test('note with poll', () => { - expect(v({ text: 'Hello, world!', poll: { choices: ['a', 'b', 'c'] } })) - .toBe(VALID); - }); - - test('null poll', () => { - expect(v({ text: 'Hello, world!', poll: null })) - .toBe(VALID); - }); - - test('allow only poll', () => { - expect(v({ poll: { choices: ['a', 'b', 'c'] } })) - .toBe(VALID); - }); - - test('poll with expiresAt', async () => { - expect(v({ poll: { choices: ['a', 'b', 'c'], expiresAt: 1 } })) - .toBe(VALID); - }); - - test('poll with expiredAfter', async () => { - expect(v({ poll: { choices: ['a', 'b', 'c'], expiredAfter: 1 } })) - .toBe(VALID); - }); - - test('reject poll without choices', () => { - expect(v({ poll: { } })) - .toBe(INVALID); - }); - - test('reject poll with empty choices', () => { - expect(v({ poll: { choices: [] } })) - .toBe(INVALID); - }); - - test('reject poll with null choices', () => { - expect(v({ poll: { choices: null } })) - .toBe(INVALID); - }); - - test('reject poll with 1 choice', () => { - expect(v({ poll: { choices: ['a'] } })) - .toBe(INVALID); - }); - - test('reject poll with too long choice', async () => { - expect(v({ poll: { choices: [await tooLong, '2'] } })) - .toBe(INVALID); - }); - - test('reject poll with too many choices', () => { - expect(v({ poll: { choices: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k'] } })) - .toBe(INVALID); - }); - - test('reject poll with non unique choices', () => { - expect(v({ poll: { choices: ['a', 'a', 'b', 'c'] } })) - .toBe(INVALID); - }); - - test('reject poll with expiredAfter 0', async () => { - expect(v({ poll: { choices: ['a', 'b', 'c'], expiredAfter: 0 } })) - .toBe(INVALID); - }); - }); - - describe('renote', () => { - test('just a renote', () => { - expect(v({ renoteId: '1' })) - .toBe(VALID); - }); - test('just a quote', () => { - expect(v({ text: 'Hello, world!', renoteId: '1' })) - .toBe(VALID); - }); - test('reject invalid renoteId', () => { - expect(v({ renoteId: 'あ' })) - .toBe(INVALID); - }); - }); - - test('text, fileIds and poll', () => { - expect(v({ text: 'Hello, world!', fileIds: ['1', '2', '3'], poll: { choices: ['a', 'b', 'c'] } })) - .toBe(VALID); - }); - - test('text, invalid fileIds and invalid poll', () => { - expect(v({ text: 'Hello, world!', fileIds: ['あ'], poll: { choices: ['a'] } })) - .toBe(INVALID); - }); - }); -}); diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index b70d45bb47..a133294169 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -1,30 +1,20 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import ms from 'ms'; import { In } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { MiUser } from '@/models/User.js'; -import type { UsersRepository, NotesRepository, BlockingsRepository, DriveFilesRepository, ChannelsRepository } from '@/models/_.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiNote } from '@/models/Note.js'; -import type { MiChannel } from '@/models/Channel.js'; -import type { Config } from '@/config.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { NoteCreateService } from '@/core/NoteCreateService.js'; -import { DI } from '@/di-symbols.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; +import create from '@/services/note/create.js'; +import { User } from '@/models/entities/user.js'; +import { Users, DriveFiles, Notes, Channels, Blockings } from '@/models/index.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { Note } from '@/models/entities/note.js'; +import { Channel } from '@/models/entities/channel.js'; +import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { noteVisibilities } from '../../../../types.js'; import { ApiError } from '../../error.js'; +import define from '../../define.js'; export const meta = { tags: ['notes'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, limit: { duration: ms('1hour'), @@ -58,36 +48,18 @@ export const meta = { id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a', }, - cannotRenoteDueToVisibility: { - message: 'You can not Renote due to target visibility.', - code: 'CANNOT_RENOTE_DUE_TO_VISIBILITY', - id: 'be9529e9-fe72-4de0-ae43-0b363c4938af', - }, - noSuchReplyTarget: { message: 'No such reply target.', code: 'NO_SUCH_REPLY_TARGET', id: '749ee0f6-d3da-459a-bf02-282e2da4292c', }, - cannotReplyToInvisibleNote: { - message: 'You cannot reply to an invisible Note.', - code: 'CANNOT_REPLY_TO_AN_INVISIBLE_NOTE', - id: 'b98980fa-3780-406c-a935-b6d0eeee10d1', - }, - cannotReplyToPureRenote: { message: 'You can not reply to a pure Renote.', code: 'CANNOT_REPLY_TO_A_PURE_RENOTE', id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15', }, - maxLength: { - message: 'You tried posting a note which is too long.', - code: 'MAX_LENGTH', - id: '3ac74a84-8fd5-4bb0-870f-01804f82ce16', - }, - cannotCreateAlreadyExpiredPoll: { message: 'Poll is already expired.', code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', @@ -105,18 +77,6 @@ export const meta = { code: 'YOU_HAVE_BEEN_BLOCKED', id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3', }, - - noSuchFile: { - message: 'Some files are not found.', - code: 'NO_SUCH_FILE', - id: 'b6992544-63e7-67f0-fa7f-32444b1b5306', - }, - - cannotRenoteOutsideOfChannel: { - message: 'Cannot renote outside of channel.', - code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL', - id: '33510210-8452-094c-6227-4a6c05d99f00', - }, }, } as const; @@ -127,23 +87,12 @@ export const paramDef = { visibleUserIds: { type: 'array', uniqueItems: true, items: { type: 'string', format: 'misskey:id', } }, - cw: { type: 'string', nullable: true, minLength: 1, maxLength: 500 }, + text: { type: 'string', maxLength: MAX_NOTE_TEXT_LENGTH, nullable: true }, + cw: { type: 'string', nullable: true, maxLength: 100 }, localOnly: { type: 'boolean', default: false }, - reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, noExtractMentions: { type: 'boolean', default: false }, noExtractHashtags: { type: 'boolean', default: false }, noExtractEmojis: { type: 'boolean', default: false }, - replyId: { type: 'string', format: 'misskey:id', nullable: true }, - renoteId: { type: 'string', format: 'misskey:id', nullable: true }, - channelId: { type: 'string', format: 'misskey:id', nullable: true }, - - // anyOf内にバリデーションを書いても最初の一つしかチェックされない - // See https://github.com/misskey-dev/misskey/pull/10082 - text: { - type: 'string', - minLength: 1, - nullable: true, - }, fileIds: { type: 'array', uniqueItems: true, @@ -152,12 +101,17 @@ export const paramDef = { items: { type: 'string', format: 'misskey:id' }, }, mediaIds: { + deprecated: true, + description: 'Use `fileIds` instead. If both are specified, this property is discarded.', type: 'array', uniqueItems: true, minItems: 1, maxItems: 16, items: { type: 'string', format: 'misskey:id' }, }, + replyId: { type: 'string', format: 'misskey:id', nullable: true }, + renoteId: { type: 'string', format: 'misskey:id', nullable: true }, + channelId: { type: 'string', format: 'misskey:id', nullable: true }, poll: { type: 'object', nullable: true, @@ -169,194 +123,153 @@ export const paramDef = { maxItems: 10, items: { type: 'string', minLength: 1, maxLength: 50 }, }, - multiple: { type: 'boolean' }, + multiple: { type: 'boolean', default: false }, expiresAt: { type: 'integer', nullable: true }, expiredAfter: { type: 'integer', nullable: true, minimum: 1 }, }, required: ['choices'], }, }, - // (re)note with text, files and poll are optional anyOf: [ - { required: ['text'] }, - { required: ['renoteId'] }, - { required: ['fileIds'] }, - { required: ['mediaIds'] }, - { required: ['poll'] }, + { + // (re)note with text, files and poll are optional + properties: { + text: { type: 'string', minLength: 1, maxLength: MAX_NOTE_TEXT_LENGTH, nullable: false }, + }, + required: ['text'], + }, + { + // (re)note with files, text and poll are optional + required: ['fileIds'], + }, + { + // (re)note with files, text and poll are optional + required: ['mediaIds'], + }, + { + // (re)note with poll, text and files are optional + properties: { + poll: { type: 'object', nullable: false }, + }, + required: ['poll'], + }, + { + // pure renote + required: ['renoteId'], + }, ], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - - private noteEntityService: NoteEntityService, - private noteCreateService: NoteCreateService, - ) { - super(meta, paramDef, async (ps, me) => { - if (ps.text && (ps.text.length > this.config.maxNoteLength)) { - throw new ApiError(meta.errors.maxLength); - } - - let visibleUsers: MiUser[] = []; - if (ps.visibleUserIds) { - visibleUsers = await this.usersRepository.findBy({ - id: In(ps.visibleUserIds), - }); - } - - let files: MiDriveFile[] = []; - const fileIds = ps.fileIds ?? ps.mediaIds ?? null; - if (fileIds != null) { - files = await this.driveFilesRepository.createQueryBuilder('file') - .where('file.userId = :userId AND file.id IN (:...fileIds)', { - userId: me.id, - fileIds, - }) - .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') - .setParameters({ fileIds }) - .getMany(); - - if (files.length !== fileIds.length) { - throw new ApiError(meta.errors.noSuchFile); - } - } - - let renote: MiNote | null = null; - if (ps.renoteId != null) { - // Fetch renote to note - renote = await this.notesRepository.findOneBy({ id: ps.renoteId }); - - if (renote == null) { - throw new ApiError(meta.errors.noSuchRenoteTarget); - } else if (isPureRenote(renote)) { - throw new ApiError(meta.errors.cannotReRenote); - } - - // Check blocking - if (renote.userId !== me.id) { - const blockExist = await this.blockingsRepository.exist({ - where: { - blockerId: renote.userId, - blockeeId: me.id, - }, - }); - if (blockExist) { - throw new ApiError(meta.errors.youHaveBeenBlocked); - } - } - - if (renote.visibility === 'followers' && renote.userId !== me.id) { - // 他人のfollowers noteはreject - throw new ApiError(meta.errors.cannotRenoteDueToVisibility); - } else if (renote.visibility === 'specified') { - // specified / direct noteはreject - throw new ApiError(meta.errors.cannotRenoteDueToVisibility); - } - - if (renote.channelId && renote.channelId !== ps.channelId) { - // チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック - // リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する - const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId }); - if (renoteChannel == null) { - // リノートしたいノートが書き込まれているチャンネルが無い - throw new ApiError(meta.errors.noSuchChannel); - } else if (!renoteChannel.allowRenoteToExternal) { - // リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合 - throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel); - } - } - } - - let reply: MiNote | null = null; - if (ps.replyId != null) { - // Fetch reply - reply = await this.notesRepository.findOneBy({ id: ps.replyId }); - - if (reply == null) { - throw new ApiError(meta.errors.noSuchReplyTarget); - } else if (isPureRenote(reply)) { - throw new ApiError(meta.errors.cannotReplyToPureRenote); - } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) { - throw new ApiError(meta.errors.noSuchReplyTarget); - } - - // Check blocking - if (reply.userId !== me.id) { - const blockExist = await this.blockingsRepository.exist({ - where: { - blockerId: reply.userId, - blockeeId: me.id, - }, - }); - if (blockExist) { - throw new ApiError(meta.errors.youHaveBeenBlocked); - } - } - } - - if (ps.poll) { - if (typeof ps.poll.expiresAt === 'number') { - if (ps.poll.expiresAt < Date.now()) { - throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); - } - } else if (typeof ps.poll.expiredAfter === 'number') { - ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter; - } - } - - let channel: MiChannel | null = null; - if (ps.channelId != null) { - channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false }); - - if (channel == null) { - throw new ApiError(meta.errors.noSuchChannel); - } - } - - // 投稿を作成 - const note = await this.noteCreateService.create(me, { - createdAt: new Date(), - files: files, - poll: ps.poll ? { - choices: ps.poll.choices, - multiple: ps.poll.multiple ?? false, - expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, - } : undefined, - text: ps.text ?? undefined, - reply, - renote, - cw: ps.cw, - localOnly: ps.localOnly, - reactionAcceptance: ps.reactionAcceptance, - visibility: ps.visibility, - visibleUsers, - channel, - apMentions: ps.noExtractMentions ? [] : undefined, - apHashtags: ps.noExtractHashtags ? [] : undefined, - apEmojis: ps.noExtractEmojis ? [] : undefined, - }); - - return { - createdNote: await this.noteEntityService.pack(note, me), - }; +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + let visibleUsers: User[] = []; + if (ps.visibleUserIds) { + visibleUsers = await Users.findBy({ + id: In(ps.visibleUserIds), }); } -} + + let files: DriveFile[] = []; + const fileIds = ps.fileIds != null ? ps.fileIds : ps.mediaIds != null ? ps.mediaIds : null; + if (fileIds != null) { + files = await DriveFiles.createQueryBuilder('file') + .where('file.userId = :userId AND file.id IN (:...fileIds)', { + userId: user.id, + fileIds, + }) + .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') + .setParameters({ fileIds }) + .getMany(); + } + + let renote: Note | null = null; + if (ps.renoteId != null) { + // Fetch renote to note + renote = await Notes.findOneBy({ id: ps.renoteId }); + + if (renote == null) { + throw new ApiError(meta.errors.noSuchRenoteTarget); + } else if (renote.renoteId && !renote.text && !renote.fileIds && !renote.hasPoll) { + throw new ApiError(meta.errors.cannotReRenote); + } + + // Check blocking + if (renote.userId !== user.id) { + const block = await Blockings.findOneBy({ + blockerId: renote.userId, + blockeeId: user.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + } + + let reply: Note | null = null; + if (ps.replyId != null) { + // Fetch reply + reply = await Notes.findOneBy({ id: ps.replyId }); + + if (reply == null) { + throw new ApiError(meta.errors.noSuchReplyTarget); + } else if (reply.renoteId && !reply.text && !reply.fileIds && !reply.hasPoll) { + throw new ApiError(meta.errors.cannotReplyToPureRenote); + } + + // Check blocking + if (reply.userId !== user.id) { + const block = await Blockings.findOneBy({ + blockerId: reply.userId, + blockeeId: user.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + } + + if (ps.poll) { + if (typeof ps.poll.expiresAt === 'number') { + if (ps.poll.expiresAt < Date.now()) { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + } + } else if (typeof ps.poll.expiredAfter === 'number') { + ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter; + } + } + + let channel: Channel | null = null; + if (ps.channelId != null) { + channel = await Channels.findOneBy({ id: ps.channelId }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + } + + // 投稿を作成 + const note = await create(user, { + createdAt: new Date(), + files: files, + poll: ps.poll ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple || false, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, + } : undefined, + text: ps.text || undefined, + reply, + renote, + cw: ps.cw, + localOnly: ps.localOnly, + visibility: ps.visibility, + visibleUsers, + channel, + apMentions: ps.noExtractMentions ? [] : undefined, + apHashtags: ps.noExtractHashtags ? [] : undefined, + apEmojis: ps.noExtractEmojis ? [] : undefined, + }); + + return { + createdNote: await Notes.pack(note, user), + }; +}); diff --git a/packages/backend/src/server/api/endpoints/notes/delete.ts b/packages/backend/src/server/api/endpoints/notes/delete.ts index 74e425ac60..c23ceeb5bf 100644 --- a/packages/backend/src/server/api/endpoints/notes/delete.ts +++ b/packages/backend/src/server/api/endpoints/notes/delete.ts @@ -1,22 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import ms from 'ms'; -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteDeleteService } from '@/core/NoteDeleteService.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { RoleService } from '@/core/RoleService.js'; +import deleteNote from '@/services/note/delete.js'; +import { Users } from '@/models/index.js'; +import define from '../../define.js'; +import { getNote } from '../../common/getters.js'; import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], - requireCredential: 'always', + requireCredential: true, kind: 'write:notes', @@ -49,28 +41,17 @@ export const paramDef = { required: ['noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); - private getterService: GetterService, - private roleService: RoleService, - private noteDeleteService: NoteDeleteService, - ) { - super(meta, paramDef, async (ps, me) => { - const note = await this.getterService.getNote(ps.noteId).catch(err => { - if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw err; - }); - - if (!await this.roleService.isModerator(me) && (note.userId !== me.id)) { - throw new ApiError(meta.errors.accessDenied); - } - - // この操作を行うのが投稿者とは限らない(例えばモデレーター)ため - await this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: note.userId }), note, false, me); - }); + if ((!user.isAdmin && !user.isModerator) && (note.userId !== user.id)) { + throw new ApiError(meta.errors.accessDenied); } -} + + // この操作を行うのが投稿者とは限らない(例えばモデレーター)ため + await deleteNote(await Users.findOneByOrFail({ id: note.userId }), note); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts deleted file mode 100644 index 80700ba84e..0000000000 --- a/packages/backend/src/server/api/endpoints/notes/edit.ts +++ /dev/null @@ -1,410 +0,0 @@ -import ms from 'ms'; -import { In } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { MiUser } from '@/models/User.js'; -import type { UsersRepository, NotesRepository, BlockingsRepository, DriveFilesRepository, ChannelsRepository } from '@/models/_.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiNote } from '@/models/Note.js'; -import type { MiChannel } from '@/models/Channel.js'; -import type { Config } from '@/config.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { NoteEditService } from '@/core/NoteEditService.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['notes'], - - requireCredential: 'always', - - limit: { - duration: ms('1hour'), - max: 300, - }, - - kind: 'write:notes', - - res: { - type: 'object', - optional: false, - nullable: false, - properties: { - createdNote: { - type: 'object', - optional: false, - nullable: false, - ref: 'Note', - }, - }, - }, - - errors: { - noSuchRenoteTarget: { - message: 'No such renote target.', - code: 'NO_SUCH_RENOTE_TARGET', - id: 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4', - }, - - cannotReRenote: { - message: 'You can not Renote a pure Renote.', - code: 'CANNOT_RENOTE_TO_A_PURE_RENOTE', - id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a', - }, - - noSuchReplyTarget: { - message: 'No such reply target.', - code: 'NO_SUCH_REPLY_TARGET', - id: '749ee0f6-d3da-459a-bf02-282e2da4292c', - }, - - cannotReplyToPureRenote: { - message: 'You can not reply to a pure Renote.', - code: 'CANNOT_REPLY_TO_A_PURE_RENOTE', - id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15', - }, - - cannotCreateAlreadyExpiredPoll: { - message: 'Poll is already expired.', - code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', - id: '04da457d-b083-4055-9082-955525eda5a5', - }, - - noSuchChannel: { - message: 'No such channel.', - code: 'NO_SUCH_CHANNEL', - id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb', - }, - - youHaveBeenBlocked: { - message: 'You have been blocked by this user.', - code: 'YOU_HAVE_BEEN_BLOCKED', - id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3', - }, - - accountLocked: { - message: 'You migrated. Your account is now locked.', - code: 'ACCOUNT_LOCKED', - id: 'd390d7e1-8a5e-46ed-b625-06271cafd3d3', - }, - - needsEditId: { - message: 'You need to specify `editId`.', - code: 'NEEDS_EDIT_ID', - id: 'd697edc8-8c73-4de8-bded-35fd198b79e5', - }, - - noSuchNote: { - message: 'No such note.', - code: 'NO_SUCH_NOTE', - id: 'eef6c173-3010-4a23-8674-7c4fcaeba719', - }, - - youAreNotTheAuthor: { - message: 'You are not the author of this note.', - code: 'YOU_ARE_NOT_THE_AUTHOR', - id: 'c6e61685-411d-43d0-b90a-a448d2539001', - }, - - cannotPrivateRenote: { - message: 'You can not perform a private renote.', - code: 'CANNOT_PRIVATE_RENOTE', - id: '19a50f1c-84fa-4e33-81d3-17834ccc0ad8', - }, - - notLocalUser: { - message: 'You are not a local user.', - code: 'NOT_LOCAL_USER', - id: 'b907f407-2aa0-4283-800b-a2c56290b822', - }, - - cannotRenoteOutsideOfChannel: { - message: 'Cannot renote outside of channel.', - code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL', - id: '33510210-8452-094c-6227-4a6c05d99f00', - }, - - cannotQuoteaQuoteOfCurrentPost: { - message: 'Cannot quote a quote of edited note.', - code: 'CANNOT_QUOTE_A_QUOTE_OF_EDITED_NOTE', - id: '33510210-8452-094c-6227-4a6c05d99f01', - }, - - cannotQuoteCurrentPost: { - message: 'Cannot quote the current note.', - code: 'CANNOT_QUOTE_THE_CURRENT_NOTE', - id: '33510210-8452-094c-6227-4a6c05d99f02', - }, - - maxLength: { - message: 'You tried posting a note which is too long.', - code: 'MAX_LENGTH', - id: '3ac74a84-8fd5-4bb0-870f-01804f82ce16', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - editId: { type: 'string', format: 'misskey:id' }, - visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' }, - visibleUserIds: { - type: 'array', - uniqueItems: true, - items: { - type: 'string', - format: 'misskey:id', - }, - }, - cw: { type: 'string', nullable: true, minLength: 1, maxLength: 500 }, - localOnly: { type: 'boolean', default: false }, - reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, - noExtractMentions: { type: 'boolean', default: false }, - noExtractHashtags: { type: 'boolean', default: false }, - noExtractEmojis: { type: 'boolean', default: false }, - replyId: { type: 'string', format: 'misskey:id', nullable: true }, - renoteId: { type: 'string', format: 'misskey:id', nullable: true }, - channelId: { type: 'string', format: 'misskey:id', nullable: true }, - text: { - type: 'string', - minLength: 1, - nullable: true, - }, - fileIds: { - type: 'array', - uniqueItems: true, - minItems: 1, - maxItems: 16, - items: { type: 'string', format: 'misskey:id' }, - }, - mediaIds: { - type: 'array', - uniqueItems: true, - minItems: 1, - maxItems: 16, - items: { type: 'string', format: 'misskey:id' }, - }, - poll: { - type: 'object', - nullable: true, - properties: { - choices: { - type: 'array', - uniqueItems: true, - minItems: 2, - maxItems: 10, - items: { type: 'string', minLength: 1, maxLength: 50 }, - }, - multiple: { type: 'boolean' }, - expiresAt: { type: 'integer', nullable: true }, - expiredAfter: { type: 'integer', nullable: true, minimum: 1 }, - }, - required: ['choices'], - }, - }, - anyOf: [ - { - // (re)note with text, files and poll are optional - properties: { - text: { - type: 'string', - minLength: 1, - nullable: false, - }, - }, - required: ['text'], - }, - { - // (re)note with files, text and poll are optional - required: ['fileIds'], - }, - { - // (re)note with files, text and poll are optional - required: ['mediaIds'], - }, - { - // (re)note with poll, text and files are optional - properties: { - poll: { type: 'object', nullable: false }, - }, - required: ['poll'], - }, - { - // pure renote - required: ['renoteId'], - }, - ], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - - private noteEntityService: NoteEntityService, - private noteEditService: NoteEditService, - ) { - super(meta, paramDef, async (ps, me) => { - if (ps.text && (ps.text.length > this.config.maxNoteLength)) { - throw new ApiError(meta.errors.maxLength); - } - let visibleUsers: MiUser[] = []; - if (ps.visibleUserIds) { - visibleUsers = await this.usersRepository.findBy({ - id: In(ps.visibleUserIds), - }); - } - - let files: MiDriveFile[] = []; - const fileIds = ps.fileIds ?? ps.mediaIds ?? null; - if (fileIds != null) { - files = await this.driveFilesRepository.createQueryBuilder('file') - .where('file.userId = :userId AND file.id IN (:...fileIds)', { - userId: me.id, - fileIds, - }) - .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') - .setParameters({ fileIds }) - .getMany(); - - if (files.length !== fileIds.length) { - throw new ApiError(meta.errors.noSuchNote); - } - } - - let renote: MiNote | null = null; - - if (ps.renoteId === ps.editId) { - throw new ApiError(meta.errors.cannotQuoteCurrentPost); - } - - if (ps.renoteId != null) { - // Fetch renote to note - renote = await this.notesRepository.findOneBy({ id: ps.renoteId }); - - if (renote == null) { - throw new ApiError(meta.errors.noSuchRenoteTarget); - } else if (renote.renoteId && !renote.text && !renote.fileIds && !renote.hasPoll) { - throw new ApiError(meta.errors.cannotReRenote); - } - - if (renote.renoteId === ps.editId) { - throw new ApiError(meta.errors.cannotQuoteaQuoteOfCurrentPost); - } - - // Check blocking - if (renote.userId !== me.id) { - const blockExist = await this.blockingsRepository.exist({ - where: { - blockerId: renote.userId, - blockeeId: me.id, - }, - }); - if (blockExist) { - throw new ApiError(meta.errors.youHaveBeenBlocked); - } - } - - if (renote.channelId && renote.channelId !== ps.channelId) { - // チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック - // リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する - const renoteChannel = await this.channelsRepository.findOneById(renote.channelId); - if (renoteChannel == null) { - // リノートしたいノートが書き込まれているチャンネルが無い - throw new ApiError(meta.errors.noSuchChannel); - } else if (!renoteChannel.allowRenoteToExternal) { - // リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合 - throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel); - } - } - } - - let reply: MiNote | null = null; - if (ps.replyId != null) { - // Fetch reply - reply = await this.notesRepository.findOneBy({ id: ps.replyId }); - - if (reply == null) { - throw new ApiError(meta.errors.noSuchReplyTarget); - } else if (reply.renoteId && !reply.text && !reply.fileIds && !reply.hasPoll) { - throw new ApiError(meta.errors.cannotReplyToPureRenote); - } - - // Check blocking - if (reply.userId !== me.id) { - const blockExist = await this.blockingsRepository.exist({ - where: { - blockerId: reply.userId, - blockeeId: me.id, - }, - }); - if (blockExist) { - throw new ApiError(meta.errors.youHaveBeenBlocked); - } - } - } - - if (ps.poll) { - if (typeof ps.poll.expiresAt === 'number') { - if (ps.poll.expiresAt < Date.now()) { - throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); - } - } else if (typeof ps.poll.expiredAfter === 'number') { - ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter; - } - } - - let channel: MiChannel | null = null; - if (ps.channelId != null) { - channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false }); - - if (channel == null) { - throw new ApiError(meta.errors.noSuchChannel); - } - } - - // 投稿を作成 - const note = await this.noteEditService.edit(me, ps.editId!, { - files: files, - poll: ps.poll ? { - choices: ps.poll.choices, - multiple: ps.poll.multiple ?? false, - expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, - } : undefined, - text: ps.text ?? undefined, - reply, - renote, - cw: ps.cw, - localOnly: ps.localOnly, - reactionAcceptance: ps.reactionAcceptance, - visibility: ps.visibility, - visibleUsers, - channel, - apMentions: ps.noExtractMentions ? [] : undefined, - apHashtags: ps.noExtractHashtags ? [] : undefined, - apEmojis: ps.noExtractEmojis ? [] : undefined, - }); - - return { - createdNote: await this.noteEntityService.pack(note, me), - }; - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts index de27be971b..097371a425 100644 --- a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts @@ -1,31 +1,16 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import ms from 'ms'; -import type { NoteFavoritesRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { DI } from '@/di-symbols.js'; -import { AchievementService } from '@/core/AchievementService.js'; +import { NoteFavorites } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { getNote } from '../../../common/getters.js'; export const meta = { tags: ['notes', 'favorites'], - requireCredential: 'always', - prohibitMoved: true, + requireCredential: true, kind: 'write:favorites', - limit: { - duration: ms('1hour'), - max: 20, - }, - errors: { noSuchNote: { message: 'No such note.', @@ -49,45 +34,29 @@ export const paramDef = { required: ['noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.noteFavoritesRepository) - private noteFavoritesRepository: NoteFavoritesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Get favoritee + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); - private idService: IdService, - private getterService: GetterService, - private achievementService: AchievementService, - ) { - super(meta, paramDef, async (ps, me) => { - // Get favoritee - const note = await this.getterService.getNote(ps.noteId).catch(err => { - if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw err; - }); + // if already favorited + const exist = await NoteFavorites.findOneBy({ + noteId: note.id, + userId: user.id, + }); - // if already favorited - const exist = await this.noteFavoritesRepository.exist({ - where: { - noteId: note.id, - userId: me.id, - }, - }); - - if (exist) { - throw new ApiError(meta.errors.alreadyFavorited); - } - - // Create favorite - await this.noteFavoritesRepository.insert({ - id: this.idService.gen(), - noteId: note.id, - userId: me.id, - }); - - if (note.userHost == null && note.userId !== me.id) { - this.achievementService.create(note.userId, 'myNoteFavorited1'); - } - }); + if (exist != null) { + throw new ApiError(meta.errors.alreadyFavorited); } -} + + // Create favorite + await NoteFavorites.insert({ + id: genId(), + createdAt: new Date(), + noteId: note.id, + userId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts b/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts index 2834dd2be8..82ef4fa197 100644 --- a/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts +++ b/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts @@ -1,19 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { DI } from '@/di-symbols.js'; -import type { NoteFavoritesRepository } from '@/models/_.js'; +import { NoteFavorites } from '@/models/index.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { getNote } from '../../../common/getters.js'; export const meta = { tags: ['notes', 'favorites'], - requireCredential: 'always', + requireCredential: true, kind: 'write:favorites', @@ -40,33 +33,24 @@ export const paramDef = { required: ['noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.noteFavoritesRepository) - private noteFavoritesRepository: NoteFavoritesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Get favoritee + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); - private getterService: GetterService, - ) { - super(meta, paramDef, async (ps, me) => { - // Get favoritee - const note = await this.getterService.getNote(ps.noteId).catch(err => { - if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw err; - }); + // if already favorited + const exist = await NoteFavorites.findOneBy({ + noteId: note.id, + userId: user.id, + }); - // if already favorited - const exist = await this.noteFavoritesRepository.findOneBy({ - noteId: note.id, - userId: me.id, - }); - - if (exist == null) { - throw new ApiError(meta.errors.notFavorited); - } - - // Delete favorite - await this.noteFavoritesRepository.delete(exist.id); - }); + if (exist == null) { + throw new ApiError(meta.errors.notFavorited); } -} + + // Delete favorite + await NoteFavorites.delete(exist.id); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index 1c84bf769c..dd9cc581aa 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -1,23 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { FeaturedService } from '@/core/FeaturedService.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; -import { CacheService } from '@/core/CacheService.js'; +import { Notes } from '@/models/index.js'; +import define from '../../define.js'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; +import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; export const meta = { tags: ['notes'], - requireCredential: 'conditional', - allowGet: true, - cacheSec: 3600, + requireCredential: false, res: { type: 'array', @@ -34,76 +23,45 @@ export const paramDef = { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - untilId: { type: 'string', format: 'misskey:id' }, - channelId: { type: 'string', nullable: true, format: 'misskey:id' }, + offset: { type: 'integer', default: 0 }, }, required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - private globalNotesRankingCache: string[] = []; - private globalNotesRankingCacheLastFetchedAt = 0; +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const max = 30; + const day = 1000 * 60 * 60 * 24 * 3; // 3日前まで - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, + const query = Notes.createQueryBuilder('note') + .addSelect('note.score') + .where('note.userHost IS NULL') + .andWhere('note.score > 0') + .andWhere('note.createdAt > :date', { date: new Date(Date.now() - day) }) + .andWhere('note.visibility = \'public\'') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - private cacheService: CacheService, - private noteEntityService: NoteEntityService, - private featuredService: FeaturedService, - ) { - super(meta, paramDef, async (ps, me) => { - let noteIds: string[]; - if (ps.channelId) { - noteIds = await this.featuredService.getInChannelNotesRanking(ps.channelId, 50); - } else { - if (this.globalNotesRankingCacheLastFetchedAt !== 0 && (Date.now() - this.globalNotesRankingCacheLastFetchedAt < 1000 * 60 * 30)) { - noteIds = this.globalNotesRankingCache; - } else { - noteIds = await this.featuredService.getGlobalNotesRanking(100); - this.globalNotesRankingCache = noteIds; - this.globalNotesRankingCacheLastFetchedAt = Date.now(); - } - } + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); - noteIds.sort((a, b) => a > b ? -1 : 1); - if (ps.untilId) { - noteIds = noteIds.filter(id => id < ps.untilId!); - } - noteIds = noteIds.slice(0, ps.limit); + let notes = await query + .orderBy('note.score', 'DESC') + .take(max) + .getMany(); - if (noteIds.length === 0) { - return []; - } + notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - const [ - userIdsWhoMeMuting, - userIdsWhoBlockingMe, - ] = me ? await Promise.all([ - this.cacheService.userMutingsCache.fetch(me.id), - this.cacheService.userBlockedCache.fetch(me.id), - ]) : [new Set(), new Set()]; + notes = notes.slice(ps.offset, ps.offset + ps.limit); - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); - - const notes = (await query.getMany()).filter(note => { - if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; - - return true; - }); - - notes.sort((a, b) => a.id > b.id ? -1 : 1); - - return await this.noteEntityService.packMany(notes, me); - }); - } -} + return await Notes.packMany(notes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 1631cc3516..925318f544 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -1,25 +1,17 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Brackets } from 'typeorm'; -import type { NotesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import ActiveUsersChart from '@/core/chart/charts/active-users.js'; -import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Notes } from '@/models/index.js'; +import { activeUsersChart } from '@/services/chart/index.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; -import { CacheService } from '@/core/CacheService.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; +import { generateRepliesQuery } from '../../common/generate-replies-query.js'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; +import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; export const meta = { tags: ['notes'], - requireCredential: 'conditional', - res: { type: 'array', optional: false, nullable: false, @@ -42,9 +34,11 @@ export const meta = { export const paramDef = { type: 'object', properties: { - withFiles: { type: 'boolean', default: false }, - withBots: { type: 'boolean', default: true }, - withRenotes: { type: 'boolean', default: true }, + withFiles: { + type: 'boolean', + default: false, + description: 'Only show notes that have attached files.', + }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, @@ -54,78 +48,51 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - private noteEntityService: NoteEntityService, - private queryService: QueryService, - private roleService: RoleService, - private activeUsersChart: ActiveUsersChart, - private cacheService: CacheService, - ) { - super(meta, paramDef, async (ps, me) => { - const policies = await this.roleService.getUserPolicies(me ? me.id : null); - if (!policies.gtlAvailable) { - throw new ApiError(meta.errors.gtlDisabled); - } - - const [ - followings, - ] = me ? await Promise.all([ - this.cacheService.userFollowingsCache.fetch(me.id), - ]) : [undefined]; - - //#region Construct query - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.visibility = \'public\'') - .andWhere('note.channelId IS NULL') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - } - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - - if (!ps.withBots) query.andWhere('user.isBot = FALSE'); - - if (ps.withRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.where('note.renoteId IS NULL'); - qb.orWhere(new Brackets(qb => { - qb.where('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - })); - })); - } - //#endregion - - let timeline = await query.limit(ps.limit).getMany(); - - timeline = timeline.filter(note => { - if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false; - return true; - }); - - process.nextTick(() => { - if (me) { - this.activeUsersChart.read(me); - } - }); - - return await this.noteEntityService.packMany(timeline, me); - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const m = await fetchMeta(); + if (m.disableGlobalTimeline) { + if (user == null || (!user.isAdmin && !user.isModerator)) { + throw new ApiError(meta.errors.gtlDisabled); + } } -} + + //#region Construct query + const query = makePaginationQuery(Notes.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.visibility = \'public\'') + .andWhere('note.channelId IS NULL') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + + generateRepliesQuery(query, user); + if (user) { + generateMutedUserQuery(query, user); + generateMutedNoteQuery(query, user); + generateBlockedUserQuery(query, user); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.take(ps.limit).getMany(); + + process.nextTick(() => { + if (user) { + activeUsersChart.read(user); + } + }); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index e5a8ace429..2dc98c4c9f 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -1,31 +1,21 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { Brackets } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, ChannelFollowingsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import ActiveUsersChart from '@/core/chart/charts/active-users.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; -import { IdService } from '@/core/IdService.js'; -import { CacheService } from '@/core/CacheService.js'; -import { FanoutTimelineName } from '@/core/FanoutTimelineService.js'; -import { QueryService } from '@/core/QueryService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { MiLocalUser } from '@/models/User.js'; -import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Followings, Notes } from '@/models/index.js'; +import { activeUsersChart } from '@/services/chart/index.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; +import { generateRepliesQuery } from '../../common/generate-replies-query.js'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; +import { generateChannelQuery } from '../../common/generate-channel-query.js'; +import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; export const meta = { tags: ['notes'], - requireCredential: 'always', - kind: 'read:account', + requireCredential: true, res: { type: 'array', @@ -43,12 +33,6 @@ export const meta = { code: 'STL_DISABLED', id: '620763f4-f621-4533-ab33-0577a1a3c342', }, - - bothWithRepliesAndWithFiles: { - message: 'Specifying both withReplies and withFiles is not supported', - code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', - id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f', - }, }, } as const; @@ -60,222 +44,96 @@ export const paramDef = { untilId: { type: 'string', format: 'misskey:id' }, sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, - allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default includeMyRenotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true }, - withFiles: { type: 'boolean', default: false }, - withRenotes: { type: 'boolean', default: true }, - withReplies: { type: 'boolean', default: false }, - withBots: { type: 'boolean', default: true }, + withFiles: { + type: 'boolean', + default: false, + description: 'Only show notes that have attached files.', + }, }, required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - - private noteEntityService: NoteEntityService, - private roleService: RoleService, - private activeUsersChart: ActiveUsersChart, - private idService: IdService, - private cacheService: CacheService, - private queryService: QueryService, - private userFollowingService: UserFollowingService, - private metaService: MetaService, - private fanoutTimelineEndpointService: FanoutTimelineEndpointService, - ) { - super(meta, paramDef, async (ps, me) => { - const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); - const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); - - const policies = await this.roleService.getUserPolicies(me.id); - if (!policies.ltlAvailable) { - throw new ApiError(meta.errors.stlDisabled); - } - - if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); - - const serverSettings = await this.metaService.fetch(); - - if (!serverSettings.enableFanoutTimeline) { - const timeline = await this.getFromDb({ - untilId, - sinceId, - limit: ps.limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, - withFiles: ps.withFiles, - withReplies: ps.withReplies, - withBots: ps.withBots, - }, me); - - process.nextTick(() => { - this.activeUsersChart.read(me); - }); - - return await this.noteEntityService.packMany(timeline, me); - } - - let timelineConfig: FanoutTimelineName[]; - - if (ps.withFiles) { - timelineConfig = [ - `homeTimelineWithFiles:${me.id}`, - 'localTimelineWithFiles', - ]; - } else if (ps.withReplies) { - timelineConfig = [ - `homeTimeline:${me.id}`, - 'localTimeline', - 'localTimelineWithReplies', - ]; - } else { - timelineConfig = [ - `homeTimeline:${me.id}`, - 'localTimeline', - ]; - } - - const redisTimeline = await this.fanoutTimelineEndpointService.timeline({ - untilId, - sinceId, - limit: ps.limit, - allowPartial: ps.allowPartial, - me, - redisTimelines: timelineConfig, - useDbFallback: serverSettings.enableFanoutTimelineDbFallback, - alwaysIncludeMyNotes: true, - excludePureRenotes: !ps.withRenotes, - excludeBots: !ps.withBots, - dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ - untilId, - sinceId, - limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, - withFiles: ps.withFiles, - withReplies: ps.withReplies, - withBots: ps.withBots, - }, me), - }); - - process.nextTick(() => { - this.activeUsersChart.read(me); - }); - - return redisTimeline; - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const m = await fetchMeta(); + if (m.disableLocalTimeline && (!user.isAdmin && !user.isModerator)) { + throw new ApiError(meta.errors.stlDisabled); } - private async getFromDb(ps: { - untilId: string | null, - sinceId: string | null, - limit: number, - includeMyRenotes: boolean, - includeRenotedMyNotes: boolean, - includeLocalRenotes: boolean, - withFiles: boolean, - withReplies: boolean, - withBots: boolean, - }, me: MiLocalUser) { - const followees = await this.userFollowingService.getFollowees(me.id); - const followingChannels = await this.channelFollowingsRepository.find({ - where: { - followerId: me.id, - }, - }); + //#region Construct query + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: user.id }); - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere(new Brackets(qb => { - if (followees.length > 0) { - const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; - qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); - qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); - } else { - qb.where('note.userId = :meId', { meId: me.id }); - qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); - } - })) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + const query = makePaginationQuery(Notes.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere(new Brackets(qb => { + qb.where(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: user.id }) + .orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') + .setParameters(followingQuery.getParameters()); - if (followingChannels.length > 0) { - const followingChannelIds = followingChannels.map(x => x.followeeId); + generateChannelQuery(query, user); + generateRepliesQuery(query, user); + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateMutedNoteQuery(query, user); + generateBlockedUserQuery(query, user); - query.andWhere(new Brackets(qb => { - qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); - qb.orWhere('note.channelId IS NULL'); - })); - } else { - query.andWhere('note.channelId IS NULL'); - } - - if (!ps.withReplies) { - query.andWhere(new Brackets(qb => { - qb - .where('note.replyId IS NULL') // 返信ではない - .orWhere(new Brackets(qb => { - qb // 返信だけど投稿者自身への返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = note.userId'); - })); - })); - } - - this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeRenotedMyNotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeLocalRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserHost IS NOT NULL'); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - - if (!ps.withBots) query.andWhere('user.isBot = FALSE'); - //#endregion - - return await query.limit(ps.limit).getMany(); + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); } -} + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.take(ps.limit).getMany(); + + process.nextTick(() => { + activeUsersChart.read(user); + }); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/like.ts b/packages/backend/src/server/api/endpoints/notes/like.ts deleted file mode 100644 index a660cf0f0f..0000000000 --- a/packages/backend/src/server/api/endpoints/notes/like.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { ReactionService } from '@/core/ReactionService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['notes'], - - requireCredential: 'always', - - prohibitMoved: true, - - kind: 'write:reactions', - - errors: { - noSuchNote: { - message: 'No such note.', - code: 'NO_SUCH_NOTE', - id: '033d0620-5bfe-4027-965d-980b0c85a3ea', - }, - - youHaveBeenBlocked: { - message: 'You cannot like this note because you have been blocked by this user.', - code: 'YOU_HAVE_BEEN_BLOCKED', - id: '20ef5475-9f38-4e4c-bd33-de6d979498ec', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - noteId: { type: 'string', format: 'misskey:id' }, - override: { type: 'string', nullable: true }, - }, - required: ['noteId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private getterService: GetterService, - private reactionService: ReactionService, - private metaService: MetaService, - ) { - super(meta, paramDef, async (ps, me) => { - const instance = await this.metaService.fetch(); - const like = ps.override ?? instance.defaultLike; - const note = await this.getterService.getNote(ps.noteId).catch(err => { - if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw err; - }); - await this.reactionService.create(me, note, like).catch(async err => { - if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') { - await this.reactionService.delete(me, note); - return; - } - if (err.id === 'e70412a4-7197-4726-8e74-f3e0deb92aa7') throw new ApiError(meta.errors.youHaveBeenBlocked); - throw err; - }); - return; - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index a7bfed46d3..aac2a3749c 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -1,29 +1,20 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { Brackets } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import ActiveUsersChart from '@/core/chart/charts/active-users.js'; -import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; -import { IdService } from '@/core/IdService.js'; -import { CacheService } from '@/core/CacheService.js'; -import { QueryService } from '@/core/QueryService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { MiLocalUser } from '@/models/User.js'; -import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Notes, Users } from '@/models/index.js'; +import { activeUsersChart } from '@/services/chart/index.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; +import { generateRepliesQuery } from '../../common/generate-replies-query.js'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; +import { generateChannelQuery } from '../../common/generate-channel-query.js'; +import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; export const meta = { tags: ['notes'], - requireCredential: 'conditional', - res: { type: 'array', optional: false, nullable: false, @@ -40,154 +31,89 @@ export const meta = { code: 'LTL_DISABLED', id: '45a6eb02-7695-4393-b023-dd3be9aaaefd', }, - - bothWithRepliesAndWithFiles: { - message: 'Specifying both withReplies and withFiles is not supported', - code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', - id: 'dd9c8400-1cb5-4eef-8a31-200c5f933793', - }, }, } as const; export const paramDef = { type: 'object', properties: { - withFiles: { type: 'boolean', default: false }, - withRenotes: { type: 'boolean', default: true }, - withReplies: { type: 'boolean', default: false }, - withBots: { type: 'boolean', default: true }, + withFiles: { + type: 'boolean', + default: false, + description: 'Only show notes that have attached files.', + }, + fileType: { type: 'array', items: { + type: 'string', + } }, + excludeNsfw: { type: 'boolean', default: false }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, - allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, }, required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - private noteEntityService: NoteEntityService, - private roleService: RoleService, - private activeUsersChart: ActiveUsersChart, - private idService: IdService, - private cacheService: CacheService, - private fanoutTimelineEndpointService: FanoutTimelineEndpointService, - private queryService: QueryService, - private metaService: MetaService, - ) { - super(meta, paramDef, async (ps, me) => { - const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); - const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); - - const policies = await this.roleService.getUserPolicies(me ? me.id : null); - if (!policies.ltlAvailable) { - throw new ApiError(meta.errors.ltlDisabled); - } - - if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); - - const serverSettings = await this.metaService.fetch(); - - if (!serverSettings.enableFanoutTimeline) { - const timeline = await this.getFromDb({ - untilId, - sinceId, - limit: ps.limit, - withFiles: ps.withFiles, - withReplies: ps.withReplies, - withBots: ps.withBots, - }, me); - - process.nextTick(() => { - if (me) { - this.activeUsersChart.read(me); - } - }); - - return await this.noteEntityService.packMany(timeline, me); - } - - const timeline = await this.fanoutTimelineEndpointService.timeline({ - untilId, - sinceId, - limit: ps.limit, - allowPartial: ps.allowPartial, - me, - useDbFallback: serverSettings.enableFanoutTimelineDbFallback, - redisTimelines: - ps.withFiles ? ['localTimelineWithFiles'] - : ps.withReplies ? ['localTimeline', 'localTimelineWithReplies'] - : me ? ['localTimeline', `localTimelineWithReplyTo:${me.id}`] - : ['localTimeline'], - alwaysIncludeMyNotes: true, - excludePureRenotes: !ps.withRenotes, - excludeBots: !ps.withBots, - dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ - untilId, - sinceId, - limit, - withFiles: ps.withFiles, - withReplies: ps.withReplies, - withBots: ps.withBots, - }, me), - }); - - process.nextTick(() => { - if (me) { - this.activeUsersChart.read(me); - } - }); - - return timeline; - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const m = await fetchMeta(); + if (m.disableLocalTimeline) { + if (user == null || (!user.isAdmin && !user.isModerator)) { + throw new ApiError(meta.errors.ltlDisabled); + } } - private async getFromDb(ps: { - sinceId: string | null, - untilId: string | null, - limit: number, - withFiles: boolean, - withReplies: boolean, - withBots: boolean, - }, me: MiLocalUser | null) { - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), - ps.sinceId, ps.untilId) - .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL) AND (note.channelId IS NULL)') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + //#region Construct query + const query = makePaginationQuery(Notes.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); - if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + generateChannelQuery(query, user); + generateRepliesQuery(query, user); + generateVisibilityQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateMutedNoteQuery(query, user); + if (user) generateBlockedUserQuery(query, user); - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - - if (!ps.withReplies) { - query.andWhere(new Brackets(qb => { - qb - .where('note.replyId IS NULL') // 返信ではない - .orWhere(new Brackets(qb => { - qb // 返信だけど投稿者自身への返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = note.userId'); - })); - })); - } - - if (!ps.withBots) query.andWhere('user.isBot = FALSE'); - - return await query.limit(ps.limit).getMany(); + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); } -} + + if (ps.fileType != null) { + query.andWhere('note.fileIds != \'{}\''); + query.andWhere(new Brackets(qb => { + for (const type of ps.fileType!) { + const i = ps.fileType!.indexOf(type); + qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type }); + } + })); + + if (ps.excludeNsfw) { + query.andWhere('note.cw IS NULL'); + query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); + } + } + //#endregion + + const timeline = await query.take(ps.limit).getMany(); + + process.nextTick(() => { + if (user) { + activeUsersChart.read(user); + } + }); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts index bb8e19fdfe..9b41544523 100644 --- a/packages/backend/src/server/api/endpoints/notes/mentions.ts +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -1,22 +1,17 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { Brackets } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, FollowingsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; -import { DI } from '@/di-symbols.js'; +import read from '@/services/note/read.js'; +import { Notes, Followings } from '@/models/index.js'; +import define from '../../define.js'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; +import { generateMutedNoteThreadQuery } from '../../common/generate-muted-note-thread-query.js'; export const meta = { tags: ['notes'], - requireCredential: 'always', - kind: 'read:account', + requireCredential: true, res: { type: 'array', @@ -41,57 +36,46 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: user.id }); - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(new Brackets(qb => { qb + .where(`'{"${user.id}"}' <@ note.mentions`) + .orWhere(`'{"${user.id}"}' <@ note.visibleUserIds`); + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - private noteEntityService: NoteEntityService, - private queryService: QueryService, - private noteReadService: NoteReadService, - ) { - super(meta, paramDef, async (ps, me) => { - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateMutedNoteThreadQuery(query, user); + generateBlockedUserQuery(query, user); - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere(new Brackets(qb => { - qb - .where(`'{"${me.id}"}' <@ note.mentions`) - .orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`); - })) - // Avoid scanning primary key index - .orderBy('CONCAT(note.id)', 'DESC') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateMutedNoteThreadQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - - if (ps.visibility) { - query.andWhere('note.visibility = :visibility', { visibility: ps.visibility }); - } - - if (ps.following) { - query.andWhere(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id }); - query.setParameters(followingQuery.getParameters()); - } - - const mentions = await query.limit(ps.limit).getMany(); - - this.noteReadService.read(me.id, mentions); - - return await this.noteEntityService.packMany(mentions, me); - }); + if (ps.visibility) { + query.andWhere('note.visibility = :visibility', { visibility: ps.visibility }); } -} + + if (ps.following) { + query.andWhere(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: user.id }); + query.setParameters(followingQuery.getParameters()); + } + + const mentions = await query.take(ps.limit).getMany(); + + read(user.id, mentions); + + return await Notes.packMany(mentions, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts index aa3c1fc41f..5a04d68f3e 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { Brackets, In } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, MutingsRepository, PollsRepository, PollVotesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { DI } from '@/di-symbols.js'; +import { Polls, Mutings, Notes, PollVotes } from '@/models/index.js'; +import define from '../../../define.js'; export const meta = { tags: ['notes'], - requireCredential: 'always', - kind: 'read:account', + requireCredential: true, res: { type: 'array', @@ -36,76 +27,57 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = Polls.createQueryBuilder('poll') + .where('poll.userHost IS NULL') + .andWhere('poll.userId != :meId', { meId: user.id }) + .andWhere('poll.noteVisibility = \'public\'') + .andWhere(new Brackets(qb => { qb + .where('poll.expiresAt IS NULL') + .orWhere('poll.expiresAt > :now', { now: new Date() }); + })); - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, + //#region exclude arleady voted polls + const votedQuery = PollVotes.createQueryBuilder('vote') + .select('vote.noteId') + .where('vote.userId = :meId', { meId: user.id }); - @Inject(DI.pollVotesRepository) - private pollVotesRepository: PollVotesRepository, + query + .andWhere(`poll.noteId NOT IN (${ votedQuery.getQuery() })`); - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, + query.setParameters(votedQuery.getParameters()); + //#endregion - private noteEntityService: NoteEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.pollsRepository.createQueryBuilder('poll') - .where('poll.userHost IS NULL') - .andWhere('poll.userId != :meId', { meId: me.id }) - .andWhere('poll.noteVisibility = \'public\'') - .andWhere(new Brackets(qb => { - qb - .where('poll.expiresAt IS NULL') - .orWhere('poll.expiresAt > :now', { now: new Date() }); - })); + //#region mute + const mutingQuery = Mutings.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: user.id }); - //#region exclude arleady voted polls - const votedQuery = this.pollVotesRepository.createQueryBuilder('vote') - .select('vote.noteId') - .where('vote.userId = :meId', { meId: me.id }); + query + .andWhere(`poll.userId NOT IN (${ mutingQuery.getQuery() })`); - query - .andWhere(`poll.noteId NOT IN (${ votedQuery.getQuery() })`); + query.setParameters(mutingQuery.getParameters()); + //#endregion - query.setParameters(votedQuery.getParameters()); - //#endregion + const polls = await query + .orderBy('poll.noteId', 'DESC') + .take(ps.limit) + .skip(ps.offset) + .getMany(); - //#region mute - const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') - .select('muting.muteeId') - .where('muting.muterId = :muterId', { muterId: me.id }); + if (polls.length === 0) return []; - query - .andWhere(`poll.userId NOT IN (${ mutingQuery.getQuery() })`); + const notes = await Notes.find({ + where: { + id: In(polls.map(poll => poll.noteId)), + }, + order: { + createdAt: 'DESC', + }, + }); - query.setParameters(mutingQuery.getParameters()); - //#endregion - - const polls = await query - .orderBy('poll.noteId', 'DESC') - .limit(ps.limit) - .offset(ps.offset) - .getMany(); - - if (polls.length === 0) return []; - - const notes = await this.notesRepository.find({ - where: { - id: In(polls.map(poll => poll.noteId)), - }, - order: { - id: 'DESC', - }, - }); - - return await this.noteEntityService.packMany(notes, me, { - detail: true, - }); - }); - } -} + return await Notes.packMany(notes, user, { + detail: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts index 18141a1053..45a832cbd2 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts @@ -1,28 +1,21 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, PollsRepository, PollVotesRepository } from '@/models/_.js'; -import type { MiRemoteUser } from '@/models/User.js'; -import { IdService } from '@/core/IdService.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { QueueService } from '@/core/QueueService.js'; -import { PollService } from '@/core/PollService.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; -import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { Not } from 'typeorm'; +import { publishNoteStream } from '@/services/stream.js'; +import { createNotification } from '@/services/create-notification.js'; +import { deliver } from '@/queue/index.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderVote from '@/remote/activitypub/renderer/vote.js'; +import { deliverQuestionUpdate } from '@/services/note/polls/update.js'; +import { PollVotes, NoteWatchings, Users, Polls, Blockings } from '@/models/index.js'; +import { IRemoteUser } from '@/models/entities/user.js'; +import { genId } from '@/misc/gen-id.js'; +import { getNote } from '../../../common/getters.js'; import { ApiError } from '../../../error.js'; +import define from '../../../define.js'; export const meta = { tags: ['notes'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:votes', @@ -74,101 +67,103 @@ export const paramDef = { required: ['noteId', 'choice'], } as const; -// TODO: ロジックをサービスに切り出す +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const createdAt = new Date(); -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, + // Get votee + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - @Inject(DI.pollVotesRepository) - private pollVotesRepository: PollVotesRepository, - - private idService: IdService, - private getterService: GetterService, - private queueService: QueueService, - private pollService: PollService, - private apRendererService: ApRendererService, - private globalEventService: GlobalEventService, - private userBlockingService: UserBlockingService, - ) { - super(meta, paramDef, async (ps, me) => { - const createdAt = new Date(); - - // Get votee - const note = await this.getterService.getNote(ps.noteId).catch(err => { - if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw err; - }); - - if (!note.hasPoll) { - throw new ApiError(meta.errors.noPoll); - } - - // Check blocking - if (note.userId !== me.id) { - const blocked = await this.userBlockingService.checkBlocked(note.userId, me.id); - if (blocked) { - throw new ApiError(meta.errors.youHaveBeenBlocked); - } - } - - const poll = await this.pollsRepository.findOneByOrFail({ noteId: note.id }); - - if (poll.expiresAt && poll.expiresAt < createdAt) { - throw new ApiError(meta.errors.alreadyExpired); - } - - if (poll.choices[ps.choice] == null) { - throw new ApiError(meta.errors.invalidChoice); - } - - // if already voted - const exist = await this.pollVotesRepository.findBy({ - noteId: note.id, - userId: me.id, - }); - - if (exist.length) { - if (poll.multiple) { - if (exist.some(x => x.choice === ps.choice)) { - throw new ApiError(meta.errors.alreadyVoted); - } - } else { - throw new ApiError(meta.errors.alreadyVoted); - } - } - - // Create vote - const vote = await this.pollVotesRepository.insert({ - id: this.idService.gen(createdAt.getTime()), - noteId: note.id, - userId: me.id, - choice: ps.choice, - }).then(x => this.pollVotesRepository.findOneByOrFail(x.identifiers[0])); - - // Increment votes count - const index = ps.choice + 1; // In SQL, array index is 1 based - await this.pollsRepository.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`); - - this.globalEventService.publishNoteStream(note.id, 'pollVoted', { - choice: ps.choice, - userId: me.id, - }); - - // リモート投票の場合リプライ送信 - if (note.userHost != null) { - const pollOwner = await this.usersRepository.findOneByOrFail({ id: note.userId }) as MiRemoteUser; - - this.queueService.deliver(me, this.apRendererService.addContext(await this.apRendererService.renderVote(me, vote, note, poll, pollOwner)), pollOwner.inbox, false); - } - - // リモートフォロワーにUpdate配信 - this.pollService.deliverQuestionUpdate(note.id); - }); + if (!note.hasPoll) { + throw new ApiError(meta.errors.noPoll); } -} + + // Check blocking + if (note.userId !== user.id) { + const block = await Blockings.findOneBy({ + blockerId: note.userId, + blockeeId: user.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + + const poll = await Polls.findOneByOrFail({ noteId: note.id }); + + if (poll.expiresAt && poll.expiresAt < createdAt) { + throw new ApiError(meta.errors.alreadyExpired); + } + + if (poll.choices[ps.choice] == null) { + throw new ApiError(meta.errors.invalidChoice); + } + + // if already voted + const exist = await PollVotes.findBy({ + noteId: note.id, + userId: user.id, + }); + + if (exist.length) { + if (poll.multiple) { + if (exist.some(x => x.choice === ps.choice)) { + throw new ApiError(meta.errors.alreadyVoted); + } + } else { + throw new ApiError(meta.errors.alreadyVoted); + } + } + + // Create vote + const vote = await PollVotes.insert({ + id: genId(), + createdAt, + noteId: note.id, + userId: user.id, + choice: ps.choice, + }).then(x => PollVotes.findOneByOrFail(x.identifiers[0])); + + // Increment votes count + const index = ps.choice + 1; // In SQL, array index is 1 based + await Polls.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`); + + publishNoteStream(note.id, 'pollVoted', { + choice: ps.choice, + userId: user.id, + }); + + // Notify + createNotification(note.userId, 'pollVote', { + notifierId: user.id, + noteId: note.id, + choice: ps.choice, + }); + + // Fetch watchers + NoteWatchings.findBy({ + noteId: note.id, + userId: Not(user.id), + }).then(watchers => { + for (const watcher of watchers) { + createNotification(watcher.userId, 'pollVote', { + notifierId: user.id, + noteId: note.id, + choice: ps.choice, + }); + } + }); + + // リモート投票の場合リプライ送信 + if (note.userHost != null) { + const pollOwner = await Users.findOneByOrFail({ id: note.userId }) as IRemoteUser; + + deliver(user, renderActivity(await renderVote(user, vote, note, poll, pollOwner)), pollOwner.inbox); + } + + // リモートフォロワーにUpdate配信 + deliverQuestionUpdate(note.id); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts index 49010e1431..15a62d394d 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts @@ -1,21 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Brackets, type FindOptionsWhere } from 'typeorm'; -import type { NoteReactionsRepository } from '@/models/_.js'; -import type { MiNoteReaction } from '@/models/NoteReaction.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteReactionEntityService } from '@/core/entities/NoteReactionEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { QueryService } from '@/core/QueryService.js'; +import { DeepPartial, FindOptionsWhere } from 'typeorm'; +import { NoteReactions } from '@/models/index.js'; +import { NoteReaction } from '@/models/entities/note-reaction.js'; +import define from '../../define.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes', 'reactions'], - requireCredential: 'conditional', + requireCredential: false, allowGet: true, cacheSec: 60, @@ -45,38 +37,36 @@ export const paramDef = { noteId: { type: 'string', format: 'misskey:id' }, type: { type: 'string', nullable: true }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + offset: { type: 'integer', default: 0 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, }, required: ['noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.noteReactionsRepository) - private noteReactionsRepository: NoteReactionsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = { + noteId: ps.noteId, + } as FindOptionsWhere; - private noteReactionEntityService: NoteReactionEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), ps.sinceId, ps.untilId) - .andWhere('reaction.noteId = :noteId', { noteId: ps.noteId }) - .leftJoinAndSelect('reaction.user', 'user') - .leftJoinAndSelect('reaction.note', 'note'); - - if (ps.type) { - // ローカルリアクションはホスト名が . とされているが - // DB 上ではそうではないので、必要に応じて変換 - const suffix = '@.:'; - const type = ps.type.endsWith(suffix) ? ps.type.slice(0, ps.type.length - suffix.length) + ':' : ps.type; - query.andWhere('reaction.reaction = :type', { type }); - } - - const reactions = await query.limit(ps.limit).getMany(); - - return await Promise.all(reactions.map(reaction => this.noteReactionEntityService.pack(reaction, me))); - }); + if (ps.type) { + // ローカルリアクションはホスト名が . とされているが + // DB 上ではそうではないので、必要に応じて変換 + const suffix = '@.:'; + const type = ps.type.endsWith(suffix) ? ps.type.slice(0, ps.type.length - suffix.length) + ':' : ps.type; + query.reaction = type; } -} + + const reactions = await NoteReactions.find({ + where: query, + take: ps.limit, + skip: ps.offset, + order: { + id: -1, + }, + relations: ['user', 'user.avatar', 'user.banner', 'note'], + }); + + return await Promise.all(reactions.map(reaction => NoteReactions.pack(reaction, user))); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts index 9a6007b672..07e52a9266 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts @@ -1,20 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { ReactionService } from '@/core/ReactionService.js'; +import createReaction from '@/services/note/reaction/create.js'; +import define from '../../../define.js'; +import { getNote } from '../../../common/getters.js'; import { ApiError } from '../../../error.js'; export const meta = { tags: ['reactions', 'notes'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:reactions', @@ -48,23 +40,16 @@ export const paramDef = { required: ['noteId', 'reaction'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private getterService: GetterService, - private reactionService: ReactionService, - ) { - super(meta, paramDef, async (ps, me) => { - const note = await this.getterService.getNote(ps.noteId).catch(err => { - if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw err; - }); - await this.reactionService.create(me, note, ps.reaction).catch(err => { - if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') throw new ApiError(meta.errors.alreadyReacted); - if (err.id === 'e70412a4-7197-4726-8e74-f3e0deb92aa7') throw new ApiError(meta.errors.youHaveBeenBlocked); - throw err; - }); - return; - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + await createReaction(user, note, ps.reaction).catch(e => { + if (e.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') throw new ApiError(meta.errors.alreadyReacted); + if (e.id === 'e70412a4-7197-4726-8e74-f3e0deb92aa7') throw new ApiError(meta.errors.youHaveBeenBlocked); + throw e; + }); + return; +}); diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts b/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts index 397ef775a6..c13cafa21d 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts @@ -1,19 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import ms from 'ms'; -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { ReactionService } from '@/core/ReactionService.js'; +import deleteReaction from '@/services/note/reaction/delete.js'; +import define from '../../../define.js'; +import { getNote } from '../../../common/getters.js'; import { ApiError } from '../../../error.js'; export const meta = { tags: ['reactions', 'notes'], - requireCredential: 'always', + requireCredential: true, kind: 'write:reactions', @@ -46,21 +40,14 @@ export const paramDef = { required: ['noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private getterService: GetterService, - private reactionService: ReactionService, - ) { - super(meta, paramDef, async (ps, me) => { - const note = await this.getterService.getNote(ps.noteId).catch(err => { - if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw err; - }); - await this.reactionService.delete(me, note).catch(err => { - if (err.id === '60527ec9-b4cb-4a88-a6bd-32d3ad26817d') throw new ApiError(meta.errors.notReacted); - throw err; - }); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + await deleteReaction(user, note).catch(e => { + if (e.id === '60527ec9-b4cb-4a88-a6bd-32d3ad26817d') throw new ApiError(meta.errors.notReacted); + throw e; + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts index b5c76d3b0a..28be360763 100644 --- a/packages/backend/src/server/api/endpoints/notes/renotes.ts +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -1,21 +1,16 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; +import { Notes } from '@/models/index.js'; +import define from '../../define.js'; +import { getNote } from '../../common/getters.js'; import { ApiError } from '../../error.js'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; export const meta = { tags: ['notes'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'array', @@ -40,56 +35,39 @@ export const paramDef = { type: 'object', properties: { noteId: { type: 'string', format: 'misskey:id' }, - userId: { type: "string", format: "misskey:id" }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, - quote: { type: 'boolean', default: false }, }, required: ['noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); - private noteEntityService: NoteEntityService, - private queryService: QueryService, - private getterService: GetterService, - ) { - super(meta, paramDef, async (ps, me) => { - const note = await this.getterService.getNote(ps.noteId).catch(err => { - if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw err; - }); + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.renoteId = :renoteId', { renoteId: note.id }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere('note.renoteId = :renoteId', { renoteId: note.id }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - if (ps.userId) { - query.andWhere("user.id = :userId", { userId: ps.userId }); - } + generateVisibilityQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); - if (ps.quote) { - query.andWhere("note.text IS NOT NULL"); - } else { - query.andWhere("note.text IS NULL"); - } + const renotes = await query.take(ps.limit).getMany(); - this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); - - const renotes = await query.limit(ps.limit).getMany(); - - return await this.noteEntityService.packMany(renotes, me); - }); - } -} + return await Notes.packMany(renotes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts index 81a85edb4e..ab0018f58e 100644 --- a/packages/backend/src/server/api/endpoints/notes/replies.ts +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -1,19 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { DI } from '@/di-symbols.js'; +import { Notes } from '@/models/index.js'; +import define from '../../define.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; +import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; export const meta = { tags: ['notes'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'array', @@ -37,31 +32,27 @@ export const paramDef = { required: ['noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.replyId = :replyId', { replyId: ps.noteId }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - private noteEntityService: NoteEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere('note.replyId = :replyId', { replyId: ps.noteId }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + generateVisibilityQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); - this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); + const timeline = await query.take(ps.limit).getMany(); - const timeline = await query.limit(ps.limit).getMany(); - - return await this.noteEntityService.packMany(timeline, me); - }); - } -} + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index 851bb8e8f1..777de7221c 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -1,26 +1,16 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { Brackets } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; +import { Notes } from '@/models/index.js'; import { safeForSql } from '@/misc/safe-for-sql.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { MetaService } from '@/core/MetaService.js'; -import { CacheService } from '@/core/CacheService.js'; -import { UtilityService } from '@/core/UtilityService.js'; +import define from '../../define.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; +import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; export const meta = { tags: ['notes', 'hashtags'], - requireCredential: 'conditional', - res: { type: 'array', optional: false, nullable: false, @@ -46,125 +36,105 @@ export const paramDef = { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - - tag: { type: 'string', minLength: 1 }, - query: { - type: 'array', - description: 'The outer arrays are chained with OR, the inner arrays are chained with AND.', - items: { - type: 'array', - items: { - type: 'string', - minLength: 1, - }, - minItems: 1, - }, - minItems: 1, - }, }, anyOf: [ - { required: ['tag'] }, - { required: ['query'] }, + { + properties: { + tag: { type: 'string', minLength: 1 }, + }, + required: ['tag'], + }, + { + properties: { + query: { + type: 'array', + description: 'The outer arrays are chained with OR, the inner arrays are chained with AND.', + items: { + type: 'array', + items: { + type: 'string', + minLength: 1, + }, + minItems: 1, + }, + minItems: 1, + }, + }, + required: ['query'], + }, ], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - private noteEntityService: NoteEntityService, - private queryService: QueryService, - private metaService: MetaService, - private cacheService: CacheService, - private utilityService: UtilityService, - ) { - super(meta, paramDef, async (ps, me) => { - const meta = await this.metaService.fetch(true); + generateVisibilityQuery(query, me); + if (me) generateMutedUserQuery(query, me); + if (me) generateBlockedUserQuery(query, me); - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere('note.visibility = \'public\'') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - if (!meta.enableBotTrending) query.andWhere('user.isBot = FALSE'); - - this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); - - const [ - followings, - ] = me ? await Promise.all([ - this.cacheService.userFollowingsCache.fetch(me.id), - ]) : [undefined]; - - try { - if (ps.tag) { - if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection'); - query.andWhere(`'{"${normalizeForSearch(ps.tag)}"}' <@ note.tags`); - } else { - query.andWhere(new Brackets(qb => { - for (const tags of ps.query!) { - qb.orWhere(new Brackets(qb => { - for (const tag of tags) { - if (!safeForSql(normalizeForSearch(tag))) throw new Error('Injection'); - qb.andWhere(`'{"${normalizeForSearch(tag)}"}' <@ note.tags`); - } - })); + try { + if (ps.tag) { + if (!safeForSql(ps.tag)) throw 'Injection'; + query.andWhere(`'{"${normalizeForSearch(ps.tag)}"}' <@ note.tags`); + } else { + query.andWhere(new Brackets(qb => { + for (const tags of ps.query!) { + qb.orWhere(new Brackets(qb => { + for (const tag of tags) { + if (!safeForSql(tag)) throw 'Injection'; + qb.andWhere(`'{"${normalizeForSearch(tag)}"}' <@ note.tags`); } })); } - } catch (e) { - if (e === 'Injection') return []; - throw e; - } - - if (ps.reply != null) { - if (ps.reply) { - query.andWhere('note.replyId IS NOT NULL'); - } else { - query.andWhere('note.replyId IS NULL'); - } - } - - if (ps.renote != null) { - if (ps.renote) { - query.andWhere('note.renoteId IS NOT NULL'); - } else { - query.andWhere('note.renoteId IS NULL'); - } - } - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - - if (ps.poll != null) { - if (ps.poll) { - query.andWhere('note.hasPoll = TRUE'); - } else { - query.andWhere('note.hasPoll = FALSE'); - } - } - - // Search notes - let notes = await query.limit(ps.limit).getMany(); - - notes = notes.filter(note => { - if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false; - if (note.user?.isSuspended) return false; - if (this.utilityService.isBlockedHost(meta.blockedHosts, note.userHost)) return false; - if (meta.allowlistMode && !this.utilityService.isAllowedHost(meta.allowedHosts, note.userHost)) return false; - if (this.utilityService.isSilencedHost(meta.silencedHosts, note.userHost)) return false; - return true; - }); - - return await this.noteEntityService.packMany(notes, me); - }); + })); + } + } catch (e) { + if (e === 'Injection') return []; + throw e; } -} + + if (ps.reply != null) { + if (ps.reply) { + query.andWhere('note.replyId IS NOT NULL'); + } else { + query.andWhere('note.replyId IS NULL'); + } + } + + if (ps.renote != null) { + if (ps.renote) { + query.andWhere('note.renoteId IS NOT NULL'); + } else { + query.andWhere('note.renoteId IS NULL'); + } + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.poll != null) { + if (ps.poll) { + query.andWhere('note.hasPoll = TRUE'); + } else { + query.andWhere('note.hasPoll = FALSE'); + } + } + + // Search notes + const notes = await query.take(ps.limit).getMany(); + + return await Notes.packMany(notes, me); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts index 6822c04ebb..4e2cdae801 100644 --- a/packages/backend/src/server/api/endpoints/notes/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/search.ts @@ -1,19 +1,17 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { SearchService } from '@/core/SearchService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { RoleService } from '@/core/RoleService.js'; -import { ApiError } from '../../error.js'; +import { In } from 'typeorm'; +import { Notes } from '@/models/index.js'; +import config from '@/config/index.js'; +import es from '../../../../db/elasticsearch.js'; +import define from '../../define.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; +import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; export const meta = { tags: ['notes'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'array', @@ -26,11 +24,6 @@ export const meta = { }, errors: { - unavailable: { - message: 'Search of notes unavailable.', - code: 'UNAVAILABLE', - id: '0b44998d-77aa-4427-80d0-d2c9b8523011', - }, }, } as const; @@ -44,44 +37,106 @@ export const paramDef = { offset: { type: 'integer', default: 0 }, host: { type: 'string', - description: 'The local host is represented with `.`.', + nullable: true, + description: 'The local host is represented with `null`.', }, - filetype: { type: 'string', nullable: true }, userId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, channelId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, - order: { type: 'string' }, }, required: ['query'], } as const; -// TODO: ロジックをサービスに切り出す +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + if (es == null) { + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId); -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private noteEntityService: NoteEntityService, - private searchService: SearchService, - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const policies = await this.roleService.getUserPolicies(me ? me.id : null); - if (!policies.canSearchNotes) { - throw new ApiError(meta.errors.unavailable); - } + if (ps.userId) { + query.andWhere('note.userId = :userId', { userId: ps.userId }); + } else if (ps.channelId) { + query.andWhere('note.channelId = :channelId', { channelId: ps.channelId }); + } - const notes = await this.searchService.searchNote(ps.query, me, { + query + .andWhere('note.text ILIKE :q', { q: `%${ps.query}%` }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + + generateVisibilityQuery(query, me); + if (me) generateMutedUserQuery(query, me); + if (me) generateBlockedUserQuery(query, me); + + const notes = await query.take(ps.limit).getMany(); + + return await Notes.packMany(notes, me); + } else { + const userQuery = ps.userId != null ? [{ + term: { userId: ps.userId, - channelId: ps.channelId, - host: ps.host, - filetype: ps.filetype, - order: ps.order, - }, { - untilId: ps.untilId, - sinceId: ps.sinceId, - limit: ps.limit, - }); + }, + }] : []; - return await this.noteEntityService.packMany(notes, me); + const hostQuery = ps.userId == null ? + ps.host === null ? [{ + bool: { + must_not: { + exists: { + field: 'userHost', + }, + }, + }, + }] : ps.host !== undefined ? [{ + term: { + userHost: ps.host, + }, + }] : [] + : []; + + const result = await es.search({ + index: config.elasticsearch.index || 'misskey_note', + body: { + size: ps.limit, + from: ps.offset, + query: { + bool: { + must: [{ + simple_query_string: { + fields: ['text'], + query: ps.query.toLowerCase(), + default_operator: 'and', + }, + }, ...hostQuery, ...userQuery], + }, + }, + sort: [{ + _doc: 'desc', + }], + }, }); + + const hits = result.body.hits.hits.map((hit: any) => hit._id); + + if (hits.length === 0) return []; + + // Fetch found notes + const notes = await Notes.find({ + where: { + id: In(hits), + }, + order: { + id: -1, + }, + }); + + return await Notes.packMany(notes, me); } -} +}); diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts index 219d43f791..5cd74bd2ca 100644 --- a/packages/backend/src/server/api/endpoints/notes/show.ts +++ b/packages/backend/src/server/api/endpoints/notes/show.ts @@ -1,20 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { DI } from '@/di-symbols.js'; -import type { NotesRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; +import { Notes } from '@/models/index.js'; +import define from '../../define.js'; +import { getNote } from '../../common/getters.js'; import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'object', @@ -39,34 +31,14 @@ export const paramDef = { required: ['noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - private noteEntityService: NoteEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = await this.notesRepository.createQueryBuilder('note') - .where('note.id = :noteId', { noteId: ps.noteId }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); - this.queryService.generateVisibilityQuery(query, me); - if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - } - - const note = await query.getOne(); - - if (note === null) { - throw new ApiError(meta.errors.noSuchNote); - } - - return await this.noteEntityService.pack(note, me, { - detail: true, - }); - }); - } -} + return await Notes.pack(note, user, { + detail: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/state.ts b/packages/backend/src/server/api/endpoints/notes/state.ts index 02ba1ad7a9..01afa5add2 100644 --- a/packages/backend/src/server/api/endpoints/notes/state.ts +++ b/packages/backend/src/server/api/endpoints/notes/state.ts @@ -1,18 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, NoteThreadMutingsRepository, NoteFavoritesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; +import { NoteFavorites, Notes, NoteThreadMutings, NoteWatchings } from '@/models/index.js'; +import define from '../../define.js'; export const meta = { tags: ['notes'], - requireCredential: 'always', - kind: 'read:account', + requireCredential: true, res: { type: 'object', @@ -22,6 +14,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isWatching: { + type: 'boolean', + optional: false, nullable: false, + }, isMutedThread: { type: 'boolean', optional: false, nullable: false, @@ -38,42 +34,37 @@ export const paramDef = { required: ['noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const note = await Notes.findOneByOrFail({ id: ps.noteId }); - @Inject(DI.noteThreadMutingsRepository) - private noteThreadMutingsRepository: NoteThreadMutingsRepository, + const [favorite, watching, threadMuting] = await Promise.all([ + NoteFavorites.count({ + where: { + userId: user.id, + noteId: note.id, + }, + take: 1, + }), + NoteWatchings.count({ + where: { + userId: user.id, + noteId: note.id, + }, + take: 1, + }), + NoteThreadMutings.count({ + where: { + userId: user.id, + threadId: note.threadId || note.id, + }, + take: 1, + }), + ]); - @Inject(DI.noteFavoritesRepository) - private noteFavoritesRepository: NoteFavoritesRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const note = await this.notesRepository.findOneByOrFail({ id: ps.noteId }); - - const [favorite, threadMuting] = await Promise.all([ - this.noteFavoritesRepository.count({ - where: { - userId: me.id, - noteId: note.id, - }, - take: 1, - }), - this.noteThreadMutingsRepository.count({ - where: { - userId: me.id, - threadId: note.threadId ?? note.id, - }, - take: 1, - }), - ]); - - return { - isFavorited: favorite !== 0, - isMutedThread: threadMuting !== 0, - }; - }); - } -} + return { + isFavorited: favorite !== 0, + isWatching: watching !== 0, + isMutedThread: threadMuting !== 0, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts index d31284b885..cf360526d3 100644 --- a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts @@ -1,30 +1,17 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import ms from 'ms'; -import type { NotesRepository, NoteThreadMutingsRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; -import { DI } from '@/di-symbols.js'; +import { Notes, NoteThreadMutings } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import readNote from '@/services/note/read.js'; +import define from '../../../define.js'; +import { getNote } from '../../../common/getters.js'; import { ApiError } from '../../../error.js'; export const meta = { tags: ['notes'], - requireCredential: 'always', + requireCredential: true, kind: 'write:account', - limit: { - duration: ms('1hour'), - max: 10, - }, - errors: { noSuchNote: { message: 'No such note.', @@ -42,40 +29,27 @@ export const paramDef = { required: ['noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); - @Inject(DI.noteThreadMutingsRepository) - private noteThreadMutingsRepository: NoteThreadMutingsRepository, + const mutedNotes = await Notes.find({ + where: [{ + id: note.threadId || note.id, + }, { + threadId: note.threadId || note.id, + }], + }); - private getterService: GetterService, - private noteReadService: NoteReadService, - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const note = await this.getterService.getNote(ps.noteId).catch(err => { - if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw err; - }); + await readNote(user.id, mutedNotes); - const mutedNotes = await this.notesRepository.find({ - where: [{ - id: note.threadId ?? note.id, - }, { - threadId: note.threadId ?? note.id, - }], - }); - - await this.noteReadService.read(me.id, mutedNotes); - - await this.noteThreadMutingsRepository.insert({ - id: this.idService.gen(), - threadId: note.threadId ?? note.id, - userId: me.id, - }); - }); - } -} + await NoteThreadMutings.insert({ + id: genId(), + createdAt: new Date(), + threadId: note.threadId || note.id, + userId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts index 7a0ecf544c..ac310d0fe6 100644 --- a/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts @@ -1,19 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { NoteThreadMutingsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { DI } from '@/di-symbols.js'; +import { NoteThreadMutings } from '@/models/index.js'; +import define from '../../../define.js'; +import { getNote } from '../../../common/getters.js'; import { ApiError } from '../../../error.js'; export const meta = { tags: ['notes'], - requireCredential: 'always', + requireCredential: true, kind: 'write:account', @@ -34,24 +27,15 @@ export const paramDef = { required: ['noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.noteThreadMutingsRepository) - private noteThreadMutingsRepository: NoteThreadMutingsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); - private getterService: GetterService, - ) { - super(meta, paramDef, async (ps, me) => { - const note = await this.getterService.getNote(ps.noteId).catch(err => { - if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw err; - }); - - await this.noteThreadMutingsRepository.delete({ - threadId: note.threadId ?? note.id, - userId: me.id, - }); - }); - } -} + await NoteThreadMutings.delete({ + threadId: note.threadId || note.id, + userId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 01d666df30..22f4925175 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -1,28 +1,19 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { Brackets } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, ChannelFollowingsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import ActiveUsersChart from '@/core/chart/charts/active-users.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { IdService } from '@/core/IdService.js'; -import { CacheService } from '@/core/CacheService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; -import { MiLocalUser } from '@/models/User.js'; -import { MetaService } from '@/core/MetaService.js'; -import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { Notes, Followings } from '@/models/index.js'; +import { activeUsersChart } from '@/services/chart/index.js'; +import define from '../../define.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; +import { generateRepliesQuery } from '../../common/generate-replies-query.js'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; +import { generateChannelQuery } from '../../common/generate-channel-query.js'; +import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; export const meta = { tags: ['notes'], - requireCredential: 'always', - kind: 'read:account', + requireCredential: true, res: { type: 'array', @@ -43,212 +34,98 @@ export const paramDef = { untilId: { type: 'string', format: 'misskey:id' }, sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, - allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default includeMyRenotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true }, - withFiles: { type: 'boolean', default: false }, - withRenotes: { type: 'boolean', default: true }, - withBots: { type: 'boolean', default: true }, + withFiles: { + type: 'boolean', + default: false, + description: 'Only show notes that have attached files.', + }, }, required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const hasFollowing = (await Followings.count({ + where: { + followerId: user.id, + }, + take: 1, + })) !== 0; - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, + //#region Construct query + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: user.id }); - private noteEntityService: NoteEntityService, - private activeUsersChart: ActiveUsersChart, - private idService: IdService, - private cacheService: CacheService, - private fanoutTimelineEndpointService: FanoutTimelineEndpointService, - private userFollowingService: UserFollowingService, - private queryService: QueryService, - private metaService: MetaService, - ) { - super(meta, paramDef, async (ps, me) => { - const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); - const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); + const query = makePaginationQuery(Notes.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere(new Brackets(qb => { qb + .where('note.userId = :meId', { meId: user.id }); + if (hasFollowing) qb.orWhere(`note.userId IN (${ followingQuery.getQuery() })`); + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') + .setParameters(followingQuery.getParameters()); - const serverSettings = await this.metaService.fetch(); - - if (!serverSettings.enableFanoutTimeline) { - const timeline = await this.getFromDb({ - untilId, - sinceId, - limit: ps.limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, - withFiles: ps.withFiles, - withRenotes: ps.withRenotes, - withBots: ps.withBots, - }, me); - - process.nextTick(() => { - this.activeUsersChart.read(me); - }); - - return await this.noteEntityService.packMany(timeline, me); - } - - const [ - followings, - ] = await Promise.all([ - this.cacheService.userFollowingsCache.fetch(me.id), - ]); - - const timeline = this.fanoutTimelineEndpointService.timeline({ - untilId, - sinceId, - limit: ps.limit, - allowPartial: ps.allowPartial, - me, - useDbFallback: serverSettings.enableFanoutTimelineDbFallback, - redisTimelines: ps.withFiles ? [`homeTimelineWithFiles:${me.id}`] : [`homeTimeline:${me.id}`], - alwaysIncludeMyNotes: true, - excludePureRenotes: !ps.withRenotes, - noteFilter: note => { - if (note.reply && note.reply.visibility === 'followers') { - if (!Object.hasOwn(followings, note.reply.userId)) return false; - } - if (!ps.withBots && note.user?.isBot) return false; - - return true; - }, - dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ - untilId, - sinceId, - limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, - withFiles: ps.withFiles, - withRenotes: ps.withRenotes, - withBots: ps.withBots, - }, me), - }); - - process.nextTick(() => { - this.activeUsersChart.read(me); - }); - - return timeline; - }); - } - - private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; withBots: boolean; }, me: MiLocalUser) { - const followees = await this.userFollowingService.getFollowees(me.id); - const followingChannels = await this.channelFollowingsRepository.find({ - where: { - followerId: me.id, - }, - }); - - //#region Construct query - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - if (followees.length > 0 && followingChannels.length > 0) { - // ユーザー・チャンネルともにフォローあり - const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; - const followingChannelIds = followingChannels.map(x => x.followeeId); - query.andWhere(new Brackets(qb => { - qb - .where(new Brackets(qb2 => { - qb2 - .where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }) - .andWhere('note.channelId IS NULL'); - })) - .orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); - })); - } else if (followees.length > 0) { - // ユーザーフォローのみ(チャンネルフォローなし) - const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; - query - .andWhere('note.channelId IS NULL') - .andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); - } else if (followingChannels.length > 0) { - // チャンネルフォローのみ(ユーザーフォローなし) - const followingChannelIds = followingChannels.map(x => x.followeeId); - query.andWhere(new Brackets(qb => { - qb - .where('note.channelId IN (:...followingChannelIds)', { followingChannelIds }) - .orWhere('note.userId = :meId', { meId: me.id }); - })); - } else { - // フォローなし - query - .andWhere('note.channelId IS NULL') - .andWhere('note.userId = :meId', { meId: me.id }); - } + generateChannelQuery(query, user); + generateRepliesQuery(query, user); + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateMutedNoteQuery(query, user); + generateBlockedUserQuery(query, user); + if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { - qb - .where('note.replyId IS NULL') // 返信ではない - .orWhere(new Brackets(qb => { - qb // 返信だけど投稿者自身への返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = note.userId'); - })); + qb.orWhere('note.userId != :meId', { meId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); })); - - this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeRenotedMyNotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeLocalRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserHost IS NOT NULL'); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - - if (ps.withRenotes === false) { - query.andWhere('note.renoteId IS NULL'); - } - - if (!ps.withBots) query.andWhere('user.isBot = FALSE'); - //#endregion - - return await query.limit(ps.limit).getMany(); } -} + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.take(ps.limit).getMany(); + + process.nextTick(() => { + activeUsersChart.read(user); + }); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index 2a6d514f19..5e40e7106f 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -1,39 +1,24 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { URLSearchParams } from 'node:url'; -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { RoleService } from '@/core/RoleService.js'; +import fetch from 'node-fetch'; +import config from '@/config/index.js'; +import { getAgentByUrl } from '@/misc/fetch.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Notes } from '@/models/index.js'; import { ApiError } from '../../error.js'; +import { getNote } from '../../common/getters.js'; +import define from '../../define.js'; export const meta = { tags: ['notes'], - requireCredential: 'always', - kind: 'read:account', + requireCredential: false, res: { type: 'object', optional: false, nullable: false, - properties: { - sourceLang: { type: 'string' }, - text: { type: 'string' }, - }, }, errors: { - unavailable: { - message: 'Translate of notes unavailable.', - code: 'UNAVAILABLE', - id: '50a70314-2d8a-431b-b433-efa5cc56444c', - }, noSuchNote: { message: 'No such note.', code: 'NO_SUCH_NOTE', @@ -51,70 +36,59 @@ export const paramDef = { required: ['noteId', 'targetLang'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private noteEntityService: NoteEntityService, - private getterService: GetterService, - private metaService: MetaService, - private httpRequestService: HttpRequestService, - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const policies = await this.roleService.getUserPolicies(me.id); - if (!policies.canUseTranslator) { - throw new ApiError(meta.errors.unavailable); - } +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); - const note = await this.getterService.getNote(ps.noteId).catch(err => { - if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw err; - }); - - if (!(await this.noteEntityService.isVisibleForMe(note, me.id))) { - return 204; // TODO: 良い感じのエラー返す - } - - if (note.text == null) { - return 204; - } - - const instance = await this.metaService.fetch(); - - if (instance.deeplAuthKey == null) { - return 204; // TODO: 良い感じのエラー返す - } - - let targetLang = ps.targetLang; - if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; - - const params = new URLSearchParams(); - params.append('auth_key', instance.deeplAuthKey); - params.append('text', note.text); - params.append('target_lang', targetLang); - - const endpoint = instance.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; - - const res = await this.httpRequestService.send(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json, */*', - }, - body: params.toString(), - }); - - const json = (await res.json()) as { - translations: { - detected_source_language: string; - text: string; - }[]; - }; - - return { - sourceLang: json.translations[0].detected_source_language, - text: json.translations[0].text, - }; - }); + if (!(await Notes.isVisibleForMe(note, user ? user.id : null))) { + return 204; // TODO: 良い感じのエラー返す } -} + + if (note.text == null) { + return 204; + } + + const instance = await fetchMeta(); + + if (instance.deeplAuthKey == null) { + return 204; // TODO: 良い感じのエラー返す + } + + let targetLang = ps.targetLang; + if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; + + const params = new URLSearchParams(); + params.append('auth_key', instance.deeplAuthKey); + params.append('text', note.text); + params.append('target_lang', targetLang); + + const endpoint = instance.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; + + const res = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': config.userAgent, + Accept: 'application/json, */*', + }, + body: params, + // TODO + //timeout: 10000, + agent: getAgentByUrl, + }); + + const json = (await res.json()) as { + translations: { + detected_source_language: string; + text: string; + }[]; + }; + + return { + sourceLang: json.translations[0].detected_source_language, + text: json.translations[0].text, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/notes/unrenote.ts b/packages/backend/src/server/api/endpoints/notes/unrenote.ts index f23b36155e..3fba0efe0c 100644 --- a/packages/backend/src/server/api/endpoints/notes/unrenote.ts +++ b/packages/backend/src/server/api/endpoints/notes/unrenote.ts @@ -1,21 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import ms from 'ms'; -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, NotesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteDeleteService } from '@/core/NoteDeleteService.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; +import deleteNote from '@/services/note/delete.js'; +import { Notes, Users } from '@/models/index.js'; +import define from '../../define.js'; +import { getNote } from '../../common/getters.js'; import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], - requireCredential: 'always', + requireCredential: true, kind: 'write:notes', @@ -38,41 +31,23 @@ export const paramDef = { type: 'object', properties: { noteId: { type: 'string', format: 'misskey:id' }, - quote: { type: 'boolean', default: false }, }, required: ['noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, + const renotes = await Notes.findBy({ + userId: user.id, + renoteId: note.id, + }); - private getterService: GetterService, - private noteDeleteService: NoteDeleteService, - ) { - super(meta, paramDef, async (ps, me) => { - const note = await this.getterService.getNote(ps.noteId).catch(err => { - if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw err; - }); - - const renotes = await this.notesRepository.findBy({ - userId: me.id, - renoteId: note.id, - }); - - for (const note of renotes) { - if (ps.quote) { - if (note.text) this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: me.id }), note, false); - } else { - if (!note.text) this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: me.id }), note, false); - } - } - }); + for (const note of renotes) { + deleteNote(await Users.findOneByOrFail({ id: user.id }), note); } -} +}); diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index 6b04911555..e603a8f625 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -1,28 +1,15 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; import { Brackets } from 'typeorm'; -import type { MiUserList, NotesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import ActiveUsersChart from '@/core/chart/charts/active-users.js'; -import { DI } from '@/di-symbols.js'; -import { CacheService } from '@/core/CacheService.js'; -import { IdService } from '@/core/IdService.js'; -import { QueryService } from '@/core/QueryService.js'; -import { MiLocalUser } from '@/models/User.js'; -import { MetaService } from '@/core/MetaService.js'; -import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { UserLists, UserListJoinings, Notes } from '@/models/index.js'; +import { activeUsersChart } from '@/services/chart/index.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; export const meta = { tags: ['notes', 'lists'], - requireCredential: 'always', - kind: 'read:account', + requireCredential: true, res: { type: 'array', @@ -52,11 +39,9 @@ export const paramDef = { untilId: { type: 'string', format: 'misskey:id' }, sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, - allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default includeMyRenotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true }, - withRenotes: { type: 'boolean', default: true }, withFiles: { type: 'boolean', default: false, @@ -66,176 +51,73 @@ export const paramDef = { required: ['listId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const list = await UserLists.findOneBy({ + id: ps.listId, + userId: user.id, + }); - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - private noteEntityService: NoteEntityService, - private activeUsersChart: ActiveUsersChart, - private cacheService: CacheService, - private idService: IdService, - private fanoutTimelineEndpointService: FanoutTimelineEndpointService, - private queryService: QueryService, - private metaService: MetaService, - ) { - super(meta, paramDef, async (ps, me) => { - const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); - const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); - - const list = await this.userListsRepository.findOneBy({ - id: ps.listId, - userId: me.id, - }); - - if (list == null) { - throw new ApiError(meta.errors.noSuchList); - } - - const serverSettings = await this.metaService.fetch(); - - if (!serverSettings.enableFanoutTimeline) { - const timeline = await this.getFromDb(list, { - untilId, - sinceId, - limit: ps.limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, - withFiles: ps.withFiles, - withRenotes: ps.withRenotes, - }, me); - - this.activeUsersChart.read(me); - - await this.noteEntityService.packMany(timeline, me); - } - - const timeline = await this.fanoutTimelineEndpointService.timeline({ - untilId, - sinceId, - limit: ps.limit, - allowPartial: ps.allowPartial, - me, - useDbFallback: serverSettings.enableFanoutTimelineDbFallback, - redisTimelines: ps.withFiles ? [`userListTimelineWithFiles:${list.id}`] : [`userListTimeline:${list.id}`], - alwaysIncludeMyNotes: true, - excludePureRenotes: !ps.withRenotes, - dbFallback: async (untilId, sinceId, limit) => await this.getFromDb(list, { - untilId, - sinceId, - limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, - withFiles: ps.withFiles, - withRenotes: ps.withRenotes, - }, me), - }); - - this.activeUsersChart.read(me); - - return timeline; - }); + if (list == null) { + throw new ApiError(meta.errors.noSuchList); } - private async getFromDb(list: MiUserList, ps: { - untilId: string | null, - sinceId: string | null, - limit: number, - includeMyRenotes: boolean, - includeRenotedMyNotes: boolean, - includeLocalRenotes: boolean, - withFiles: boolean, - withRenotes: boolean, - }, me: MiLocalUser) { - //#region Construct query - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .innerJoin(this.userListMembershipsRepository.metadata.targetName, 'userListMemberships', 'userListMemberships.userId = note.userId') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .andWhere('userListMemberships.userListId = :userListId', { userListId: list.id }) - .andWhere('note.channelId IS NULL') // チャンネルノートではない - .andWhere(new Brackets(qb => { - qb - .where('note.replyId IS NULL') // 返信ではない - .orWhere(new Brackets(qb => { - qb // 返信だけど投稿者自身への返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = note.userId'); - })) - .orWhere(new Brackets(qb => { - qb // 返信だけど自分宛ての返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = :meId', { meId: me.id }); - })) - .orWhere(new Brackets(qb => { - qb // 返信だけどwithRepliesがtrueの場合 - .where('note.replyId IS NOT NULL') - .andWhere('userListMemberships.withReplies = true'); - })); - })); + //#region Construct query + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .innerJoin(UserListJoinings.metadata.targetName, 'userListJoining', 'userListJoining.userId = note.userId') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') + .andWhere('userListJoining.userListId = :userListId', { userListId: list.id }); - this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + generateVisibilityQuery(query, user); - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeRenotedMyNotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeLocalRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserHost IS NOT NULL'); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.withRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere(new Brackets(qb => { - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - })); - })); - } - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - //#endregion - - return await query.limit(ps.limit).getMany(); + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); } -} + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.take(ps.limit).getMany(); + + activeUsersChart.read(user); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/versions.ts b/packages/backend/src/server/api/endpoints/notes/versions.ts deleted file mode 100644 index 7e4059a611..0000000000 --- a/packages/backend/src/server/api/endpoints/notes/versions.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['notes'], - - requireCredential: 'conditional', - - res: { - type: 'object', - optional: false, nullable: false, - }, - - errors: { - noSuchNote: { - message: 'No such note.', - code: 'NO_SUCH_NOTE', - id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - noteId: { type: 'string', format: 'misskey:id' }, - }, - required: ['noteId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private getterService: GetterService, - ) { - super(meta, paramDef, async (ps, me) => { - const edits = await this.getterService.getEdits(ps.noteId).catch(err => { - if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw err; - }); - - let editArray = []; - - for (const edit of edits) { - editArray.push({ - oldDate: edit.oldDate as Date | null ?? null, - updatedAt: edit.updatedAt, - text: edit.oldText ?? edit.newText ?? null, - }); - } - - editArray = editArray.sort((a, b) => { return new Date(b.oldDate ?? b.updatedAt).getTime() - new Date(a.oldDate ?? a.updatedAt).getTime(); }); - - return editArray; - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/notes/watching/create.ts b/packages/backend/src/server/api/endpoints/notes/watching/create.ts new file mode 100644 index 0000000000..7d482b0732 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/watching/create.ts @@ -0,0 +1,38 @@ +import watch from '@/services/note/watch.js'; +import define from '../../../define.js'; +import { getNote } from '../../../common/getters.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + + kind: 'write:account', + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'ea0e37a6-90a3-4f58-ba6b-c328ca206fc7', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteId: { type: 'string', format: 'misskey:id' }, + }, + required: ['noteId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + await watch(user.id, note); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/watching/delete.ts b/packages/backend/src/server/api/endpoints/notes/watching/delete.ts new file mode 100644 index 0000000000..2c1a2e5fbd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/watching/delete.ts @@ -0,0 +1,38 @@ +import unwatch from '@/services/note/unwatch.js'; +import define from '../../../define.js'; +import { getNote } from '../../../common/getters.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + + kind: 'write:account', + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '09b3695c-f72c-4731-a428-7cff825fc82e', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteId: { type: 'string', format: 'misskey:id' }, + }, + required: ['noteId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + await unwatch(user.id, note); +}); diff --git a/packages/backend/src/server/api/endpoints/notifications/create.ts b/packages/backend/src/server/api/endpoints/notifications/create.ts index 2a35e895c9..80d513d8da 100644 --- a/packages/backend/src/server/api/endpoints/notifications/create.ts +++ b/packages/backend/src/server/api/endpoints/notifications/create.ts @@ -1,24 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NotificationService } from '@/core/NotificationService.js'; +import { createNotification } from '@/services/create-notification.js'; +import define from '../../define.js'; export const meta = { tags: ['notifications'], - requireCredential: 'always', + requireCredential: true, kind: 'write:notifications', - limit: { - duration: 1000 * 60, - max: 10, - }, - errors: { }, } as const; @@ -33,18 +22,12 @@ export const paramDef = { required: ['body'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private notificationService: NotificationService, - ) { - super(meta, paramDef, async (ps, user, token) => { - this.notificationService.createNotification(user.id, 'app', { - appAccessTokenId: token ? token.id : null, - customBody: ps.body, - customHeader: ps.header ?? token?.name ?? null, - customIcon: ps.icon ?? token?.iconUrl ?? null, - }); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user, token) => { + createNotification(user.id, 'app', { + appAccessTokenId: token ? token.id : null, + customBody: ps.body, + customHeader: ps.header, + customIcon: ps.icon, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts index c62ee7edbc..d169afbb35 100644 --- a/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts +++ b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts @@ -1,16 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NotificationService } from '@/core/NotificationService.js'; +import { publishMainStream } from '@/services/stream.js'; +import { pushNotification } from '@/services/push-notification.js'; +import { Notifications } from '@/models/index.js'; +import define from '../../define.js'; export const meta = { tags: ['notifications', 'account'], - requireCredential: 'always', + requireCredential: true, kind: 'write:notifications', } as const; @@ -21,13 +17,17 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private notificationService: NotificationService, - ) { - super(meta, paramDef, async (ps, me) => { - this.notificationService.readAllNotification(me.id, true); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Update documents + await Notifications.update({ + notifieeId: user.id, + isRead: false, + }, { + isRead: true, + }); + + // 全ての通知を読みましたよというイベントを発行 + publishMainStream(user.id, 'readAllNotifications'); + pushNotification(user.id, 'readAllNotifications', undefined); +}); diff --git a/packages/backend/src/server/api/endpoints/notifications/read.ts b/packages/backend/src/server/api/endpoints/notifications/read.ts new file mode 100644 index 0000000000..7bce525a55 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notifications/read.ts @@ -0,0 +1,49 @@ +import define from '../../define.js'; +import { readNotification } from '../../common/read-notification.js'; + +export const meta = { + tags: ['notifications', 'account'], + + requireCredential: true, + + kind: 'write:notifications', + + description: 'Mark a notification as read.', + + errors: { + noSuchNotification: { + message: 'No such notification.', + code: 'NO_SUCH_NOTIFICATION', + id: 'efa929d5-05b5-47d1-beec-e6a4dbed011e', + }, + }, +} as const; + +export const paramDef = { + oneOf: [ + { + type: 'object', + properties: { + notificationId: { type: 'string', format: 'misskey:id' }, + }, + required: ['notificationId'], + }, + { + type: 'object', + properties: { + notificationIds: { + type: 'array', + items: { type: 'string', format: 'misskey:id' }, + maxItems: 100, + }, + }, + required: ['notificationIds'], + }, + ], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + if ('notificationId' in ps) return readNotification(user.id, [ps.notificationId]); + return readNotification(user.id, ps.notificationIds); +}); diff --git a/packages/backend/src/server/api/endpoints/notifications/test-notification.ts b/packages/backend/src/server/api/endpoints/notifications/test-notification.ts deleted file mode 100644 index 0bf349d2dc..0000000000 --- a/packages/backend/src/server/api/endpoints/notifications/test-notification.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NotificationService } from '@/core/NotificationService.js'; - -export const meta = { - tags: ['notifications'], - - requireCredential: 'always', - - kind: 'write:notifications', - - limit: { - duration: 1000 * 60, - max: 10, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: {}, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private notificationService: NotificationService, - ) { - super(meta, paramDef, async (ps, user) => { - this.notificationService.createNotification(user.id, 'test', {}); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/page-push.ts b/packages/backend/src/server/api/endpoints/page-push.ts index 9395bd5ce7..6dd3ede85a 100644 --- a/packages/backend/src/server/api/endpoints/page-push.ts +++ b/packages/backend/src/server/api/endpoints/page-push.ts @@ -1,18 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { PagesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; +import { publishMainStream } from '@/services/stream.js'; +import { Users, Pages } from '@/models/index.js'; +import define from '../define.js'; import { ApiError } from '../error.js'; export const meta = { - requireCredential: 'always', + requireCredential: true, secure: true, errors: { @@ -34,30 +26,20 @@ export const paramDef = { required: ['pageId', 'event'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.pagesRepository) - private pagesRepository: PagesRepository, - - private userEntityService: UserEntityService, - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - const page = await this.pagesRepository.findOneBy({ id: ps.pageId }); - if (page == null) { - throw new ApiError(meta.errors.noSuchPage); - } - - this.globalEventService.publishMainStream(page.userId, 'pageEvent', { - pageId: ps.pageId, - event: ps.event, - var: ps.var, - userId: me.id, - user: await this.userEntityService.pack(me.id, { id: page.userId }, { - detail: true, - }), - }); - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const page = await Pages.findOneBy({ id: ps.pageId }); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); } -} + + publishMainStream(page.userId, 'pageEvent', { + pageId: ps.pageId, + event: ps.event, + var: ps.var, + userId: user.id, + user: await Users.pack(user.id, { id: page.userId }, { + detail: true, + }), + }); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts index 9d8929839d..b008cde84e 100644 --- a/packages/backend/src/server/api/endpoints/pages/create.ts +++ b/packages/backend/src/server/api/endpoints/pages/create.ts @@ -1,30 +1,20 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import ms from 'ms'; -import { Inject, Injectable } from '@nestjs/common'; -import type { DriveFilesRepository, PagesRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { MiPage } from '@/models/Page.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { PageEntityService } from '@/core/entities/PageEntityService.js'; -import { DI } from '@/di-symbols.js'; +import { Pages, DriveFiles } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { Page } from '@/models/entities/page.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; export const meta = { tags: ['pages'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:pages', limit: { duration: ms('1hour'), - max: 10, + max: 300, }, res: { @@ -68,58 +58,46 @@ export const paramDef = { required: ['title', 'name', 'content', 'variables', 'script'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.pagesRepository) - private pagesRepository: PagesRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private pageEntityService: PageEntityService, - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - let eyeCatchingImage = null; - if (ps.eyeCatchingImageId != null) { - eyeCatchingImage = await this.driveFilesRepository.findOneBy({ - id: ps.eyeCatchingImageId, - userId: me.id, - }); - - if (eyeCatchingImage == null) { - throw new ApiError(meta.errors.noSuchFile); - } - } - - await this.pagesRepository.findBy({ - userId: me.id, - name: ps.name, - }).then(result => { - if (result.length > 0) { - throw new ApiError(meta.errors.nameAlreadyExists); - } - }); - - const page = await this.pagesRepository.insert(new MiPage({ - id: this.idService.gen(), - updatedAt: new Date(), - title: ps.title, - name: ps.name, - summary: ps.summary, - content: ps.content, - variables: ps.variables, - script: ps.script, - eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null, - userId: me.id, - visibility: 'public', - alignCenter: ps.alignCenter, - hideTitleWhenPinned: ps.hideTitleWhenPinned, - font: ps.font, - })).then(x => this.pagesRepository.findOneByOrFail(x.identifiers[0])); - - return await this.pageEntityService.pack(page); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + let eyeCatchingImage = null; + if (ps.eyeCatchingImageId != null) { + eyeCatchingImage = await DriveFiles.findOneBy({ + id: ps.eyeCatchingImageId, + userId: user.id, }); + + if (eyeCatchingImage == null) { + throw new ApiError(meta.errors.noSuchFile); + } } -} + + await Pages.findBy({ + userId: user.id, + name: ps.name, + }).then(result => { + if (result.length > 0) { + throw new ApiError(meta.errors.nameAlreadyExists); + } + }); + + const page = await Pages.insert(new Page({ + id: genId(), + createdAt: new Date(), + updatedAt: new Date(), + title: ps.title, + name: ps.name, + summary: ps.summary, + content: ps.content, + variables: ps.variables, + script: ps.script, + eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null, + userId: user.id, + visibility: 'public', + alignCenter: ps.alignCenter, + hideTitleWhenPinned: ps.hideTitleWhenPinned, + font: ps.font, + })).then(x => Pages.findOneByOrFail(x.identifiers[0])); + + return await Pages.pack(page); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/delete.ts b/packages/backend/src/server/api/endpoints/pages/delete.ts index c443344559..a7708e6585 100644 --- a/packages/backend/src/server/api/endpoints/pages/delete.ts +++ b/packages/backend/src/server/api/endpoints/pages/delete.ts @@ -1,18 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { PagesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; +import { Pages } from '@/models/index.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; export const meta = { tags: ['pages'], - requireCredential: 'always', + requireCredential: true, kind: 'write:pages', @@ -39,22 +32,15 @@ export const paramDef = { required: ['pageId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.pagesRepository) - private pagesRepository: PagesRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const page = await this.pagesRepository.findOneBy({ id: ps.pageId }); - if (page == null) { - throw new ApiError(meta.errors.noSuchPage); - } - if (page.userId !== me.id) { - throw new ApiError(meta.errors.accessDenied); - } - - await this.pagesRepository.delete(page.id); - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const page = await Pages.findOneBy({ id: ps.pageId }); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); } -} + if (page.userId !== user.id) { + throw new ApiError(meta.errors.accessDenied); + } + + await Pages.delete(page.id); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/featured.ts b/packages/backend/src/server/api/endpoints/pages/featured.ts index d26c40a20f..5a149a626e 100644 --- a/packages/backend/src/server/api/endpoints/pages/featured.ts +++ b/packages/backend/src/server/api/endpoints/pages/featured.ts @@ -1,18 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { PagesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { PageEntityService } from '@/core/entities/PageEntityService.js'; -import { DI } from '@/di-symbols.js'; +import { Pages } from '@/models/index.js'; +import define from '../../define.js'; export const meta = { tags: ['pages'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'array', @@ -31,23 +23,14 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.pagesRepository) - private pagesRepository: PagesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = Pages.createQueryBuilder('page') + .where('page.visibility = \'public\'') + .andWhere('page.likedCount > 0') + .orderBy('page.likedCount', 'DESC'); - private pageEntityService: PageEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.pagesRepository.createQueryBuilder('page') - .where('page.visibility = \'public\'') - .andWhere('page.likedCount > 0') - .orderBy('page.likedCount', 'DESC'); + const pages = await query.take(10).getMany(); - const pages = await query.limit(10).getMany(); - - return await this.pageEntityService.packMany(pages, me); - }); - } -} + return await Pages.packMany(pages, me); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/like.ts b/packages/backend/src/server/api/endpoints/pages/like.ts index 7deab962f8..269b539f74 100644 --- a/packages/backend/src/server/api/endpoints/pages/like.ts +++ b/packages/backend/src/server/api/endpoints/pages/like.ts @@ -1,21 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { PagesRepository, PageLikesRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; +import { Pages, PageLikes } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; export const meta = { tags: ['pages'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:page-likes', @@ -35,7 +26,7 @@ export const meta = { alreadyLiked: { message: 'The page has already been liked.', code: 'ALREADY_LIKED', - id: 'd4c1edbe-7da2-4eae-8714-1acfd2d63941', + id: 'cc98a8a2-0dc3-4123-b198-62c71df18ed3', }, }, } as const; @@ -48,47 +39,34 @@ export const paramDef = { required: ['pageId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.pagesRepository) - private pagesRepository: PagesRepository, - - @Inject(DI.pageLikesRepository) - private pageLikesRepository: PageLikesRepository, - - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const page = await this.pagesRepository.findOneBy({ id: ps.pageId }); - if (page == null) { - throw new ApiError(meta.errors.noSuchPage); - } - - if (page.userId === me.id) { - throw new ApiError(meta.errors.yourPage); - } - - // if already liked - const exist = await this.pageLikesRepository.exist({ - where: { - pageId: page.id, - userId: me.id, - }, - }); - - if (exist) { - throw new ApiError(meta.errors.alreadyLiked); - } - - // Create like - await this.pageLikesRepository.insert({ - id: this.idService.gen(), - pageId: page.id, - userId: me.id, - }); - - this.pagesRepository.increment({ id: page.id }, 'likedCount', 1); - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const page = await Pages.findOneBy({ id: ps.pageId }); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); } -} + + if (page.userId === user.id) { + throw new ApiError(meta.errors.yourPage); + } + + // if already liked + const exist = await PageLikes.findOneBy({ + pageId: page.id, + userId: user.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyLiked); + } + + // Create like + await PageLikes.insert({ + id: genId(), + createdAt: new Date(), + pageId: page.id, + userId: user.id, + }); + + Pages.increment({ id: page.id }, 'likedCount', 1); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/show.ts b/packages/backend/src/server/api/endpoints/pages/show.ts index 7ca53d50d5..5d37e86b91 100644 --- a/packages/backend/src/server/api/endpoints/pages/show.ts +++ b/packages/backend/src/server/api/endpoints/pages/show.ts @@ -1,21 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { IsNull } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, PagesRepository } from '@/models/_.js'; -import type { MiPage } from '@/models/Page.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { PageEntityService } from '@/core/entities/PageEntityService.js'; -import { DI } from '@/di-symbols.js'; +import { Pages, Users } from '@/models/index.js'; +import { Page } from '@/models/entities/page.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; export const meta = { tags: ['pages'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'object', @@ -34,51 +26,45 @@ export const meta = { export const paramDef = { type: 'object', - properties: { - pageId: { type: 'string', format: 'misskey:id' }, - name: { type: 'string' }, - username: { type: 'string' }, - }, anyOf: [ - { required: ['pageId'] }, - { required: ['name', 'username'] }, + { + properties: { + pageId: { type: 'string', format: 'misskey:id' }, + }, + required: ['pageId'], + }, + { + properties: { + name: { type: 'string' }, + username: { type: 'string' }, + }, + required: ['name', 'username'], + }, ], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + let page: Page | null = null; - @Inject(DI.pagesRepository) - private pagesRepository: PagesRepository, - - private pageEntityService: PageEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - let page: MiPage | null = null; - - if (ps.pageId) { - page = await this.pagesRepository.findOneBy({ id: ps.pageId }); - } else if (ps.name && ps.username) { - const author = await this.usersRepository.findOneBy({ - host: IsNull(), - usernameLower: ps.username.toLowerCase(), - }); - if (author) { - page = await this.pagesRepository.findOneBy({ - name: ps.name, - userId: author.id, - }); - } - } - - if (page == null) { - throw new ApiError(meta.errors.noSuchPage); - } - - return await this.pageEntityService.pack(page, me); + if (ps.pageId) { + page = await Pages.findOneBy({ id: ps.pageId }); + } else if (ps.name && ps.username) { + const author = await Users.findOneBy({ + host: IsNull(), + usernameLower: ps.username.toLowerCase(), }); + if (author) { + page = await Pages.findOneBy({ + name: ps.name, + userId: author.id, + }); + } } -} + + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + return await Pages.pack(page, user); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/unlike.ts b/packages/backend/src/server/api/endpoints/pages/unlike.ts index 142eaa2752..6b3a2bec10 100644 --- a/packages/backend/src/server/api/endpoints/pages/unlike.ts +++ b/packages/backend/src/server/api/endpoints/pages/unlike.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { PagesRepository, PageLikesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; +import { Pages, PageLikes } from '@/models/index.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; export const meta = { tags: ['pages'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:page-likes', @@ -41,34 +32,24 @@ export const paramDef = { required: ['pageId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.pagesRepository) - private pagesRepository: PagesRepository, - - @Inject(DI.pageLikesRepository) - private pageLikesRepository: PageLikesRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const page = await this.pagesRepository.findOneBy({ id: ps.pageId }); - if (page == null) { - throw new ApiError(meta.errors.noSuchPage); - } - - const exist = await this.pageLikesRepository.findOneBy({ - pageId: page.id, - userId: me.id, - }); - - if (exist == null) { - throw new ApiError(meta.errors.notLiked); - } - - // Delete like - await this.pageLikesRepository.delete(exist.id); - - this.pagesRepository.decrement({ id: page.id }, 'likedCount', 1); - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const page = await Pages.findOneBy({ id: ps.pageId }); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); } -} + + const exist = await PageLikes.findOneBy({ + pageId: page.id, + userId: user.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notLiked); + } + + // Delete like + await PageLikes.delete(exist.id); + + Pages.decrement({ id: page.id }, 'likedCount', 1); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts index f4ad0e4908..d241f585aa 100644 --- a/packages/backend/src/server/api/endpoints/pages/update.ts +++ b/packages/backend/src/server/api/endpoints/pages/update.ts @@ -1,22 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import ms from 'ms'; import { Not } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { PagesRepository, DriveFilesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; +import { Pages, DriveFiles } from '@/models/index.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; export const meta = { tags: ['pages'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:pages', @@ -73,67 +64,53 @@ export const paramDef = { required: ['pageId', 'title', 'name', 'content', 'variables', 'script'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.pagesRepository) - private pagesRepository: PagesRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const page = await this.pagesRepository.findOneBy({ id: ps.pageId }); - if (page == null) { - throw new ApiError(meta.errors.noSuchPage); - } - if (page.userId !== me.id) { - throw new ApiError(meta.errors.accessDenied); - } - - let eyeCatchingImage = null; - if (ps.eyeCatchingImageId != null) { - eyeCatchingImage = await this.driveFilesRepository.findOneBy({ - id: ps.eyeCatchingImageId, - userId: me.id, - }); - - if (eyeCatchingImage == null) { - throw new ApiError(meta.errors.noSuchFile); - } - } - - await this.pagesRepository.findBy({ - id: Not(ps.pageId), - userId: me.id, - name: ps.name, - }).then(result => { - if (result.length > 0) { - throw new ApiError(meta.errors.nameAlreadyExists); - } - }); - - await this.pagesRepository.update(page.id, { - updatedAt: new Date(), - title: ps.title, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - name: ps.name === undefined ? page.name : ps.name, - summary: ps.summary === undefined ? page.summary : ps.summary, - content: ps.content, - variables: ps.variables, - script: ps.script, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - alignCenter: ps.alignCenter === undefined ? page.alignCenter : ps.alignCenter, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - hideTitleWhenPinned: ps.hideTitleWhenPinned === undefined ? page.hideTitleWhenPinned : ps.hideTitleWhenPinned, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - font: ps.font === undefined ? page.font : ps.font, - eyeCatchingImageId: ps.eyeCatchingImageId === null - ? null - : ps.eyeCatchingImageId === undefined - ? page.eyeCatchingImageId - : eyeCatchingImage!.id, - }); - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const page = await Pages.findOneBy({ id: ps.pageId }); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); } -} + if (page.userId !== user.id) { + throw new ApiError(meta.errors.accessDenied); + } + + let eyeCatchingImage = null; + if (ps.eyeCatchingImageId != null) { + eyeCatchingImage = await DriveFiles.findOneBy({ + id: ps.eyeCatchingImageId, + userId: user.id, + }); + + if (eyeCatchingImage == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + await Pages.findBy({ + id: Not(ps.pageId), + userId: user.id, + name: ps.name, + }).then(result => { + if (result.length > 0) { + throw new ApiError(meta.errors.nameAlreadyExists); + } + }); + + await Pages.update(page.id, { + updatedAt: new Date(), + title: ps.title, + name: ps.name === undefined ? page.name : ps.name, + summary: ps.name === undefined ? page.summary : ps.summary, + content: ps.content, + variables: ps.variables, + script: ps.script, + alignCenter: ps.alignCenter === undefined ? page.alignCenter : ps.alignCenter, + hideTitleWhenPinned: ps.hideTitleWhenPinned === undefined ? page.hideTitleWhenPinned : ps.hideTitleWhenPinned, + font: ps.font === undefined ? page.font : ps.font, + eyeCatchingImageId: ps.eyeCatchingImageId === null + ? null + : ps.eyeCatchingImageId === undefined + ? page.eyeCatchingImageId + : eyeCatchingImage!.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/ping.ts b/packages/backend/src/server/api/endpoints/ping.ts index 0f1d821dbc..2891a0860a 100644 --- a/packages/backend/src/server/api/endpoints/ping.ts +++ b/packages/backend/src/server/api/endpoints/ping.ts @@ -1,13 +1,7 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; +import define from '../define.js'; export const meta = { - requireCredential: 'never', + requireCredential: false, tags: ['meta'], @@ -29,14 +23,9 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - ) { - super(meta, paramDef, async () => { - return { - pong: Date.now(), - }; - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async () => { + return { + pong: Date.now(), + }; +}); diff --git a/packages/backend/src/server/api/endpoints/pinned-users.ts b/packages/backend/src/server/api/endpoints/pinned-users.ts index 47c977635c..41595b47d9 100644 --- a/packages/backend/src/server/api/endpoints/pinned-users.ts +++ b/packages/backend/src/server/api/endpoints/pinned-users.ts @@ -1,22 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { IsNull } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; +import { Users } from '@/models/index.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; import * as Acct from '@/misc/acct.js'; -import type { MiUser } from '@/models/User.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { MetaService } from '@/core/MetaService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DI } from '@/di-symbols.js'; +import { User } from '@/models/entities/user.js'; +import define from '../define.js'; export const meta = { tags: ['users'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'array', @@ -35,24 +27,14 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const meta = await fetchMeta(); - private metaService: MetaService, - private userEntityService: UserEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const meta = await this.metaService.fetch(); + const users = await Promise.all(meta.pinnedUsers.map(acct => Acct.parse(acct)).map(acct => Users.findOneBy({ + usernameLower: acct.username.toLowerCase(), + host: acct.host ?? IsNull(), + }))); - const users = await Promise.all(meta.pinnedUsers.map(acct => Acct.parse(acct)).map(acct => this.usersRepository.findOneBy({ - usernameLower: acct.username.toLowerCase(), - host: acct.host ?? IsNull(), - }))); - - return await this.userEntityService.packMany(users.filter(x => x !== null) as MiUser[], me, { detail: true }); - }); - } -} + return await Users.packMany(users.filter(x => x !== undefined) as User[], me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/promo/read.ts b/packages/backend/src/server/api/endpoints/promo/read.ts index 9e770d359a..c6a940c65e 100644 --- a/packages/backend/src/server/api/endpoints/promo/read.ts +++ b/packages/backend/src/server/api/endpoints/promo/read.ts @@ -1,21 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { PromoReadsRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; +import { PromoReads } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { getNote } from '../../common/getters.js'; export const meta = { tags: ['notes'], - requireCredential: 'always', - kind: 'write:account', + requireCredential: true, errors: { noSuchNote: { @@ -34,37 +26,26 @@ export const paramDef = { required: ['noteId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.promoReadsRepository) - private promoReadsRepository: PromoReadsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); - private idService: IdService, - private getterService: GetterService, - ) { - super(meta, paramDef, async (ps, me) => { - const note = await this.getterService.getNote(ps.noteId).catch(err => { - if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); - throw err; - }); + const exist = await PromoReads.findOneBy({ + noteId: note.id, + userId: user.id, + }); - const exist = await this.promoReadsRepository.exist({ - where: { - noteId: note.id, - userId: me.id, - }, - }); - - if (exist) { - return; - } - - await this.promoReadsRepository.insert({ - id: this.idService.gen(), - noteId: note.id, - userId: me.id, - }); - }); + if (exist != null) { + return; } -} + + await PromoReads.insert({ + id: genId(), + createdAt: new Date(), + noteId: note.id, + userId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts deleted file mode 100644 index d0379977b3..0000000000 --- a/packages/backend/src/server/api/endpoints/renote-mute/create.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import ms from 'ms'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { IdService } from '@/core/IdService.js'; -import type { RenoteMutingsRepository } from '@/models/_.js'; -import type { MiRenoteMuting } from '@/models/RenoteMuting.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['account'], - - requireCredential: 'always', - prohibitMoved: true, - - kind: 'write:mutes', - - limit: { - duration: ms('1hour'), - max: 20, - }, - - errors: { - noSuchUser: { - message: 'No such user.', - code: 'NO_SUCH_USER', - id: '5e0a5dff-1e94-4202-87ae-4d9c89eb2271', - }, - - muteeIsYourself: { - message: 'Mutee is yourself.', - code: 'MUTEE_IS_YOURSELF', - id: '37285718-52f7-4aef-b7de-c38b8e8a8420', - }, - - alreadyMuting: { - message: 'You are already muting that user.', - code: 'ALREADY_MUTING', - id: 'ccfecbe4-1f1c-4fc2-8a3d-c3ffee61cb7b', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.renoteMutingsRepository) - private renoteMutingsRepository: RenoteMutingsRepository, - - private getterService: GetterService, - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const muter = me; - - // 自分自身 - if (me.id === ps.userId) { - throw new ApiError(meta.errors.muteeIsYourself); - } - - // Get mutee - const mutee = await getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); - - // Check if already muting - const exist = await this.renoteMutingsRepository.findOneBy({ - muterId: muter.id, - muteeId: mutee.id, - }); - - if (exist != null) { - throw new ApiError(meta.errors.alreadyMuting); - } - - // Create mute - await this.renoteMutingsRepository.insert({ - id: this.idService.gen(), - muterId: muter.id, - muteeId: mutee.id, - } as MiRenoteMuting); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts deleted file mode 100644 index acc16ae24c..0000000000 --- a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RenoteMutingsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['account'], - - requireCredential: 'always', - - kind: 'write:mutes', - - errors: { - noSuchUser: { - message: 'No such user.', - code: 'NO_SUCH_USER', - id: '9b6728cf-638c-4aa1-bedb-e07d8101474d', - }, - - muteeIsYourself: { - message: 'Mutee is yourself.', - code: 'MUTEE_IS_YOURSELF', - id: '619b1314-0850-4597-a242-e245f3da42af', - }, - - notMuting: { - message: 'You are not muting that user.', - code: 'NOT_MUTING', - id: '2e4ef874-8bf0-4b4b-b069-4598f6d05817', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.renoteMutingsRepository) - private renoteMutingsRepository: RenoteMutingsRepository, - - private getterService: GetterService, - ) { - super(meta, paramDef, async (ps, me) => { - const muter = me; - - // Check if the mutee is yourself - if (me.id === ps.userId) { - throw new ApiError(meta.errors.muteeIsYourself); - } - - // Get mutee - const mutee = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); - - // Check not muting - const exist = await this.renoteMutingsRepository.findOneBy({ - muterId: muter.id, - muteeId: mutee.id, - }); - - if (exist == null) { - throw new ApiError(meta.errors.notMuting); - } - - // Delete mute - await this.renoteMutingsRepository.delete({ - id: exist.id, - }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/renote-mute/list.ts b/packages/backend/src/server/api/endpoints/renote-mute/list.ts deleted file mode 100644 index 00a71f094c..0000000000 --- a/packages/backend/src/server/api/endpoints/renote-mute/list.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RenoteMutingsRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { RenoteMutingEntityService } from '@/core/entities/RenoteMutingEntityService.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['account'], - - requireCredential: 'always', - - kind: 'read:mutes', - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'RenoteMuting', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - }, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.renoteMutingsRepository) - private renoteMutingsRepository: RenoteMutingsRepository, - - private renoteMutingEntityService: RenoteMutingEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.renoteMutingsRepository.createQueryBuilder('muting'), ps.sinceId, ps.untilId) - .andWhere('muting.muterId = :meId', { meId: me.id }); - - const mutings = await query - .limit(ps.limit) - .getMany(); - - return await this.renoteMutingEntityService.packMany(mutings, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/request-reset-password.ts b/packages/backend/src/server/api/endpoints/request-reset-password.ts index d0019f541a..511a6bbb53 100644 --- a/packages/backend/src/server/api/endpoints/request-reset-password.ts +++ b/packages/backend/src/server/api/endpoints/request-reset-password.ts @@ -1,23 +1,18 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - +import rndstr from 'rndstr'; import ms from 'ms'; import { IsNull } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { PasswordResetRequestsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { IdService } from '@/core/IdService.js'; -import type { Config } from '@/config.js'; -import { DI } from '@/di-symbols.js'; -import { EmailService } from '@/core/EmailService.js'; -import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js'; +import { publishMainStream } from '@/services/stream.js'; +import config from '@/config/index.js'; +import { Users, UserProfiles, PasswordResetRequests } from '@/models/index.js'; +import { sendEmail } from '@/services/send-email.js'; +import { genId } from '@/misc/gen-id.js'; +import { ApiError } from '../error.js'; +import define from '../define.js'; export const meta = { tags: ['reset password'], - requireCredential: 'never', + requireCredential: false, description: 'Request a users password to be reset.', @@ -40,60 +35,42 @@ export const paramDef = { required: ['username', 'email'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.config) - private config: Config, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const user = await Users.findOneBy({ + usernameLower: ps.username.toLowerCase(), + host: IsNull(), + }); - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.passwordResetRequestsRepository) - private passwordResetRequestsRepository: PasswordResetRequestsRepository, - - private idService: IdService, - private emailService: EmailService, - ) { - super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ - usernameLower: ps.username.toLowerCase(), - host: IsNull(), - }); - - // 合致するユーザーが登録されていなかったら無視 - if (user == null) { - return; - } - - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - - // 合致するメアドが登録されていなかったら無視 - if (profile.email !== ps.email) { - return; - } - - // メアドが認証されていなかったら無視 - if (!profile.emailVerified) { - return; - } - - const token = secureRndstr(64, { chars: L_CHARS }); - - await this.passwordResetRequestsRepository.insert({ - id: this.idService.gen(), - userId: profile.userId, - token, - }); - - const link = `${this.config.url}/reset-password/${token}`; - - this.emailService.sendEmail(ps.email, 'Password reset requested', - `To reset password, please click this link:
${link}`, - `To reset password, please click this link: ${link}`); - }); + // 合致するユーザーが登録されていなかったら無視 + if (user == null) { + return; } -} + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + // 合致するメアドが登録されていなかったら無視 + if (profile.email !== ps.email) { + return; + } + + // メアドが認証されていなかったら無視 + if (!profile.emailVerified) { + return; + } + + const token = rndstr('a-z0-9', 64); + + await PasswordResetRequests.insert({ + id: genId(), + createdAt: new Date(), + userId: profile.userId, + token, + }); + + const link = `${config.url}/reset-password/${token}`; + + sendEmail(ps.email, 'Password reset requested', + `To reset password, please click this link:
${link}`, + `To reset password, please click this link: ${link}`); +}); diff --git a/packages/backend/src/server/api/endpoints/reset-db.ts b/packages/backend/src/server/api/endpoints/reset-db.ts index 3eb88fa38b..140f96d579 100644 --- a/packages/backend/src/server/api/endpoints/reset-db.ts +++ b/packages/backend/src/server/api/endpoints/reset-db.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import * as Redis from 'ioredis'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { resetDb } from '@/misc/reset-db.js'; +import { resetDb } from '@/db/postgre.js'; +import define from '../define.js'; +import { ApiError } from '../error.js'; export const meta = { tags: ['non-productive'], - requireCredential: 'never', + requireCredential: false, description: 'Only available when running with NODE_ENV=testing. Reset the database and flush Redis.', @@ -28,22 +20,11 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.db) - private db: DataSource, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test'; - @Inject(DI.redis) - private redisClient: Redis.Redis, - ) { - super(meta, paramDef, async (ps, me) => { - if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV is not a test'); + await resetDb(); - await redisClient.flushdb(); - await resetDb(this.db); - - await new Promise(resolve => setTimeout(resolve, 1000)); - }); - } -} + await new Promise(resolve => setTimeout(resolve, 1000)); +}); diff --git a/packages/backend/src/server/api/endpoints/reset-password.ts b/packages/backend/src/server/api/endpoints/reset-password.ts index 3a2faee785..797169c2c3 100644 --- a/packages/backend/src/server/api/endpoints/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/reset-password.ts @@ -1,20 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -//import bcrypt from 'bcryptjs'; -import * as argon2 from 'argon2'; -import { Inject, Injectable } from '@nestjs/common'; -import type { UserProfilesRepository, PasswordResetRequestsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { IdService } from '@/core/IdService.js'; +import bcrypt from 'bcryptjs'; +import { publishMainStream } from '@/services/stream.js'; +import { Users, UserProfiles, PasswordResetRequests } from '@/models/index.js'; +import define from '../define.js'; +import { ApiError } from '../error.js'; export const meta = { tags: ['reset password'], - requireCredential: 'never', + requireCredential: false, description: 'Complete the password reset that was previously requested.', @@ -32,36 +25,24 @@ export const paramDef = { required: ['token', 'password'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.passwordResetRequestsRepository) - private passwordResetRequestsRepository: PasswordResetRequestsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const req = await PasswordResetRequests.findOneByOrFail({ + token: ps.token, + }); - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const req = await this.passwordResetRequestsRepository.findOneByOrFail({ - token: ps.token, - }); - - // 発行してから30分以上経過していたら無効 - if (Date.now() - this.idService.parse(req.id).date.getTime() > 1000 * 60 * 30) { - throw new Error(); // TODO - } - - // Generate hash of password - //const salt = await bcrypt.genSalt(8); - const hash = await argon2.hash(ps.password); - - await this.userProfilesRepository.update(req.userId, { - password: hash, - }); - - this.passwordResetRequestsRepository.delete(req.id); - }); + // 発行してから30分以上経過していたら無効 + if (Date.now() - req.createdAt.getTime() > 1000 * 60 * 30) { + throw new Error(); // TODO } -} + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(ps.password, salt); + + await UserProfiles.update(req.userId, { + password: hash, + }); + + PasswordResetRequests.delete(req.id); +}); diff --git a/packages/backend/src/server/api/endpoints/retention.ts b/packages/backend/src/server/api/endpoints/retention.ts deleted file mode 100644 index 866fbd7573..0000000000 --- a/packages/backend/src/server/api/endpoints/retention.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { RetentionAggregationsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['users'], - - requireCredential: 'conditional', - - res: { - }, - - allowGet: true, - cacheSec: 60 * 60, -} as const; - -export const paramDef = { - type: 'object', - properties: {}, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.retentionAggregationsRepository) - private retentionAggregationsRepository: RetentionAggregationsRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const records = await this.retentionAggregationsRepository.find({ - order: { - id: 'DESC', - }, - take: 30, - }); - - return records.map(record => ({ - createdAt: record.createdAt.toISOString(), - users: record.usersCount, - data: record.data, - })); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/roles/list.ts b/packages/backend/src/server/api/endpoints/roles/list.ts deleted file mode 100644 index 7e50211a01..0000000000 --- a/packages/backend/src/server/api/endpoints/roles/list.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RolesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; - -export const meta = { - tags: ['role'], - - requireCredential: 'always', - kind: 'read:account', - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'Role', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - }, - required: [ - ], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.rolesRepository) - private rolesRepository: RolesRepository, - - private roleEntityService: RoleEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const roles = await this.rolesRepository.findBy({ - isPublic: true, - isExplorable: true, - }); - return await this.roleEntityService.packMany(roles, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts deleted file mode 100644 index 822d8995de..0000000000 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { NotesRepository, RolesRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { DI } from '@/di-symbols.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { IdService } from '@/core/IdService.js'; -import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['role', 'notes'], - - requireCredential: 'always', - kind: 'read:account', - - errors: { - noSuchRole: { - message: 'No such role.', - code: 'NO_SUCH_ROLE', - id: 'eb70323a-df61-4dd4-ad90-89c83c7cf26e', - }, - }, - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'Note', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - roleId: { type: 'string', format: 'misskey:id' }, - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - sinceDate: { type: 'integer' }, - untilDate: { type: 'integer' }, - }, - required: ['roleId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.rolesRepository) - private rolesRepository: RolesRepository, - - private idService: IdService, - private noteEntityService: NoteEntityService, - private queryService: QueryService, - private fanoutTimelineService: FanoutTimelineService, - ) { - super(meta, paramDef, async (ps, me) => { - const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); - const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); - - const role = await this.rolesRepository.findOneBy({ - id: ps.roleId, - isPublic: true, - }); - - if (role == null) { - throw new ApiError(meta.errors.noSuchRole); - } - if (!role.isExplorable) { - return []; - } - - let noteIds = await this.fanoutTimelineService.get(`roleTimeline:${role.id}`, untilId, sinceId); - noteIds = noteIds.slice(0, ps.limit); - - if (noteIds.length === 0) { - return []; - } - - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .andWhere('(note.visibility = \'public\')') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - - const notes = await query.getMany(); - notes.sort((a, b) => a.id > b.id ? -1 : 1); - - return await this.noteEntityService.packMany(notes, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/roles/show.ts b/packages/backend/src/server/api/endpoints/roles/show.ts deleted file mode 100644 index 8967a998ff..0000000000 --- a/packages/backend/src/server/api/endpoints/roles/show.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { RolesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['role', 'users'], - - requireCredential: 'conditional', - - errors: { - noSuchRole: { - message: 'No such role.', - code: 'NO_SUCH_ROLE', - id: 'de5502bf-009a-4639-86c1-fec349e46dcb', - }, - }, - - res: { - type: 'object', - optional: false, nullable: false, - ref: 'Role', - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - roleId: { type: 'string', format: 'misskey:id' }, - }, - required: ['roleId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.rolesRepository) - private rolesRepository: RolesRepository, - - private roleEntityService: RoleEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const role = await this.rolesRepository.findOneBy({ - id: ps.roleId, - isPublic: true, - }); - - if (role == null) { - throw new ApiError(meta.errors.noSuchRole); - } - - return await this.roleEntityService.pack(role, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/roles/users.ts b/packages/backend/src/server/api/endpoints/roles/users.ts deleted file mode 100644 index 6b932ababa..0000000000 --- a/packages/backend/src/server/api/endpoints/roles/users.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Brackets } from 'typeorm'; -import type { RoleAssignmentsRepository, RolesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { DI } from '@/di-symbols.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['role', 'users'], - - requireCredential: 'conditional', - - errors: { - noSuchRole: { - message: 'No such role.', - code: 'NO_SUCH_ROLE', - id: '30aaaee3-4792-48dc-ab0d-cf501a575ac5', - }, - }, - - res: { - type: 'array', - items: { - type: 'object', - nullable: false, - properties: { - id: { - type: 'string', - format: 'misskey:id' - }, - user: { - type: 'object', - ref: 'User' - }, - }, - required: ['id', 'user'], - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - roleId: { type: 'string', format: 'misskey:id' }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - }, - required: ['roleId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.rolesRepository) - private rolesRepository: RolesRepository, - - @Inject(DI.roleAssignmentsRepository) - private roleAssignmentsRepository: RoleAssignmentsRepository, - - private queryService: QueryService, - private userEntityService: UserEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const role = await this.rolesRepository.findOneBy({ - id: ps.roleId, - isPublic: true, - isExplorable: true, - }); - - if (role == null) { - throw new ApiError(meta.errors.noSuchRole); - } - - const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId) - .andWhere('assign.roleId = :roleId', { roleId: role.id }) - .andWhere(new Brackets(qb => { - qb - .where('assign.expiresAt IS NULL') - .orWhere('assign.expiresAt > :now', { now: new Date() }); - })) - .innerJoinAndSelect('assign.user', 'user'); - - const assigns = await query - .limit(ps.limit) - .getMany(); - - return await Promise.all(assigns.map(async assign => ({ - id: assign.id, - user: await this.userEntityService.pack(assign.user!, me, { detail: true }), - }))); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/server-info.ts b/packages/backend/src/server/api/endpoints/server-info.ts index c84a5dee91..99f3730e97 100644 --- a/packages/backend/src/server/api/endpoints/server-info.ts +++ b/packages/backend/src/server/api/endpoints/server-info.ts @@ -1,67 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import * as os from 'node:os'; import si from 'systeminformation'; -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { MetaService } from '@/core/MetaService.js'; +import define from '../define.js'; export const meta = { - requireCredential: 'never', - allowGet: true, - cacheSec: 60 * 1, + requireCredential: false, tags: ['meta'], - res: { - type: 'object', - optional: false, nullable: false, - properties: { - machine: { - type: 'string', - nullable: false, - }, - cpu: { - type: 'object', - nullable: false, - properties: { - model: { - type: 'string', - nullable: false, - }, - cores: { - type: 'number', - nullable: false, - }, - }, - }, - mem: { - type: 'object', - properties: { - total: { - type: 'number', - nullable: false, - }, - }, - }, - fs: { - type: 'object', - nullable: false, - properties: { - total: { - type: 'number', - nullable: false, - }, - used: { - type: 'number', - nullable: false, - }, - }, - }, - }, - }, } as const; export const paramDef = { @@ -70,44 +14,23 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private metaService: MetaService, - ) { - super(meta, paramDef, async () => { - if (!(await this.metaService.fetch()).enableServerMachineStats) return { - machine: '?', - cpu: { - model: '?', - cores: 0, - }, - mem: { - total: 0, - }, - fs: { - total: 0, - used: 0, - }, - }; +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async () => { + const memStats = await si.mem(); + const fsStats = await si.fsSize(); - const memStats = await si.mem(); - const fsStats = await si.fsSize(); - - return { - machine: os.hostname(), - cpu: { - model: os.cpus()[0].model, - cores: os.cpus().length, - }, - mem: { - total: memStats.total, - }, - fs: { - total: fsStats[0].size, - used: fsStats[0].used, - }, - }; - }); - } -} + return { + machine: os.hostname(), + cpu: { + model: os.cpus()[0].model, + cores: os.cpus().length, + }, + mem: { + total: memStats.total, + }, + fs: { + total: fsStats[0].size, + used: fsStats[0].used, + }, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/sponsors.ts b/packages/backend/src/server/api/endpoints/sponsors.ts deleted file mode 100644 index 0b444c3891..0000000000 --- a/packages/backend/src/server/api/endpoints/sponsors.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import * as Redis from 'ioredis'; - -export const meta = { - tags: ["meta"], - description: "Get Sharkey GH Sponsors", - - requireCredential: 'never', - requireCredentialPrivateMode: false, -} as const; - -export const paramDef = { - type: "object", - properties: { - forceUpdate: { type: "boolean", default: false }, - }, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.redis) private redisClient: Redis.Redis, - ) { - super(meta, paramDef, async (ps, me) => { - let sponsors; - const cachedSponsors = await this.redisClient.get("sponsors"); - if (!ps.forceUpdate && cachedSponsors) { - sponsors = JSON.parse(cachedSponsors); - } else { - AbortSignal.timeout ??= function timeout(ms) { - const ctrl = new AbortController(); - setTimeout(() => ctrl.abort(), ms); - return ctrl.signal; - }; - - sponsors = await fetch("https://kaifa.ch/transfem-sponsors.json", { signal: AbortSignal.timeout(2000) }) - .then((response) => response.json()); - await this.redisClient.set("sponsors", JSON.stringify(sponsors), "EX", 3600); - } - return { sponsor_data: sponsors['sponsors'] }; - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/stats.ts b/packages/backend/src/server/api/endpoints/stats.ts index e5c36aedb8..cc94f8bf26 100644 --- a/packages/backend/src/server/api/endpoints/stats.ts +++ b/packages/backend/src/server/api/endpoints/stats.ts @@ -1,17 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { InstancesRepository, NoteReactionsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import NotesChart from '@/core/chart/charts/notes.js'; -import UsersChart from '@/core/chart/charts/users.js'; +import { Instances, NoteReactions, Notes, Users } from '@/models/index.js'; +import define from '../define.js'; +import { } from '@/services/chart/index.js'; +import { IsNull } from 'typeorm'; export const meta = { - requireCredential: 'never', + requireCredential: false, tags: ['meta'], @@ -57,48 +50,35 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async () => { + const [ + notesCount, + originalNotesCount, + usersCount, + originalUsersCount, + reactionsCount, + //originalReactionsCount, + instances, + ] = await Promise.all([ + Notes.count({ cache: 3600000 }), // 1 hour + Notes.count({ where: { userHost: IsNull() }, cache: 3600000 }), + Users.count({ cache: 3600000 }), + Users.count({ where: { host: IsNull() }, cache: 3600000 }), + NoteReactions.count({ cache: 3600000 }), // 1 hour + //NoteReactions.count({ where: { userHost: IsNull() }, cache: 3600000 }), + Instances.count({ cache: 3600000 }), + ]); - @Inject(DI.noteReactionsRepository) - private noteReactionsRepository: NoteReactionsRepository, - - private notesChart: NotesChart, - private usersChart: UsersChart, - ) { - super(meta, paramDef, async () => { - const notesChart = await this.notesChart.getChart('hour', 1, null); - const notesCount = notesChart.local.total[0] + notesChart.remote.total[0]; - const originalNotesCount = notesChart.local.total[0]; - - const usersChart = await this.usersChart.getChart('hour', 1, null); - const usersCount = usersChart.local.total[0] + usersChart.remote.total[0]; - const originalUsersCount = usersChart.local.total[0]; - - const [ - reactionsCount, - //originalReactionsCount, - instances, - ] = await Promise.all([ - this.noteReactionsRepository.count({ cache: 3600000 }), // 1 hour - //this.noteReactionsRepository.count({ where: { userHost: IsNull() }, cache: 3600000 }), - this.instancesRepository.count({ cache: 3600000 }), - ]); - - return { - notesCount, - originalNotesCount, - usersCount, - originalUsersCount, - reactionsCount, - //originalReactionsCount, - instances, - driveUsageLocal: 0, - driveUsageRemote: 0, - }; - }); - } -} + return { + notesCount, + originalNotesCount, + usersCount, + originalUsersCount, + reactionsCount, + //originalReactionsCount, + instances, + driveUsageLocal: 0, + driveUsageRemote: 0, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/sw/register.ts b/packages/backend/src/server/api/endpoints/sw/register.ts index 06df71a4ed..437f8874ff 100644 --- a/packages/backend/src/server/api/endpoints/sw/register.ts +++ b/packages/backend/src/server/api/endpoints/sw/register.ts @@ -1,20 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { IdService } from '@/core/IdService.js'; -import type { SwSubscriptionsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { MetaService } from '@/core/MetaService.js'; -import { DI } from '@/di-symbols.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { genId } from '@/misc/gen-id.js'; +import { SwSubscriptions } from '@/models/index.js'; +import define from '../../define.js'; export const meta = { tags: ['account'], - requireCredential: 'always', - secure: true, + requireCredential: true, description: 'Register to receive push notifications.', @@ -31,18 +23,6 @@ export const meta = { type: 'string', optional: false, nullable: true, }, - userId: { - type: 'string', - optional: false, nullable: false, - }, - endpoint: { - type: 'string', - optional: false, nullable: false, - }, - sendReadMessage: { - type: 'boolean', - optional: false, nullable: false, - }, }, }, } as const; @@ -53,57 +33,40 @@ export const paramDef = { endpoint: { type: 'string' }, auth: { type: 'string' }, publickey: { type: 'string' }, - sendReadMessage: { type: 'boolean', default: false }, }, required: ['endpoint', 'auth', 'publickey'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.swSubscriptionsRepository) - private swSubscriptionsRepository: SwSubscriptionsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // if already subscribed + const exist = await SwSubscriptions.findOneBy({ + userId: user.id, + endpoint: ps.endpoint, + auth: ps.auth, + publickey: ps.publickey, + }); - private idService: IdService, - private metaService: MetaService, - ) { - super(meta, paramDef, async (ps, me) => { - // if already subscribed - const exist = await this.swSubscriptionsRepository.findOneBy({ - userId: me.id, - endpoint: ps.endpoint, - auth: ps.auth, - publickey: ps.publickey, - }); + const instance = await fetchMeta(true); - const instance = await this.metaService.fetch(true); - - if (exist != null) { - return { - state: 'already-subscribed' as const, - key: instance.swPublicKey, - userId: me.id, - endpoint: exist.endpoint, - sendReadMessage: exist.sendReadMessage, - }; - } - - await this.swSubscriptionsRepository.insert({ - id: this.idService.gen(), - userId: me.id, - endpoint: ps.endpoint, - auth: ps.auth, - publickey: ps.publickey, - sendReadMessage: ps.sendReadMessage, - }); - - return { - state: 'subscribed' as const, - key: instance.swPublicKey, - userId: me.id, - endpoint: ps.endpoint, - sendReadMessage: ps.sendReadMessage, - }; - }); + if (exist != null) { + return { + state: 'already-subscribed' as const, + key: instance.swPublicKey, + }; } -} + + await SwSubscriptions.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + endpoint: ps.endpoint, + auth: ps.auth, + publickey: ps.publickey, + }); + + return { + state: 'subscribed' as const, + key: instance.swPublicKey, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/sw/show-registration.ts b/packages/backend/src/server/api/endpoints/sw/show-registration.ts deleted file mode 100644 index 53bd44a4a9..0000000000 --- a/packages/backend/src/server/api/endpoints/sw/show-registration.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { SwSubscriptionsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['account'], - - requireCredential: 'always', - secure: true, - - description: 'Check push notification registration exists.', - - res: { - type: 'object', - optional: false, nullable: true, - properties: { - userId: { - type: 'string', - optional: false, nullable: false, - }, - endpoint: { - type: 'string', - optional: false, nullable: false, - }, - sendReadMessage: { - type: 'boolean', - optional: false, nullable: false, - }, - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - endpoint: { type: 'string' }, - }, - required: ['endpoint'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.swSubscriptionsRepository) - private swSubscriptionsRepository: SwSubscriptionsRepository, - ) { - super(meta, paramDef, async (ps, me) => { - // if already subscribed - const exist = await this.swSubscriptionsRepository.findOneBy({ - userId: me.id, - endpoint: ps.endpoint, - }); - - if (exist != null) { - return { - userId: exist.userId, - endpoint: exist.endpoint, - sendReadMessage: exist.sendReadMessage, - }; - } - - return null; - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/sw/unregister.ts b/packages/backend/src/server/api/endpoints/sw/unregister.ts index 1d65ded77a..c19e06b879 100644 --- a/packages/backend/src/server/api/endpoints/sw/unregister.ts +++ b/packages/backend/src/server/api/endpoints/sw/unregister.ts @@ -1,17 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { SwSubscriptionsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; +import { SwSubscriptions } from '@/models/index.js'; +import define from '../../define.js'; export const meta = { tags: ['account'], - requireCredential: 'never', + requireCredential: true, description: 'Unregister from receiving push notifications.', } as const; @@ -24,17 +17,10 @@ export const paramDef = { required: ['endpoint'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.swSubscriptionsRepository) - private swSubscriptionsRepository: SwSubscriptionsRepository, - ) { - super(meta, paramDef, async (ps, me) => { - await this.swSubscriptionsRepository.delete({ - ...(me ? { userId: me.id } : {}), - endpoint: ps.endpoint, - }); - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + await SwSubscriptions.delete({ + userId: user.id, + endpoint: ps.endpoint, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/sw/update-registration.ts b/packages/backend/src/server/api/endpoints/sw/update-registration.ts deleted file mode 100644 index 796d1e7e19..0000000000 --- a/packages/backend/src/server/api/endpoints/sw/update-registration.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { SwSubscriptionsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['account'], - - requireCredential: 'always', - secure: true, - - description: 'Update push notification registration.', - - res: { - type: 'object', - optional: false, nullable: false, - properties: { - userId: { - type: 'string', - optional: false, nullable: false, - }, - endpoint: { - type: 'string', - optional: false, nullable: false, - }, - sendReadMessage: { - type: 'boolean', - optional: false, nullable: false, - }, - }, - }, - errors: { - noSuchRegistration: { - message: 'No such registration.', - code: 'NO_SUCH_REGISTRATION', - id: ' b09d8066-8064-5613-efb6-0e963b21d012', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - endpoint: { type: 'string' }, - sendReadMessage: { type: 'boolean' }, - }, - required: ['endpoint'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.swSubscriptionsRepository) - private swSubscriptionsRepository: SwSubscriptionsRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const swSubscription = await this.swSubscriptionsRepository.findOneBy({ - userId: me.id, - endpoint: ps.endpoint, - }); - - if (swSubscription === null) { - throw new ApiError(meta.errors.noSuchRegistration); - } - - if (ps.sendReadMessage !== undefined) { - swSubscription.sendReadMessage = ps.sendReadMessage; - } - - await this.swSubscriptionsRepository.update(swSubscription.id, { - sendReadMessage: swSubscription.sendReadMessage, - }); - - return { - userId: swSubscription.userId, - endpoint: swSubscription.endpoint, - sendReadMessage: swSubscription.sendReadMessage, - }; - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/test.ts b/packages/backend/src/server/api/endpoints/test.ts index d061b459e5..9949237a7e 100644 --- a/packages/backend/src/server/api/endpoints/test.ts +++ b/packages/backend/src/server/api/endpoints/test.ts @@ -1,41 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; +import define from '../define.js'; export const meta = { tags: ['non-productive'], description: 'Endpoint for testing input validation.', - requireCredential: 'never', - - res: { - type: 'object', - properties: { - id: { - type: 'string', - format: 'misskey:id' - }, - required: { - type: 'boolean', - }, - string: { - type: 'string', - }, - default: { - type: 'string', - }, - nullableDefault: { - type: 'string', - default: 'hello', - nullable: true, - }, - } - } + requireCredential: false, } as const; export const paramDef = { @@ -50,12 +20,7 @@ export const paramDef = { required: ['required'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - ) { - super(meta, paramDef, async (ps, me) => { - return ps; - }); - } -} +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + return ps; +}); diff --git a/packages/backend/src/server/api/endpoints/username/available.ts b/packages/backend/src/server/api/endpoints/username/available.ts index 4df4ab2693..3e41aeaed8 100644 --- a/packages/backend/src/server/api/endpoints/username/available.ts +++ b/packages/backend/src/server/api/endpoints/username/available.ts @@ -1,20 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { IsNull } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { UsedUsernamesRepository, UsersRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { localUsernameSchema } from '@/models/User.js'; -import { DI } from '@/di-symbols.js'; -import { MetaService } from '@/core/MetaService.js'; +import { Users, UsedUsernames } from '@/models/index.js'; +import define from '../../define.js'; export const meta = { tags: ['users'], - requireCredential: 'never', + requireCredential: false, res: { type: 'object', @@ -31,36 +22,22 @@ export const meta = { export const paramDef = { type: 'object', properties: { - username: localUsernameSchema, + username: Users.localUsernameSchema, }, required: ['username'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + // Get exist + const exist = await Users.countBy({ + host: IsNull(), + usernameLower: ps.username.toLowerCase(), + }); - @Inject(DI.usedUsernamesRepository) - private usedUsernamesRepository: UsedUsernamesRepository, + const exist2 = await UsedUsernames.countBy({ username: ps.username.toLowerCase() }); - private metaService: MetaService, - ) { - super(meta, paramDef, async (ps, me) => { - const exist = await this.usersRepository.countBy({ - host: IsNull(), - usernameLower: ps.username.toLowerCase(), - }); - - const exist2 = await this.usedUsernamesRepository.countBy({ username: ps.username.toLowerCase() }); - - const meta = await this.metaService.fetch(); - const isPreserved = meta.preservedUsernames.map(x => x.toLowerCase()).includes(ps.username.toLowerCase()); - - return { - available: exist === 0 && exist2 === 0 && !isPreserved, - }; - }); - } -} + return { + available: exist === 0 && exist2 === 0, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts index 98a38587e0..3a8211374b 100644 --- a/packages/backend/src/server/api/endpoints/users.ts +++ b/packages/backend/src/server/api/endpoints/users.ts @@ -1,19 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DI } from '@/di-symbols.js'; +import { Users } from '@/models/index.js'; +import define from '../define.js'; +import { generateMutedUserQueryForUsers } from '../common/generate-muted-user-query.js'; +import { generateBlockQueryForUsers } from '../common/generate-block-query.js'; export const meta = { tags: ['users'], - requireCredential: 'conditional', + requireCredential: false, res: { type: 'array', @@ -32,7 +25,7 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, offset: { type: 'integer', default: 0 }, sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] }, - state: { type: 'string', enum: ['all', 'alive'], default: 'all' }, + state: { type: 'string', enum: ['all', 'admin', 'moderator', 'adminOrModerator', 'alive'], default: 'all' }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, hostname: { type: 'string', @@ -44,52 +37,44 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = Users.createQueryBuilder('user'); + query.where('user.isExplorable = TRUE'); - private userEntityService: UserEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.usersRepository.createQueryBuilder('user') - .where('user.isExplorable = TRUE') - .andWhere('user.isSuspended = FALSE'); - - switch (ps.state) { - case 'alive': query.andWhere('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break; - } - - switch (ps.origin) { - case 'local': query.andWhere('user.host IS NULL'); break; - case 'remote': query.andWhere('user.host IS NOT NULL'); break; - } - - if (ps.hostname) { - query.andWhere('user.host = :hostname', { hostname: ps.hostname.toLowerCase() }); - } - - switch (ps.sort) { - case '+follower': query.orderBy('user.followersCount', 'DESC'); break; - case '-follower': query.orderBy('user.followersCount', 'ASC'); break; - case '+createdAt': query.orderBy('user.id', 'DESC'); break; - case '-createdAt': query.orderBy('user.id', 'ASC'); break; - case '+updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'DESC'); break; - case '-updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'ASC'); break; - default: query.orderBy('user.id', 'ASC'); break; - } - - if (me) this.queryService.generateMutedUserQueryForUsers(query, me); - if (me) this.queryService.generateBlockQueryForUsers(query, me); - - query.limit(ps.limit); - query.offset(ps.offset); - - const users = await query.getMany(); - - return await this.userEntityService.packMany(users, me, { detail: true }); - }); + switch (ps.state) { + case 'admin': query.andWhere('user.isAdmin = TRUE'); break; + case 'moderator': query.andWhere('user.isModerator = TRUE'); break; + case 'adminOrModerator': query.andWhere('user.isAdmin = TRUE OR user.isModerator = TRUE'); break; + case 'alive': query.andWhere('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break; } -} + + switch (ps.origin) { + case 'local': query.andWhere('user.host IS NULL'); break; + case 'remote': query.andWhere('user.host IS NOT NULL'); break; + } + + if (ps.hostname) { + query.andWhere('user.host = :hostname', { hostname: ps.hostname.toLowerCase() }); + } + + switch (ps.sort) { + case '+follower': query.orderBy('user.followersCount', 'DESC'); break; + case '-follower': query.orderBy('user.followersCount', 'ASC'); break; + case '+createdAt': query.orderBy('user.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('user.createdAt', 'ASC'); break; + case '+updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'DESC'); break; + case '-updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'ASC'); break; + default: query.orderBy('user.id', 'ASC'); break; + } + + if (me) generateMutedUserQueryForUsers(query, me); + if (me) generateBlockQueryForUsers(query, me); + + query.take(ps.limit); + query.skip(ps.offset); + + const users = await query.getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/achievements.ts b/packages/backend/src/server/api/endpoints/users/achievements.ts deleted file mode 100644 index 55a2bd2f27..0000000000 --- a/packages/backend/src/server/api/endpoints/users/achievements.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UserProfilesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - requireCredential: 'conditional', - - res: { - type: 'array', - items: { - type: 'object', - properties: { - name: { - type: 'string', - }, - unlockedAt: { - type: 'number', - }, - }, - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: ps.userId }); - - return profile.achievements; - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/users/clips.ts b/packages/backend/src/server/api/endpoints/users/clips.ts index b61b581eb6..09fdf27c23 100644 --- a/packages/backend/src/server/api/endpoints/users/clips.ts +++ b/packages/backend/src/server/api/endpoints/users/clips.ts @@ -1,20 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { ClipsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; -import { DI } from '@/di-symbols.js'; +import { Clips } from '@/models/index.js'; +import define from '../../define.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['users', 'clips'], - requireCredential: 'conditional', - description: 'Show all clips this user owns.', res: { @@ -39,25 +29,15 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.clipsRepository) - private clipsRepository: ClipsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery(Clips.createQueryBuilder('clip'), ps.sinceId, ps.untilId) + .andWhere('clip.userId = :userId', { userId: ps.userId }) + .andWhere('clip.isPublic = true'); - private clipEntityService: ClipEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.clipsRepository.createQueryBuilder('clip'), ps.sinceId, ps.untilId) - .andWhere('clip.userId = :userId', { userId: ps.userId }) - .andWhere('clip.isPublic = true'); + const clips = await query + .take(ps.limit) + .getMany(); - const clips = await query - .limit(ps.limit) - .getMany(); - - return await this.clipEntityService.packMany(clips, me); - }); - } -} + return await Clips.packMany(clips); +}); diff --git a/packages/backend/src/server/api/endpoints/users/featured-notes.ts b/packages/backend/src/server/api/endpoints/users/featured-notes.ts deleted file mode 100644 index b51e00c375..0000000000 --- a/packages/backend/src/server/api/endpoints/users/featured-notes.ts +++ /dev/null @@ -1,100 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { FeaturedService } from '@/core/FeaturedService.js'; -import { CacheService } from '@/core/CacheService.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; - -export const meta = { - tags: ['notes'], - - requireCredential: 'conditional', - allowGet: true, - cacheSec: 3600, - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'Note', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - untilId: { type: 'string', format: 'misskey:id' }, - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - private noteEntityService: NoteEntityService, - private featuredService: FeaturedService, - private cacheService: CacheService, - ) { - super(meta, paramDef, async (ps, me) => { - const userIdsWhoBlockingMe = me ? await this.cacheService.userBlockedCache.fetch(me.id) : new Set(); - - // early return if me is blocked by requesting user - if (userIdsWhoBlockingMe.has(ps.userId)) { - return []; - } - - let noteIds = await this.featuredService.getPerUserNotesRanking(ps.userId, 50); - - noteIds.sort((a, b) => a > b ? -1 : 1); - if (ps.untilId) { - noteIds = noteIds.filter(id => id < ps.untilId!); - } - noteIds = noteIds.slice(0, ps.limit); - - if (noteIds.length === 0) { - return []; - } - - const [ - userIdsWhoMeMuting, - ] = me ? await Promise.all([ - this.cacheService.userMutingsCache.fetch(me.id), - ]) : [new Set()]; - - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); - - const notes = (await query.getMany()).filter(note => { - if (me && isUserRelated(note, userIdsWhoBlockingMe, false)) return false; - if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false; - - return true; - }); - - notes.sort((a, b) => a.id > b.id ? -1 : 1); - - return await this.noteEntityService.packMany(notes, me); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/users/flashs.ts b/packages/backend/src/server/api/endpoints/users/flashs.ts deleted file mode 100644 index 7eb5d3eea0..0000000000 --- a/packages/backend/src/server/api/endpoints/users/flashs.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; -import type { FlashsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['users', 'flashs'], - - requireCredential: 'conditional', - - description: 'Show all flashs this user created.', - - res: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - ref: 'Flash', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], -} as const; - -// eslint-disable-next-line import/no-default-export -@Injectable() -export default class extends Endpoint { - constructor( - @Inject(DI.flashsRepository) - private flashsRepository: FlashsRepository, - - private flashEntityService: FlashEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.flashsRepository.createQueryBuilder('flash'), ps.sinceId, ps.untilId) - .andWhere('flash.userId = :userId', { userId: ps.userId }) - .andWhere('flash.visibility = \'public\''); - - const flashs = await query - .limit(ps.limit) - .getMany(); - - return await this.flashEntityService.packMany(flashs); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts index c05d03c74d..7f9f980764 100644 --- a/packages/backend/src/server/api/endpoints/users/followers.ts +++ b/packages/backend/src/server/api/endpoints/users/followers.ts @@ -1,22 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { IsNull } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, FollowingsRepository, UserProfilesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { DI } from '@/di-symbols.js'; +import { Users, Followings, UserProfiles } from '@/models/index.js'; +import { toPunyNullable } from '@/misc/convert-host.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['users'], - requireCredential: 'conditional', + requireCredential: false, description: 'Show everyone that follows this user.', @@ -51,77 +43,65 @@ export const paramDef = { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - - userId: { type: 'string', format: 'misskey:id' }, - username: { type: 'string' }, - host: { - type: 'string', - nullable: true, - description: 'The local host is represented with `null`.', - }, }, anyOf: [ - { required: ['userId'] }, - { required: ['username', 'host'] }, + { + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], + }, + { + properties: { + username: { type: 'string' }, + host: { + type: 'string', + nullable: true, + description: 'The local host is represented with `null`.', + }, + }, + required: ['username', 'host'], + }, ], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy(ps.userId != null + ? { id: ps.userId } + : { usernameLower: ps.username!.toLowerCase(), host: toPunyNullable(ps.host) ?? IsNull() }); - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - private utilityService: UtilityService, - private followingEntityService: FollowingEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy(ps.userId != null - ? { id: ps.userId } - : { usernameLower: ps.username!.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() }); - - if (user == null) { - throw new ApiError(meta.errors.noSuchUser); - } - - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - - if (profile.followersVisibility === 'private') { - if (me == null || (me.id !== user.id)) { - throw new ApiError(meta.errors.forbidden); - } - } else if (profile.followersVisibility === 'followers') { - if (me == null) { - throw new ApiError(meta.errors.forbidden); - } else if (me.id !== user.id) { - const isFollowing = await this.followingsRepository.exist({ - where: { - followeeId: user.id, - followerId: me.id, - }, - }); - if (!isFollowing) { - throw new ApiError(meta.errors.forbidden); - } - } - } - - const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId) - .andWhere('following.followeeId = :userId', { userId: user.id }) - .innerJoinAndSelect('following.follower', 'follower'); - - const followings = await query - .limit(ps.limit) - .getMany(); - - return await this.followingEntityService.packMany(followings, me, { populateFollower: true }); - }); + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); } -} + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + if (profile.ffVisibility === 'private') { + if (me == null || (me.id !== user.id)) { + throw new ApiError(meta.errors.forbidden); + } + } else if (profile.ffVisibility === 'followers') { + if (me == null) { + throw new ApiError(meta.errors.forbidden); + } else if (me.id !== user.id) { + const following = await Followings.findOneBy({ + followeeId: user.id, + followerId: me.id, + }); + if (following == null) { + throw new ApiError(meta.errors.forbidden); + } + } + } + + const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) + .andWhere('following.followeeId = :userId', { userId: user.id }) + .innerJoinAndSelect('following.follower', 'follower'); + + const followings = await query + .take(ps.limit) + .getMany(); + + return await Followings.packMany(followings, me, { populateFollower: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index 3dee85f52d..0aaa810f76 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -1,22 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { IsNull } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, FollowingsRepository, UserProfilesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { DI } from '@/di-symbols.js'; +import { Users, Followings, UserProfiles } from '@/models/index.js'; +import { toPunyNullable } from '@/misc/convert-host.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['users'], - requireCredential: 'conditional', + requireCredential: false, description: 'Show everyone that this user is following.', @@ -42,12 +34,6 @@ export const meta = { code: 'FORBIDDEN', id: 'f6cdb0df-c19f-ec5c-7dbb-0ba84a1f92ba', }, - - birthdayInvalid: { - message: 'Birthday date format is invalid.', - code: 'BIRTHDAY_DATE_FORMAT_INVALID', - id: 'a2b007b9-4782-4eba-abd3-93b05ed4130d', - }, }, } as const; @@ -57,94 +43,65 @@ export const paramDef = { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - - userId: { type: 'string', format: 'misskey:id' }, - username: { type: 'string' }, - host: { - type: 'string', - nullable: true, - description: 'The local host is represented with `null`.', - }, - - birthday: { type: 'string', nullable: true }, }, anyOf: [ - { required: ['userId'] }, - { required: ['username', 'host'] }, + { + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], + }, + { + properties: { + username: { type: 'string' }, + host: { + type: 'string', + nullable: true, + description: 'The local host is represented with `null`.', + }, + }, + required: ['username', 'host'], + }, ], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy(ps.userId != null + ? { id: ps.userId } + : { usernameLower: ps.username!.toLowerCase(), host: toPunyNullable(ps.host) ?? IsNull() }); - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - private utilityService: UtilityService, - private followingEntityService: FollowingEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy(ps.userId != null - ? { id: ps.userId } - : { usernameLower: ps.username!.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() }); - - if (user == null) { - throw new ApiError(meta.errors.noSuchUser); - } - - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - - if (profile.followingVisibility === 'private') { - if (me == null || (me.id !== user.id)) { - throw new ApiError(meta.errors.forbidden); - } - } else if (profile.followingVisibility === 'followers') { - if (me == null) { - throw new ApiError(meta.errors.forbidden); - } else if (me.id !== user.id) { - const isFollowing = await this.followingsRepository.exist({ - where: { - followeeId: user.id, - followerId: me.id, - }, - }); - if (!isFollowing) { - throw new ApiError(meta.errors.forbidden); - } - } - } - - const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId) - .andWhere('following.followerId = :userId', { userId: user.id }) - .innerJoinAndSelect('following.followee', 'followee'); - - if (ps.birthday) { - try { - const d = new Date(ps.birthday); - d.setHours(0, 0, 0, 0); - const birthday = `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`; - const birthdayUserQuery = this.userProfilesRepository.createQueryBuilder('user_profile'); - birthdayUserQuery.select('user_profile.userId') - .where(`SUBSTR(user_profile.birthday, 6, 5) = '${birthday}'`); - - query.andWhere(`following.followeeId IN (${ birthdayUserQuery.getQuery() })`); - } catch (err) { - throw new ApiError(meta.errors.birthdayInvalid); - } - } - - const followings = await query - .limit(ps.limit) - .getMany(); - - return await this.followingEntityService.packMany(followings, me, { populateFollowee: true }); - }); + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); } -} + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + if (profile.ffVisibility === 'private') { + if (me == null || (me.id !== user.id)) { + throw new ApiError(meta.errors.forbidden); + } + } else if (profile.ffVisibility === 'followers') { + if (me == null) { + throw new ApiError(meta.errors.forbidden); + } else if (me.id !== user.id) { + const following = await Followings.findOneBy({ + followeeId: user.id, + followerId: me.id, + }); + if (following == null) { + throw new ApiError(meta.errors.forbidden); + } + } + } + + const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) + .andWhere('following.followerId = :userId', { userId: user.id }) + .innerJoinAndSelect('following.followee', 'followee'); + + const followings = await query + .take(ps.limit) + .getMany(); + + return await Followings.packMany(followings, me, { populateFollowee: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/gallery/posts.ts b/packages/backend/src/server/api/endpoints/users/gallery/posts.ts index 42c80ae43b..35bf2df598 100644 --- a/packages/backend/src/server/api/endpoints/users/gallery/posts.ts +++ b/packages/backend/src/server/api/endpoints/users/gallery/posts.ts @@ -1,20 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { GalleryPostsRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; -import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; -import { DI } from '@/di-symbols.js'; +import define from '../../../define.js'; +import { GalleryPosts } from '@/models/index.js'; +import { makePaginationQuery } from '../../../common/make-pagination-query.js'; export const meta = { tags: ['users', 'gallery'], - requireCredential: 'conditional', - description: 'Show all gallery posts by the given user.', res: { @@ -39,24 +29,14 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.galleryPostsRepository) - private galleryPostsRepository: GalleryPostsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId) + .andWhere(`post.userId = :userId`, { userId: ps.userId }); - private galleryPostEntityService: GalleryPostEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.galleryPostsRepository.createQueryBuilder('post'), ps.sinceId, ps.untilId) - .andWhere('post.userId = :userId', { userId: ps.userId }); + const posts = await query + .take(ps.limit) + .getMany(); - const posts = await query - .limit(ps.limit) - .getMany(); - - return await this.galleryPostEntityService.packMany(posts, me); - }); - } -} + return await GalleryPosts.packMany(posts, user); +}); diff --git a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts index 353abbca88..56965d3066 100644 --- a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts +++ b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts @@ -1,22 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { Not, In, IsNull } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import { maximum } from '@/misc/prelude/array.js'; -import type { NotesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; +import { maximum } from '@/prelude/array.js'; +import { Notes, Users } from '@/models/index.js'; +import define from '../../define.js'; import { ApiError } from '../../error.js'; +import { getUser } from '../../common/getters.js'; export const meta = { tags: ['users'], - requireCredential: 'conditional', + requireCredential: false, description: 'Get a list of other users that the specified user frequently replies to.', @@ -58,75 +50,65 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + // Lookup user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); - private userEntityService: UserEntityService, - private getterService: GetterService, - ) { - super(meta, paramDef, async (ps, me) => { - // Lookup user - const user = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); + // Fetch recent notes + const recentNotes = await Notes.find({ + where: { + userId: user.id, + replyId: Not(IsNull()), + }, + order: { + id: -1, + }, + take: 1000, + select: ['replyId'], + }); - // Fetch recent notes - const recentNotes = await this.notesRepository.find({ - where: { - userId: user.id, - replyId: Not(IsNull()), - }, - order: { - id: -1, - }, - take: 1000, - select: ['replyId'], - }); - - // 投稿が少なかったら中断 - if (recentNotes.length === 0) { - return []; - } - - // TODO ミュートを考慮 - const replyTargetNotes = await this.notesRepository.find({ - where: { - id: In(recentNotes.map(p => p.replyId)), - }, - select: ['userId'], - }); - - const repliedUsers: any = {}; - - // Extract replies from recent notes - for (const userId of replyTargetNotes.map(x => x.userId.toString())) { - if (repliedUsers[userId]) { - repliedUsers[userId]++; - } else { - repliedUsers[userId] = 1; - } - } - - // Calc peak - const peak = maximum(Object.values(repliedUsers)); - - // Sort replies by frequency - const repliedUsersSorted = Object.keys(repliedUsers).sort((a, b) => repliedUsers[b] - repliedUsers[a]); - - // Extract top replied users - const topRepliedUsers = repliedUsersSorted.slice(0, ps.limit); - - // Make replies object (includes weights) - const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({ - user: await this.userEntityService.pack(user, me, { detail: true }), - weight: repliedUsers[user] / peak, - }))); - - return repliesObj; - }); + // 投稿が少なかったら中断 + if (recentNotes.length === 0) { + return []; } -} + + // TODO ミュートを考慮 + const replyTargetNotes = await Notes.find({ + where: { + id: In(recentNotes.map(p => p.replyId)), + }, + select: ['userId'], + }); + + const repliedUsers: any = {}; + + // Extract replies from recent notes + for (const userId of replyTargetNotes.map(x => x.userId.toString())) { + if (repliedUsers[userId]) { + repliedUsers[userId]++; + } else { + repliedUsers[userId] = 1; + } + } + + // Calc peak + const peak = maximum(Object.values(repliedUsers)); + + // Sort replies by frequency + const repliedUsersSorted = Object.keys(repliedUsers).sort((a, b) => repliedUsers[b] - repliedUsers[a]); + + // Extract top replied users + const topRepliedUsers = repliedUsersSorted.slice(0, ps.limit); + + // Make replies object (includes weights) + const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({ + user: await Users.pack(user, me, { detail: true }), + weight: repliedUsers[user] / peak, + }))); + + return repliesObj; +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/create.ts b/packages/backend/src/server/api/endpoints/users/groups/create.ts new file mode 100644 index 0000000000..4a6362a3c6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/create.ts @@ -0,0 +1,49 @@ +import { UserGroups, UserGroupJoinings } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { UserGroup } from '@/models/entities/user-group.js'; +import { UserGroupJoining } from '@/models/entities/user-group-joining.js'; +import define from '../../../define.js'; + +export const meta = { + tags: ['groups'], + + requireCredential: true, + + kind: 'write:user-groups', + + description: 'Create a new group.', + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'UserGroup', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 100 }, + }, + required: ['name'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const userGroup = await UserGroups.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + } as UserGroup).then(x => UserGroups.findOneByOrFail(x.identifiers[0])); + + // Push the owner + await UserGroupJoinings.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + userGroupId: userGroup.id, + } as UserGroupJoining); + + return await UserGroups.pack(userGroup); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/delete.ts b/packages/backend/src/server/api/endpoints/users/groups/delete.ts new file mode 100644 index 0000000000..2ff1f9aec1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/delete.ts @@ -0,0 +1,43 @@ +import { UserGroups } from '@/models/index.js'; +import define from '../../../define.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['groups'], + + requireCredential: true, + + kind: 'write:user-groups', + + description: 'Delete an existing group.', + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '63dbd64c-cd77-413f-8e08-61781e210b38', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + }, + required: ['groupId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const userGroup = await UserGroups.findOneBy({ + id: ps.groupId, + userId: user.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + await UserGroups.delete(userGroup.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts b/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts new file mode 100644 index 0000000000..220fff5f3e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts @@ -0,0 +1,57 @@ +import { UserGroupJoinings, UserGroupInvitations } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { UserGroupJoining } from '@/models/entities/user-group-joining.js'; +import { ApiError } from '../../../../error.js'; +import define from '../../../../define.js'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true, + + kind: 'write:user-groups', + + description: 'Join a group the authenticated user has been invited to.', + + errors: { + noSuchInvitation: { + message: 'No such invitation.', + code: 'NO_SUCH_INVITATION', + id: '98c11eca-c890-4f42-9806-c8c8303ebb5e', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + invitationId: { type: 'string', format: 'misskey:id' }, + }, + required: ['invitationId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Fetch the invitation + const invitation = await UserGroupInvitations.findOneBy({ + id: ps.invitationId, + }); + + if (invitation == null) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + if (invitation.userId !== user.id) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + // Push the user + await UserGroupJoinings.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + userGroupId: invitation.userGroupId, + } as UserGroupJoining); + + UserGroupInvitations.delete(invitation.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts b/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts new file mode 100644 index 0000000000..8d1d3db734 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts @@ -0,0 +1,47 @@ +import { UserGroupInvitations } from '@/models/index.js'; +import define from '../../../../define.js'; +import { ApiError } from '../../../../error.js'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true, + + kind: 'write:user-groups', + + description: 'Delete an existing group invitation for the authenticated user without joining the group.', + + errors: { + noSuchInvitation: { + message: 'No such invitation.', + code: 'NO_SUCH_INVITATION', + id: 'ad7471d4-2cd9-44b4-ac68-e7136b4ce656', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + invitationId: { type: 'string', format: 'misskey:id' }, + }, + required: ['invitationId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Fetch the invitation + const invitation = await UserGroupInvitations.findOneBy({ + id: ps.invitationId, + }); + + if (invitation == null) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + if (invitation.userId !== user.id) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + await UserGroupInvitations.delete(invitation.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/invite.ts b/packages/backend/src/server/api/endpoints/users/groups/invite.ts new file mode 100644 index 0000000000..1a8d320f3a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/invite.ts @@ -0,0 +1,102 @@ +import { UserGroups, UserGroupJoinings, UserGroupInvitations } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { UserGroupInvitation } from '@/models/entities/user-group-invitation.js'; +import { createNotification } from '@/services/create-notification.js'; +import { getUser } from '../../../common/getters.js'; +import { ApiError } from '../../../error.js'; +import define from '../../../define.js'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true, + + kind: 'write:user-groups', + + description: 'Invite a user to an existing group.', + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '583f8bc0-8eee-4b78-9299-1e14fc91e409', + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'da52de61-002c-475b-90e1-ba64f9cf13a8', + }, + + alreadyAdded: { + message: 'That user has already been added to that group.', + code: 'ALREADY_ADDED', + id: '7e35c6a0-39b2-4488-aea6-6ee20bd5da2c', + }, + + alreadyInvited: { + message: 'That user has already been invited to that group.', + code: 'ALREADY_INVITED', + id: 'ee0f58b4-b529-4d13-b761-b9a3e69f97e6', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['groupId', 'userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOneBy({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + const joining = await UserGroupJoinings.findOneBy({ + userGroupId: userGroup.id, + userId: user.id, + }); + + if (joining) { + throw new ApiError(meta.errors.alreadyAdded); + } + + const existInvitation = await UserGroupInvitations.findOneBy({ + userGroupId: userGroup.id, + userId: user.id, + }); + + if (existInvitation) { + throw new ApiError(meta.errors.alreadyInvited); + } + + const invitation = await UserGroupInvitations.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + userGroupId: userGroup.id, + } as UserGroupInvitation).then(x => UserGroupInvitations.findOneByOrFail(x.identifiers[0])); + + // 通知を作成 + createNotification(user.id, 'groupInvited', { + notifierId: me.id, + userGroupInvitationId: invitation.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/joined.ts b/packages/backend/src/server/api/endpoints/users/groups/joined.ts new file mode 100644 index 0000000000..16c6e544e5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/joined.ts @@ -0,0 +1,45 @@ +import { Not, In } from 'typeorm'; +import { UserGroups, UserGroupJoinings } from '@/models/index.js'; +import define from '../../../define.js'; + +export const meta = { + tags: ['groups', 'account'], + + requireCredential: true, + + kind: 'read:user-groups', + + description: 'List the groups that the authenticated user is a member of.', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'UserGroup', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const ownedGroups = await UserGroups.findBy({ + userId: me.id, + }); + + const joinings = await UserGroupJoinings.findBy({ + userId: me.id, + ...(ownedGroups.length > 0 ? { + userGroupId: Not(In(ownedGroups.map(x => x.id))), + } : {}), + }); + + return await Promise.all(joinings.map(x => UserGroups.pack(x.userGroupId))); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/leave.ts b/packages/backend/src/server/api/endpoints/users/groups/leave.ts new file mode 100644 index 0000000000..83dc757db1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/leave.ts @@ -0,0 +1,53 @@ +import { UserGroups, UserGroupJoinings } from '@/models/index.js'; +import define from '../../../define.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true, + + kind: 'write:user-groups', + + description: 'Leave a group. The owner of a group can not leave. They must transfer ownership or delete the group instead.', + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '62780270-1f67-5dc0-daca-3eb510612e31', + }, + + youAreOwner: { + message: 'Your are the owner.', + code: 'YOU_ARE_OWNER', + id: 'b6d6e0c2-ef8a-9bb8-653d-79f4a3107c69', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + }, + required: ['groupId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOneBy({ + id: ps.groupId, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + if (me.id === userGroup.userId) { + throw new ApiError(meta.errors.youAreOwner); + } + + await UserGroupJoinings.delete({ userGroupId: userGroup.id, userId: me.id }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/owned.ts b/packages/backend/src/server/api/endpoints/users/groups/owned.ts new file mode 100644 index 0000000000..d77cf1a52e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/owned.ts @@ -0,0 +1,37 @@ +import { UserGroups } from '@/models/index.js'; +import define from '../../../define.js'; + +export const meta = { + tags: ['groups', 'account'], + + requireCredential: true, + + kind: 'read:user-groups', + + description: 'List the groups that the authenticated user is the owner of.', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'UserGroup', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const userGroups = await UserGroups.findBy({ + userId: me.id, + }); + + return await Promise.all(userGroups.map(x => UserGroups.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/pull.ts b/packages/backend/src/server/api/endpoints/users/groups/pull.ts new file mode 100644 index 0000000000..ba67a1e5c9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/pull.ts @@ -0,0 +1,69 @@ +import { UserGroups, UserGroupJoinings } from '@/models/index.js'; +import define from '../../../define.js'; +import { ApiError } from '../../../error.js'; +import { getUser } from '../../../common/getters.js'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true, + + kind: 'write:user-groups', + + description: 'Removes a specified user from a group. The owner can not be removed.', + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '4662487c-05b1-4b78-86e5-fd46998aba74', + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '0b5cc374-3681-41da-861e-8bc1146f7a55', + }, + + isOwner: { + message: 'The user is the owner.', + code: 'IS_OWNER', + id: '1546eed5-4414-4dea-81c1-b0aec4f6d2af', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['groupId', 'userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOneBy({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + if (user.id === userGroup.userId) { + throw new ApiError(meta.errors.isOwner); + } + + // Pull the user + await UserGroupJoinings.delete({ userGroupId: userGroup.id, userId: user.id }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/show.ts b/packages/backend/src/server/api/endpoints/users/groups/show.ts new file mode 100644 index 0000000000..21e3d9da26 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/show.ts @@ -0,0 +1,58 @@ +import { UserGroups, UserGroupJoinings } from '@/models/index.js'; +import define from '../../../define.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['groups', 'account'], + + requireCredential: true, + + kind: 'read:user-groups', + + description: 'Show the properties of a group.', + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'UserGroup', + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: 'ea04751e-9b7e-487b-a509-330fb6bd6b9b', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + }, + required: ['groupId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOneBy({ + id: ps.groupId, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + const joining = await UserGroupJoinings.findOneBy({ + userId: me.id, + userGroupId: userGroup.id, + }); + + if (joining == null && userGroup.userId !== me.id) { + throw new ApiError(meta.errors.noSuchGroup); + } + + return await UserGroups.pack(userGroup); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/transfer.ts b/packages/backend/src/server/api/endpoints/users/groups/transfer.ts new file mode 100644 index 0000000000..6456e70dd5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/transfer.ts @@ -0,0 +1,83 @@ +import { UserGroups, UserGroupJoinings } from '@/models/index.js'; +import define from '../../../define.js'; +import { ApiError } from '../../../error.js'; +import { getUser } from '../../../common/getters.js'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true, + + kind: 'write:user-groups', + + description: 'Transfer ownership of a group from the authenticated user to another user.', + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'UserGroup', + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '8e31d36b-2f88-4ccd-a438-e2d78a9162db', + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '711f7ebb-bbb9-4dfa-b540-b27809fed5e9', + }, + + noSuchGroupMember: { + message: 'No such group member.', + code: 'NO_SUCH_GROUP_MEMBER', + id: 'd31bebee-196d-42c2-9a3e-9474d4be6cc4', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['groupId', 'userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOneBy({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + const joining = await UserGroupJoinings.findOneBy({ + userGroupId: userGroup.id, + userId: user.id, + }); + + if (joining == null) { + throw new ApiError(meta.errors.noSuchGroupMember); + } + + await UserGroups.update(userGroup.id, { + userId: ps.userId, + }); + + return await UserGroups.pack(userGroup.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/update.ts b/packages/backend/src/server/api/endpoints/users/groups/update.ts new file mode 100644 index 0000000000..0a96165fc4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/update.ts @@ -0,0 +1,55 @@ +import { UserGroups } from '@/models/index.js'; +import define from '../../../define.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['groups'], + + requireCredential: true, + + kind: 'write:user-groups', + + description: 'Update the properties of a group.', + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'UserGroup', + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '9081cda3-7a9e-4fac-a6ce-908d70f282f6', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + name: { type: 'string', minLength: 1, maxLength: 100 }, + }, + required: ['groupId', 'name'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOneBy({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + await UserGroups.update(userGroup.id, { + name: ps.name, + }); + + return await UserGroups.pack(userGroup.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts deleted file mode 100644 index ba95f5456c..0000000000 --- a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts +++ /dev/null @@ -1,159 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UserListsRepository, UserListMembershipsRepository, BlockingsRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import type { MiUserList } from '@/models/UserList.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '@/server/api/error.js'; -import { RoleService } from '@/core/RoleService.js'; -import { UserListService } from '@/core/UserListService.js'; - -export const meta = { - requireCredential: 'always', - prohibitMoved: true, - kind: 'write:account', - res: { - type: 'object', - optional: false, nullable: false, - ref: 'UserList', - }, - - errors: { - tooManyUserLists: { - message: 'You cannot create user list any more.', - code: 'TOO_MANY_USERLISTS', - id: 'e9c105b2-c595-47de-97fb-7f7c2c33e92f', - }, - noSuchList: { - message: 'No such list.', - code: 'NO_SUCH_LIST', - id: '9292f798-6175-4f7d-93f4-b6742279667d', - }, - noSuchUser: { - message: 'No such user.', - code: 'NO_SUCH_USER', - id: '13c457db-a8cb-4d88-b70a-211ceeeabb5f', - }, - - alreadyAdded: { - message: 'That user has already been added to that list.', - code: 'ALREADY_ADDED', - id: 'c3ad6fdb-692b-47ee-a455-7bd12c7af615', - }, - - youHaveBeenBlocked: { - message: 'You cannot push this user because you have been blocked by this user.', - code: 'YOU_HAVE_BEEN_BLOCKED', - id: 'a2497f2a-2389-439c-8626-5298540530f4', - }, - - tooManyUsers: { - message: 'You can not push users any more.', - code: 'TOO_MANY_USERS', - id: '1845ea77-38d1-426e-8e4e-8b83b24f5bd7', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - name: { type: 'string', minLength: 1, maxLength: 100 }, - listId: { type: 'string', format: 'misskey:id' }, - }, - required: ['name', 'listId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - - private userListService: UserListService, - private userListEntityService: UserListEntityService, - private idService: IdService, - private getterService: GetterService, - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const listExist = await this.userListsRepository.exist({ - where: { - id: ps.listId, - isPublic: true, - }, - }); - if (!listExist) throw new ApiError(meta.errors.noSuchList); - const currentCount = await this.userListsRepository.countBy({ - userId: me.id, - }); - if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) { - throw new ApiError(meta.errors.tooManyUserLists); - } - - const userList = await this.userListsRepository.insert({ - id: this.idService.gen(), - userId: me.id, - name: ps.name, - } as MiUserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0])); - - const users = (await this.userListMembershipsRepository.findBy({ - userListId: ps.listId, - })).map(x => x.userId); - - for (const user of users) { - const currentUser = await this.getterService.getUser(user).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); - - if (currentUser.id !== me.id) { - const blockExist = await this.blockingsRepository.exist({ - where: { - blockerId: currentUser.id, - blockeeId: me.id, - }, - }); - if (blockExist) { - throw new ApiError(meta.errors.youHaveBeenBlocked); - } - } - - const exist = await this.userListMembershipsRepository.exist({ - where: { - userListId: userList.id, - userId: currentUser.id, - }, - }); - - if (exist) { - throw new ApiError(meta.errors.alreadyAdded); - } - - try { - await this.userListService.addMember(currentUser, userList, me); - } catch (err) { - if (err instanceof UserListService.TooManyUsersError) { - throw new ApiError(meta.errors.tooManyUsers); - } - throw err; - } - } - return await this.userListEntityService.pack(userList); - }); - } -} - diff --git a/packages/backend/src/server/api/endpoints/users/lists/create.ts b/packages/backend/src/server/api/endpoints/users/lists/create.ts index 6385771459..783e63f5de 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create.ts @@ -1,24 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UserListsRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import type { MiUserList } from '@/models/UserList.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '@/server/api/error.js'; -import { RoleService } from '@/core/RoleService.js'; +import { UserLists } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { UserList } from '@/models/entities/user-list.js'; +import define from '../../../define.js'; export const meta = { tags: ['lists'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:account', @@ -29,14 +17,6 @@ export const meta = { optional: false, nullable: false, ref: 'UserList', }, - - errors: { - tooManyUserLists: { - message: 'You cannot create user list any more.', - code: 'TOO_MANY_USERLISTS', - id: '0cf21a28-7715-4f39-a20d-777bfdb8d138', - }, - }, } as const; export const paramDef = { @@ -47,31 +27,14 @@ export const paramDef = { required: ['name'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const userList = await UserLists.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + } as UserList).then(x => UserLists.findOneByOrFail(x.identifiers[0])); - private userListEntityService: UserListEntityService, - private idService: IdService, - private roleService: RoleService, - ) { - super(meta, paramDef, async (ps, me) => { - const currentCount = await this.userListsRepository.countBy({ - userId: me.id, - }); - if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) { - throw new ApiError(meta.errors.tooManyUserLists); - } - - const userList = await this.userListsRepository.insert({ - id: this.idService.gen(), - userId: me.id, - name: ps.name, - } as MiUserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0])); - - return await this.userListEntityService.pack(userList); - }); - } -} + return await UserLists.pack(userList); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/delete.ts b/packages/backend/src/server/api/endpoints/users/lists/delete.ts index ab33b48c8d..5a7613c98a 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/delete.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/delete.ts @@ -1,18 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UserListsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; +import { UserLists } from '@/models/index.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; export const meta = { tags: ['lists'], - requireCredential: 'always', + requireCredential: true, kind: 'write:account', @@ -35,23 +28,16 @@ export const paramDef = { required: ['listId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const userList = await this.userListsRepository.findOneBy({ - id: ps.listId, - userId: me.id, - }); +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const userList = await UserLists.findOneBy({ + id: ps.listId, + userId: user.id, + }); - if (userList == null) { - throw new ApiError(meta.errors.noSuchList); - } - - await this.userListsRepository.delete(userList.id); - }); + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); } -} + + await UserLists.delete(userList.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/favorite.ts b/packages/backend/src/server/api/endpoints/users/lists/favorite.ts deleted file mode 100644 index fa9f86848f..0000000000 --- a/packages/backend/src/server/api/endpoints/users/lists/favorite.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UserListFavoritesRepository, UserListsRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { ApiError } from '@/server/api/error.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - requireCredential: 'always', - kind: 'write:account', - errors: { - noSuchList: { - message: 'No such user list.', - code: 'NO_SUCH_USER_LIST', - id: '7dbaf3cf-7b42-4b8f-b431-b3919e580dbe', - }, - - alreadyFavorited: { - message: 'The list has already been favorited.', - code: 'ALREADY_FAVORITED', - id: '6425bba0-985b-461e-af1b-518070e72081', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - listId: { type: 'string', format: 'misskey:id' }, - }, - required: ['listId'], -} as const; - -@Injectable() // eslint-disable-next-line import/no-default-export -export default class extends Endpoint { - constructor ( - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - @Inject(DI.userListFavoritesRepository) - private userListFavoritesRepository: UserListFavoritesRepository, - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - const userListExist = await this.userListsRepository.exist({ - where: { - id: ps.listId, - isPublic: true, - }, - }); - - if (!userListExist) { - throw new ApiError(meta.errors.noSuchList); - } - - const exist = await this.userListFavoritesRepository.exist({ - where: { - userId: me.id, - userListId: ps.listId, - }, - }); - - if (exist) { - throw new ApiError(meta.errors.alreadyFavorited); - } - - await this.userListFavoritesRepository.insert({ - id: this.idService.gen(), - userId: me.id, - userListId: ps.listId, - }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts b/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts deleted file mode 100644 index b48733614f..0000000000 --- a/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts +++ /dev/null @@ -1,108 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UserListsRepository, UserListFavoritesRepository, UserListMembershipsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { QueryService } from '@/core/QueryService.js'; -import { ApiError } from '../../../error.js'; - -export const meta = { - tags: ['lists', 'account'], - - requireCredential: 'conditional', - - kind: 'read:account', - - errors: { - noSuchList: { - message: 'No such list.', - code: 'NO_SUCH_LIST', - id: '7bc05c21-1d7a-41ae-88f1-66820f4dc686', - }, - }, - - res: { - type: 'array', - items: { - type: 'object', - nullable: false, - properties: { - id: { - type: 'string', - format: 'misskey:id', - }, - createdAt: { - type: 'string', - format: 'date-time', - }, - userId: { - type: 'string', - format: 'misskey:id', - }, - user: { - type: 'object', - ref: 'User', - }, - withReplies: { - type: 'boolean', - }, - }, - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - listId: { type: 'string', format: 'misskey:id' }, - forPublic: { type: 'boolean', default: false }, - limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - }, - required: ['listId'], -} as const; - -@Injectable() // eslint-disable-next-line import/no-default-export -export default class extends Endpoint { - constructor( - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - private userListEntityService: UserListEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - // Fetch the list - const userList = await this.userListsRepository.findOneBy(!ps.forPublic && me !== null ? { - id: ps.listId, - userId: me.id, - } : { - id: ps.listId, - isPublic: true, - }); - - if (userList == null) { - throw new ApiError(meta.errors.noSuchList); - } - - const query = this.queryService.makePaginationQuery(this.userListMembershipsRepository.createQueryBuilder('membership'), ps.sinceId, ps.untilId) - .andWhere('membership.userListId = :userListId', { userListId: userList.id }) - .innerJoinAndSelect('membership.user', 'user'); - - const memberships = await query - .limit(ps.limit) - .getMany(); - - return this.userListEntityService.packMembershipsMany(memberships); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/users/lists/list.ts b/packages/backend/src/server/api/endpoints/users/lists/list.ts index 2434b5d32f..889052fa30 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/list.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/list.ts @@ -1,19 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UserListsRepository, UsersRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; -import { ApiError } from '@/server/api/error.js'; -import { DI } from '@/di-symbols.js'; +import { UserLists } from '@/models/index.js'; +import define from '../../../define.js'; export const meta = { tags: ['lists', 'account'], - requireCredential: 'conditional', + requireCredential: true, kind: 'read:account', @@ -28,61 +19,19 @@ export const meta = { ref: 'UserList', }, }, - errors: { - noSuchUser: { - message: 'No such user.', - code: 'NO_SUCH_USER', - id: 'a8af4a82-0980-4cc4-a6af-8b0ffd54465e', - }, - remoteUser: { - message: 'Not allowed to load the remote user\'s list', - code: 'REMOTE_USER_NOT_ALLOWED', - id: '53858f1b-3315-4a01-81b7-db9b48d4b79a', - }, - invalidParam: { - message: 'Invalid param.', - code: 'INVALID_PARAM', - id: 'ab36de0e-29e9-48cb-9732-d82f1281620d', - }, - }, } as const; export const paramDef = { type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - }, + properties: {}, required: [], } as const; -@Injectable() // eslint-disable-next-line import/no-default-export -export default class extends Endpoint { - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const userLists = await UserLists.findBy({ + userId: me.id, + }); - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - private userListEntityService: UserListEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - if (typeof ps.userId !== 'undefined') { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - if (user === null) throw new ApiError(meta.errors.noSuchUser); - if (user.host !== null) throw new ApiError(meta.errors.remoteUser); - } else if (me === null) { - throw new ApiError(meta.errors.invalidParam); - } - - const userLists = await this.userListsRepository.findBy(typeof ps.userId === 'undefined' && me !== null ? { - userId: me.id, - } : { - userId: ps.userId, - isPublic: true, - }); - - return await Promise.all(userLists.map(x => this.userListEntityService.pack(x))); - }); - } -} + return await Promise.all(userLists.map(x => UserLists.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/pull.ts b/packages/backend/src/server/api/endpoints/users/lists/pull.ts index e6f5a641ac..d3d1d6555c 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/pull.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/pull.ts @@ -1,22 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UserListsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { DI } from '@/di-symbols.js'; -import { UserListService } from '@/core/UserListService.js'; +import { publishUserListStream } from '@/services/stream.js'; +import { UserLists, UserListJoinings, Users } from '@/models/index.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { getUser } from '../../../common/getters.js'; export const meta = { tags: ['lists', 'users'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:account', @@ -46,33 +37,26 @@ export const paramDef = { required: ['listId', 'userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + // Fetch the list + const userList = await UserLists.findOneBy({ + id: ps.listId, + userId: me.id, + }); - private userListService: UserListService, - private getterService: GetterService, - ) { - super(meta, paramDef, async (ps, me) => { - // Fetch the list - const userList = await this.userListsRepository.findOneBy({ - id: ps.listId, - userId: me.id, - }); - - if (userList == null) { - throw new ApiError(meta.errors.noSuchList); - } - - // Fetch the user - const user = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); - - await this.userListService.removeMember(user, userList); - }); + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); } -} + + // Fetch the user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Pull the user + await UserListJoinings.delete({ userListId: userList.id, userId: user.id }); + + publishUserListStream(userList.id, 'userRemoved', await Users.pack(user)); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts index 7a1053ebb4..12b7b86342 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/push.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts @@ -1,33 +1,18 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import ms from 'ms'; -import type { UserListsRepository, UserListMembershipsRepository, BlockingsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { UserListService } from '@/core/UserListService.js'; -import { DI } from '@/di-symbols.js'; +import { pushUserToUserList } from '@/services/user-list/push.js'; +import { UserLists, UserListJoinings, Blockings } from '@/models/index.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; +import { getUser } from '../../../common/getters.js'; export const meta = { tags: ['lists', 'users'], - requireCredential: 'always', - - prohibitMoved: true, + requireCredential: true, kind: 'write:account', description: 'Add a user to an existing list.', - limit: { - duration: ms('1hour'), - max: 30, - }, - errors: { noSuchList: { message: 'No such list.', @@ -52,12 +37,6 @@ export const meta = { code: 'YOU_HAVE_BEEN_BLOCKED', id: '990232c5-3f9d-4d83-9f3f-ef27b6332a4b', }, - - tooManyUsers: { - message: 'You can not push users any more.', - code: 'TOO_MANY_USERS', - id: '2dd9752e-a338-413d-8eec-41814430989b', - }, }, } as const; @@ -70,71 +49,44 @@ export const paramDef = { required: ['listId', 'userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + // Fetch the list + const userList = await UserLists.findOneBy({ + id: ps.listId, + userId: me.id, + }); - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - - private getterService: GetterService, - private userListService: UserListService, - ) { - super(meta, paramDef, async (ps, me) => { - // Fetch the list - const userList = await this.userListsRepository.findOneBy({ - id: ps.listId, - userId: me.id, - }); - - if (userList == null) { - throw new ApiError(meta.errors.noSuchList); - } - - // Fetch the user - const user = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); - - // Check blocking - if (user.id !== me.id) { - const blockExist = await this.blockingsRepository.exist({ - where: { - blockerId: user.id, - blockeeId: me.id, - }, - }); - if (blockExist) { - throw new ApiError(meta.errors.youHaveBeenBlocked); - } - } - - const exist = await this.userListMembershipsRepository.exist({ - where: { - userListId: userList.id, - userId: user.id, - }, - }); - - if (exist) { - throw new ApiError(meta.errors.alreadyAdded); - } - - try { - await this.userListService.addMember(user, userList, me); - } catch (err) { - if (err instanceof UserListService.TooManyUsersError) { - throw new ApiError(meta.errors.tooManyUsers); - } - - throw err; - } - }); + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); } -} + + // Fetch the user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check blocking + if (user.id !== me.id) { + const block = await Blockings.findOneBy({ + blockerId: user.id, + blockeeId: me.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + + const exist = await UserListJoinings.findOneBy({ + userListId: userList.id, + userId: user.id, + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyAdded); + } + + // Push the user + await pushUserToUserList(user, userList); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/show.ts b/packages/backend/src/server/api/endpoints/users/lists/show.ts index e1d573bbb3..fd0612f735 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/show.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/show.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UserListsRepository, UserListFavoritesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; -import { DI } from '@/di-symbols.js'; +import { UserLists } from '@/models/index.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; export const meta = { tags: ['lists', 'account'], - requireCredential: 'conditional', + requireCredential: true, kind: 'read:account', @@ -38,56 +30,21 @@ export const paramDef = { type: 'object', properties: { listId: { type: 'string', format: 'misskey:id' }, - forPublic: { type: 'boolean', default: false }, }, required: ['listId'], } as const; -@Injectable() // eslint-disable-next-line import/no-default-export -export default class extends Endpoint { - constructor( - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + // Fetch the list + const userList = await UserLists.findOneBy({ + id: ps.listId, + userId: me.id, + }); - @Inject(DI.userListFavoritesRepository) - private userListFavoritesRepository: UserListFavoritesRepository, - - private userListEntityService: UserListEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const additionalProperties: Partial<{ likedCount: number, isLiked: boolean }> = {}; - // Fetch the list - const userList = await this.userListsRepository.findOneBy(!ps.forPublic && me !== null ? { - id: ps.listId, - userId: me.id, - } : { - id: ps.listId, - isPublic: true, - }); - - if (userList == null) { - throw new ApiError(meta.errors.noSuchList); - } - - if (ps.forPublic && userList.isPublic) { - additionalProperties.likedCount = await this.userListFavoritesRepository.countBy({ - userListId: ps.listId, - }); - if (me !== null) { - additionalProperties.isLiked = await this.userListFavoritesRepository.exist({ - where: { - userId: me.id, - userListId: ps.listId, - }, - }); - } else { - additionalProperties.isLiked = false; - } - } - return { - ...await this.userListEntityService.pack(userList), - ...additionalProperties, - }; - }); + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); } -} + + return await UserLists.pack(userList); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts b/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts deleted file mode 100644 index af1ed91c27..0000000000 --- a/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UserListFavoritesRepository, UserListsRepository } from '@/models/_.js'; -import { ApiError } from '@/server/api/error.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - requireCredential: 'always', - kind: 'write:account', - errors: { - noSuchList: { - message: 'No such user list.', - code: 'NO_SUCH_USER_LIST', - id: 'baedb33e-76b8-4b0c-86a8-9375c0a7b94b', - }, - - notFavorited: { - message: 'You have not favorited the list.', - code: 'ALREADY_FAVORITED', - id: '835c4b27-463d-4cfa-969b-a9058678d465', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - listId: { type: 'string', format: 'misskey:id' }, - }, - required: ['listId'], -} as const; - -@Injectable() // eslint-disable-next-line import/no-default-export -export default class extends Endpoint { - constructor ( - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - @Inject(DI.userListFavoritesRepository) - private userListFavoritesRepository: UserListFavoritesRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const userListExist = await this.userListsRepository.exist({ - where: { - id: ps.listId, - isPublic: true, - }, - }); - - if (!userListExist) { - throw new ApiError(meta.errors.noSuchList); - } - - const exist = await this.userListFavoritesRepository.findOneBy({ - userListId: ps.listId, - userId: me.id, - }); - - if (exist === null) { - throw new ApiError(meta.errors.notFavorited); - } - - await this.userListFavoritesRepository.delete({ id: exist.id }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts b/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts deleted file mode 100644 index d6e24beb2b..0000000000 --- a/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UserListsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { DI } from '@/di-symbols.js'; -import { UserListService } from '@/core/UserListService.js'; -import { ApiError } from '../../../error.js'; - -export const meta = { - tags: ['lists', 'users'], - - requireCredential: 'always', - - prohibitMoved: true, - - kind: 'write:account', - - errors: { - noSuchList: { - message: 'No such list.', - code: 'NO_SUCH_LIST', - id: '7f44670e-ab16-43b8-b4c1-ccd2ee89cc02', - }, - - noSuchUser: { - message: 'No such user.', - code: 'NO_SUCH_USER', - id: '588e7f72-c744-4a61-b180-d354e912bda2', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - listId: { type: 'string', format: 'misskey:id' }, - userId: { type: 'string', format: 'misskey:id' }, - withReplies: { type: 'boolean' }, - }, - required: ['listId', 'userId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - private userListService: UserListService, - private getterService: GetterService, - ) { - super(meta, paramDef, async (ps, me) => { - // Fetch the list - const userList = await this.userListsRepository.findOneBy({ - id: ps.listId, - userId: me.id, - }); - - if (userList == null) { - throw new ApiError(meta.errors.noSuchList); - } - - // Fetch the user - const user = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); - - await this.userListService.updateMembership(user, userList, { - withReplies: ps.withReplies, - }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/users/lists/update.ts b/packages/backend/src/server/api/endpoints/users/lists/update.ts index 16df760265..65e708b959 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/update.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/update.ts @@ -1,19 +1,11 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UserListsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; -import { DI } from '@/di-symbols.js'; +import { UserLists } from '@/models/index.js'; +import define from '../../../define.js'; import { ApiError } from '../../../error.js'; export const meta = { tags: ['lists'], - requireCredential: 'always', + requireCredential: true, kind: 'write:account', @@ -39,35 +31,25 @@ export const paramDef = { properties: { listId: { type: 'string', format: 'misskey:id' }, name: { type: 'string', minLength: 1, maxLength: 100 }, - isPublic: { type: 'boolean' }, }, - required: ['listId'], + required: ['listId', 'name'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + // Fetch the list + const userList = await UserLists.findOneBy({ + id: ps.listId, + userId: user.id, + }); - private userListEntityService: UserListEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const userList = await this.userListsRepository.findOneBy({ - id: ps.listId, - userId: me.id, - }); - - if (userList == null) { - throw new ApiError(meta.errors.noSuchList); - } - - await this.userListsRepository.update(userList.id, { - name: ps.name, - isPublic: ps.isPublic, - }); - - return await this.userListEntityService.pack(userList.id); - }); + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); } -} + + await UserLists.update(userList.id, { + name: ps.name, + }); + + return await UserLists.pack(userList.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index 5db1a2a96c..9fa56fe83a 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -1,27 +1,17 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { Brackets } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { CacheService } from '@/core/CacheService.js'; -import { IdService } from '@/core/IdService.js'; -import { QueryService } from '@/core/QueryService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { MiLocalUser } from '@/models/User.js'; -import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; -import { FanoutTimelineName } from '@/core/FanoutTimelineService.js'; -import { ApiError } from '@/server/api/error.js'; +import { Notes } from '@/models/index.js'; +import define from '../../define.js'; +import { ApiError } from '../../error.js'; +import { getUser } from '../../common/getters.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; +import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; export const meta = { tags: ['users', 'notes'], - requireCredential: 'conditional', + description: 'Show all notes that this user created.', res: { type: 'array', @@ -39,12 +29,6 @@ export const meta = { code: 'NO_SUCH_USER', id: '27e494ba-2ac2-48e8-893b-10d4d8c2387b', }, - - bothWithRepliesAndWithFiles: { - message: 'Specifying both withReplies and withFiles is not supported', - code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', - id: '91c8cb9f-36ed-46e7-9ca2-7df96ed6e222', - }, }, } as const; @@ -52,154 +36,87 @@ export const paramDef = { type: 'object', properties: { userId: { type: 'string', format: 'misskey:id' }, - withReplies: { type: 'boolean', default: false }, - withRenotes: { type: 'boolean', default: true }, - withChannelNotes: { type: 'boolean', default: false }, + includeReplies: { type: 'boolean', default: true }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, - allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default + includeMyRenotes: { type: 'boolean', default: true }, withFiles: { type: 'boolean', default: false }, + fileType: { type: 'array', items: { + type: 'string', + } }, + excludeNsfw: { type: 'boolean', default: false }, }, required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + // Lookup user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); - private noteEntityService: NoteEntityService, - private queryService: QueryService, - private cacheService: CacheService, - private idService: IdService, - private fanoutTimelineEndpointService: FanoutTimelineEndpointService, - private metaService: MetaService, - ) { - super(meta, paramDef, async (ps, me) => { - const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); - const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); - const isSelf = me && (me.id === ps.userId); + //#region Construct query + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.userId = :userId', { userId: user.id }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - const serverSettings = await this.metaService.fetch(); - - if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); - - // early return if me is blocked by requesting user - if (me != null) { - const userIdsWhoBlockingMe = await this.cacheService.userBlockedCache.fetch(me.id); - if (userIdsWhoBlockingMe.has(ps.userId)) { - return []; - } - } - - if (!serverSettings.enableFanoutTimeline) { - const timeline = await this.getFromDb({ - untilId, - sinceId, - limit: ps.limit, - userId: ps.userId, - withChannelNotes: ps.withChannelNotes, - withFiles: ps.withFiles, - withRenotes: ps.withRenotes, - }, me); - - return await this.noteEntityService.packMany(timeline, me); - } - - const redisTimelines: FanoutTimelineName[] = [ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`]; - - if (ps.withReplies) redisTimelines.push(`userTimelineWithReplies:${ps.userId}`); - if (ps.withChannelNotes) redisTimelines.push(`userTimelineWithChannel:${ps.userId}`); - - const isFollowing = me && Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId); - - const timeline = await this.fanoutTimelineEndpointService.timeline({ - untilId, - sinceId, - limit: ps.limit, - allowPartial: ps.allowPartial, - me, - redisTimelines, - useDbFallback: true, - ignoreAuthorFromMute: true, - excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies - excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files - excludePureRenotes: !ps.withRenotes, - noteFilter: note => { - if (note.channel?.isSensitive && !isSelf) return false; - if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false; - if (note.visibility === 'followers' && !isFollowing && !isSelf) return false; - - return true; - }, - dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ - untilId, - sinceId, - limit, - userId: ps.userId, - withChannelNotes: ps.withChannelNotes, - withFiles: ps.withFiles, - withRenotes: ps.withRenotes, - }, me), - }); - - return timeline; - }); + generateVisibilityQuery(query, me); + if (me) { + generateMutedUserQuery(query, me, user); + generateBlockedUserQuery(query, me); } - private async getFromDb(ps: { - untilId: string | null, - sinceId: string | null, - limit: number, - userId: string, - withChannelNotes: boolean, - withFiles: boolean, - withRenotes: boolean, - }, me: MiLocalUser | null) { - const isSelf = me && (me.id === ps.userId); - - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere('note.userId = :userId', { userId: ps.userId }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('note.channel', 'channel') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - if (ps.withChannelNotes) { - if (!isSelf) query.andWhere(new Brackets(qb => { - qb.orWhere('note.channelId IS NULL'); - qb.orWhere('channel.isSensitive = false'); - })); - } else { - query.andWhere('note.channelId IS NULL'); - } - - this.queryService.generateVisibilityQuery(query, me); - if (me) { - this.queryService.generateMutedUserQuery(query, me, { id: ps.userId }); - this.queryService.generateBlockedUserQuery(query, me); - } - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - - if (ps.withRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :userId', { userId: ps.userId }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - return await query.limit(ps.limit).getMany(); + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); } -} + + if (ps.fileType != null) { + query.andWhere('note.fileIds != \'{}\''); + query.andWhere(new Brackets(qb => { + for (const type of ps.fileType!) { + const i = ps.fileType!.indexOf(type); + qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type }); + } + })); + + if (ps.excludeNsfw) { + query.andWhere('note.cw IS NULL'); + query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); + } + } + + if (!ps.includeReplies) { + query.andWhere('note.replyId IS NULL'); + } + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :userId', { userId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + //#endregion + + const timeline = await query.take(ps.limit).getMany(); + + return await Notes.packMany(timeline, me); +}); diff --git a/packages/backend/src/server/api/endpoints/users/pages.ts b/packages/backend/src/server/api/endpoints/users/pages.ts index f25096eb00..b1d28af845 100644 --- a/packages/backend/src/server/api/endpoints/users/pages.ts +++ b/packages/backend/src/server/api/endpoints/users/pages.ts @@ -1,20 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { PageEntityService } from '@/core/entities/PageEntityService.js'; -import type { PagesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import { Pages } from '@/models/index.js'; +import define from '../../define.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; export const meta = { tags: ['users', 'pages'], - requireCredential: 'conditional', - description: 'Show all pages this user created.', res: { @@ -39,25 +29,15 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.pagesRepository) - private pagesRepository: PagesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery(Pages.createQueryBuilder('page'), ps.sinceId, ps.untilId) + .andWhere('page.userId = :userId', { userId: ps.userId }) + .andWhere('page.visibility = \'public\''); - private pageEntityService: PageEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.pagesRepository.createQueryBuilder('page'), ps.sinceId, ps.untilId) - .andWhere('page.userId = :userId', { userId: ps.userId }) - .andWhere('page.visibility = \'public\''); + const pages = await query + .take(ps.limit) + .getMany(); - const pages = await query - .limit(ps.limit) - .getMany(); - - return await this.pageEntityService.packMany(pages); - }); - } -} + return await Pages.packMany(pages); +}); diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts index abb2ca027d..9668bd21b8 100644 --- a/packages/backend/src/server/api/endpoints/users/reactions.ts +++ b/packages/backend/src/server/api/endpoints/users/reactions.ts @@ -1,20 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UserProfilesRepository, NoteReactionsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { NoteReactionEntityService } from '@/core/entities/NoteReactionEntityService.js'; -import { DI } from '@/di-symbols.js'; +import { NoteReactions, UserProfiles } from '@/models/index.js'; +import define from '../../define.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; import { ApiError } from '../../error.js'; export const meta = { tags: ['users', 'reactions'], - requireCredential: 'conditional', + requireCredential: false, description: 'Show all reactions this user made.', @@ -50,37 +43,24 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const profile = await UserProfiles.findOneByOrFail({ userId: ps.userId }); - @Inject(DI.noteReactionsRepository) - private noteReactionsRepository: NoteReactionsRepository, - - private noteReactionEntityService: NoteReactionEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: ps.userId }); - - if ((me == null || me.id !== ps.userId) && !profile.publicReactions) { - throw new ApiError(meta.errors.reactionsNotPublic); - } - - const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('reaction.userId = :userId', { userId: ps.userId }) - .leftJoinAndSelect('reaction.note', 'note'); - - this.queryService.generateVisibilityQuery(query, me); - - const reactions = await query - .limit(ps.limit) - .getMany(); - - return await Promise.all(reactions.map(reaction => this.noteReactionEntityService.pack(reaction, me, { withNote: true }))); - }); + if (me == null || (me.id !== ps.userId && !profile.publicReactions)) { + throw new ApiError(meta.errors.reactionsNotPublic); } -} + + const query = makePaginationQuery(NoteReactions.createQueryBuilder('reaction'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('reaction.userId = :userId', { userId: ps.userId }) + .leftJoinAndSelect('reaction.note', 'note'); + + generateVisibilityQuery(query, me); + + const reactions = await query + .take(ps.limit) + .getMany(); + + return await Promise.all(reactions.map(reaction => NoteReactions.pack(reaction, me, { withNote: true }))); +}); diff --git a/packages/backend/src/server/api/endpoints/users/recommendation.ts b/packages/backend/src/server/api/endpoints/users/recommendation.ts index df28d13833..e7654e1714 100644 --- a/packages/backend/src/server/api/endpoints/users/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/users/recommendation.ts @@ -1,20 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import ms from 'ms'; -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, FollowingsRepository } from '@/models/_.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DI } from '@/di-symbols.js'; +import { Users, Followings } from '@/models/index.js'; +import define from '../../define.js'; +import { generateMutedUserQueryForUsers } from '../../common/generate-muted-user-query.js'; +import { generateBlockedUserQuery, generateBlockQueryForUsers } from '../../common/generate-block-query.js'; export const meta = { tags: ['users'], - requireCredential: 'always', + requireCredential: true, kind: 'read:account', @@ -40,43 +33,30 @@ export const paramDef = { required: [], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = Users.createQueryBuilder('user') + .where('user.isLocked = FALSE') + .andWhere('user.isExplorable = TRUE') + .andWhere('user.host IS NULL') + .andWhere('user.updatedAt >= :date', { date: new Date(Date.now() - ms('7days')) }) + .andWhere('user.id != :meId', { meId: me.id }) + .orderBy('user.followersCount', 'DESC'); - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, + generateMutedUserQueryForUsers(query, me); + generateBlockQueryForUsers(query, me); + generateBlockedUserQuery(query, me); - private userEntityService: UserEntityService, - private queryService: QueryService, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.usersRepository.createQueryBuilder('user') - .where('user.isLocked = FALSE') - .andWhere('user.isExplorable = TRUE') - .andWhere('user.host IS NULL') - .andWhere('user.updatedAt >= :date', { date: new Date(Date.now() - ms('7days')) }) - .andWhere('user.id != :meId', { meId: me.id }) - .orderBy('user.followersCount', 'DESC'); + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); - this.queryService.generateMutedUserQueryForUsers(query, me); - this.queryService.generateBlockQueryForUsers(query, me); - this.queryService.generateBlockedUserQuery(query, me); + query + .andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`); - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); + query.setParameters(followingQuery.getParameters()); - query - .andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`); + const users = await query.take(ps.limit).skip(ps.offset).getMany(); - query.setParameters(followingQuery.getParameters()); - - const users = await query.limit(ps.limit).offset(ps.offset).getMany(); - - return await this.userEntityService.packMany(users, me, { detail: true }); - }); - } -} + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts index 4f872b8393..233a6a90b4 100644 --- a/packages/backend/src/server/api/endpoints/users/relation.ts +++ b/packages/backend/src/server/api/endpoints/users/relation.ts @@ -1,17 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { Users } from '@/models/index.js'; +import define from '../../define.js'; export const meta = { tags: ['users'], - requireCredential: 'always', - kind: 'read:account', + requireCredential: true, description: 'Show the different kinds of relations between the authenticated user and the specified user(s).', @@ -54,10 +47,6 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, - isRenoteMuted: { - type: 'boolean', - optional: false, nullable: false, - }, }, }, { @@ -99,10 +88,6 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, - isRenoteMuted: { - type: 'boolean', - optional: false, nullable: false, - }, }, }, }, @@ -126,17 +111,11 @@ export const paramDef = { required: ['userId'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private userEntityService: UserEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const ids = Array.isArray(ps.userId) ? ps.userId : [ps.userId]; +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const ids = Array.isArray(ps.userId) ? ps.userId : [ps.userId]; - const relations = await Promise.all(ids.map(id => this.userEntityService.getRelation(me.id, id))); + const relations = await Promise.all(ids.map(id => Users.getRelation(me.id, id))); - return Array.isArray(ps.userId) ? relations : relations[0]; - }); - } -} + return Array.isArray(ps.userId) ? relations : relations[0]; +}); diff --git a/packages/backend/src/server/api/endpoints/users/report-abuse.ts b/packages/backend/src/server/api/endpoints/users/report-abuse.ts index e107dc43db..a9987eafa9 100644 --- a/packages/backend/src/server/api/endpoints/users/report-abuse.ts +++ b/packages/backend/src/server/api/endpoints/users/report-abuse.ts @@ -1,26 +1,17 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import sanitizeHtml from 'sanitize-html'; -import { Inject, Injectable } from '@nestjs/common'; -import type { AbuseUserReportsRepository, UserProfilesRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { EmailService } from '@/core/EmailService.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { RoleService } from '@/core/RoleService.js'; +import * as sanitizeHtml from 'sanitize-html'; +import { publishAdminStream } from '@/services/stream.js'; +import { AbuseUserReports, Users } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { sendEmail } from '@/services/send-email.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { getUser } from '../../common/getters.js'; import { ApiError } from '../../error.js'; +import define from '../../define.js'; export const meta = { tags: ['users'], - requireCredential: 'always', - kind: 'write:report-abuse', + requireCredential: true, description: 'File a report.', @@ -54,74 +45,56 @@ export const paramDef = { required: ['userId', 'comment'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.abuseUserReportsRepository) - private abuseUserReportsRepository: AbuseUserReportsRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + // Lookup user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - private idService: IdService, - private metaService: MetaService, - private emailService: EmailService, - private getterService: GetterService, - private roleService: RoleService, - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - // Lookup user - const user = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); - - if (user.id === me.id) { - throw new ApiError(meta.errors.cannotReportYourself); - } - - if (await this.roleService.isAdministrator(user)) { - throw new ApiError(meta.errors.cannotReportAdmin); - } - - const report = await this.abuseUserReportsRepository.insert({ - id: this.idService.gen(), - targetUserId: user.id, - targetUserHost: user.host, - reporterId: me.id, - reporterHost: null, - comment: ps.comment, - }).then(x => this.abuseUserReportsRepository.findOneByOrFail(x.identifiers[0])); - - // Publish event to moderators - setImmediate(async () => { - const moderators = await this.roleService.getModerators(); - - for (const moderator of moderators) { - this.globalEventService.publishAdminStream(moderator.id, 'newAbuseUserReport', { - id: report.id, - targetUserId: report.targetUserId, - reporterId: report.reporterId, - comment: report.comment, - }); - - const profile = await this.userProfilesRepository.findOneBy({ userId: moderator.id }); - - if (profile?.email) { - this.emailService.sendEmail(profile.email, 'New abuse report', - sanitizeHtml(ps.comment), - sanitizeHtml(ps.comment)); - } - } - - const meta = await this.metaService.fetch(); - if (meta.maintainerEmail) { - this.emailService.sendEmail(meta.maintainerEmail, 'New abuse report', - sanitizeHtml(ps.comment), - sanitizeHtml(ps.comment)); - } - }); - }); + if (user.id === me.id) { + throw new ApiError(meta.errors.cannotReportYourself); } -} + + if (user.isAdmin) { + throw new ApiError(meta.errors.cannotReportAdmin); + } + + const report = await AbuseUserReports.insert({ + id: genId(), + createdAt: new Date(), + targetUserId: user.id, + targetUserHost: user.host, + reporterId: me.id, + reporterHost: null, + comment: ps.comment, + }).then(x => AbuseUserReports.findOneByOrFail(x.identifiers[0])); + + // Publish event to moderators + setImmediate(async () => { + const moderators = await Users.find({ + where: [{ + isAdmin: true, + }, { + isModerator: true, + }], + }); + + for (const moderator of moderators) { + publishAdminStream(moderator.id, 'newAbuseUserReport', { + id: report.id, + targetUserId: report.targetUserId, + reporterId: report.reporterId, + comment: report.comment, + }); + } + + const meta = await fetchMeta(); + if (meta.email) { + sendEmail(meta.email, 'New abuse report', + sanitizeHtml(ps.comment), + sanitizeHtml(ps.comment)); + } + }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts index 99297f16f4..6e5bc46bb5 100644 --- a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts +++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts @@ -1,22 +1,13 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { Brackets } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, FollowingsRepository } from '@/models/_.js'; -import type { Config } from '@/config.js'; -import type { MiUser } from '@/models/User.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { Followings, Users } from '@/models/index.js'; +import { USER_ACTIVE_THRESHOLD } from '@/const.js'; +import { User } from '@/models/entities/user.js'; +import define from '../../define.js'; export const meta = { tags: ['users'], - requireCredential: 'conditional', + requireCredential: false, description: 'Search for a user by username and/or host.', @@ -34,11 +25,10 @@ export const meta = { export const paramDef = { type: 'object', properties: { - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - detail: { type: 'boolean', default: true }, - username: { type: 'string', nullable: true }, host: { type: 'string', nullable: true }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + detail: { type: 'boolean', default: true }, }, anyOf: [ { required: ['username'] }, @@ -46,92 +36,81 @@ export const paramDef = { ], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.config) - private config: Config, +// TODO: avatar,bannerをJOINしたいけどエラーになる - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, + if (ps.host) { + const q = Users.createQueryBuilder('user') + .where('user.isSuspended = FALSE') + .andWhere('user.host LIKE :host', { host: ps.host.toLowerCase() + '%' }); - private userEntityService: UserEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const setUsernameAndHostQuery = (query = this.usersRepository.createQueryBuilder('user')) => { - if (ps.username) { - query.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }); - } + if (ps.username) { + q.andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }); + } - if (ps.host) { - if (ps.host === this.config.hostname || ps.host === '.') { - query.andWhere('user.host IS NULL'); - } else { - query.andWhere('user.host LIKE :host', { - host: sqlLikeEscape(ps.host.toLowerCase()) + '%', - }); - } - } + q.andWhere('user.updatedAt IS NOT NULL'); + q.orderBy('user.updatedAt', 'DESC'); - return query; - }; + const users = await q.take(ps.limit).getMany(); - const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 + return await Users.packMany(users, me, { detail: ps.detail }); + } else if (ps.username) { + let users: User[] = []; - let users: MiUser[] = []; + if (me) { + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); - if (me) { - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); + const query = Users.createQueryBuilder('user') + .where(`user.id IN (${ followingQuery.getQuery() })`) + .andWhere('user.id != :meId', { meId: me.id }) + .andWhere('user.isSuspended = FALSE') + .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })); - const query = setUsernameAndHostQuery() - .andWhere(`user.id IN (${ followingQuery.getQuery() })`) + query.setParameters(followingQuery.getParameters()); + + users = await query + .orderBy('user.usernameLower', 'ASC') + .take(ps.limit) + .getMany(); + + if (users.length < ps.limit) { + const otherQuery = await Users.createQueryBuilder('user') + .where(`user.id NOT IN (${ followingQuery.getQuery() })`) .andWhere('user.id != :meId', { meId: me.id }) .andWhere('user.isSuspended = FALSE') - .andWhere(new Brackets(qb => { - qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })); - - query.setParameters(followingQuery.getParameters()); - - users = await query - .orderBy('user.usernameLower', 'ASC') - .limit(ps.limit) - .getMany(); - - if (users.length < ps.limit) { - const otherQuery = setUsernameAndHostQuery() - .andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`) - .andWhere('user.isSuspended = FALSE') - .andWhere('user.updatedAt IS NOT NULL'); - - otherQuery.setParameters(followingQuery.getParameters()); - - const otherUsers = await otherQuery - .orderBy('user.updatedAt', 'DESC') - .limit(ps.limit - users.length) - .getMany(); - - users = users.concat(otherUsers); - } - } else { - const query = setUsernameAndHostQuery() - .andWhere('user.isSuspended = FALSE') + .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) .andWhere('user.updatedAt IS NOT NULL'); - users = await query - .orderBy('user.updatedAt', 'DESC') - .limit(ps.limit - users.length) - .getMany(); - } + otherQuery.setParameters(followingQuery.getParameters()); - return await this.userEntityService.packMany(users, me, { detail: !!ps.detail }); - }); + const otherUsers = await otherQuery + .orderBy('user.updatedAt', 'DESC') + .take(ps.limit - users.length) + .getMany(); + + users = users.concat(otherUsers); + } + } else { + users = await Users.createQueryBuilder('user') + .where('user.isSuspended = FALSE') + .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) + .andWhere('user.updatedAt IS NOT NULL') + .orderBy('user.updatedAt', 'DESC') + .take(ps.limit - users.length) + .getMany(); + } + + return await Users.packMany(users, me, { detail: !!ps.detail }); } -} + + return []; +}); diff --git a/packages/backend/src/server/api/endpoints/users/search.ts b/packages/backend/src/server/api/endpoints/users/search.ts index f5cf98a70a..01729de667 100644 --- a/packages/backend/src/server/api/endpoints/users/search.ts +++ b/packages/backend/src/server/api/endpoints/users/search.ts @@ -1,21 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { Brackets } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { UserProfiles, Users } from '@/models/index.js'; +import { User } from '@/models/entities/user.js'; +import define from '../../define.js'; export const meta = { tags: ['users'], - requireCredential: 'conditional', + requireCredential: false, description: 'Search for users.', @@ -42,106 +33,90 @@ export const paramDef = { required: ['query'], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, + const isUsername = ps.query.startsWith('@'); - private userEntityService: UserEntityService, - ) { - super(meta, paramDef, async (ps, me) => { - const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 + let users: User[] = []; - ps.query = ps.query.trim(); - const isUsername = ps.query.startsWith('@'); + if (isUsername) { + const usernameQuery = Users.createQueryBuilder('user') + .where('user.usernameLower LIKE :username', { username: ps.query.replace('@', '').toLowerCase() + '%' }) + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE'); - let users: MiUser[] = []; + if (ps.origin === 'local') { + usernameQuery.andWhere('user.host IS NULL'); + } else if (ps.origin === 'remote') { + usernameQuery.andWhere('user.host IS NOT NULL'); + } - if (isUsername) { - const usernameQuery = this.usersRepository.createQueryBuilder('user') - .where('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.query.replace('@', '').toLowerCase()) + '%' }) - .andWhere(new Brackets(qb => { - qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })) - .andWhere('user.isSuspended = FALSE'); + users = await usernameQuery + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .take(ps.limit) + .skip(ps.offset) + .getMany(); + } else { + const nameQuery = Users.createQueryBuilder('user') + .where(new Brackets(qb => { + qb.where('user.name ILIKE :query', { query: '%' + ps.query + '%' }); - if (ps.origin === 'local') { - usernameQuery.andWhere('user.host IS NULL'); - } else if (ps.origin === 'remote') { - usernameQuery.andWhere('user.host IS NOT NULL'); + // Also search username if it qualifies as username + if (Users.validateLocalUsername(ps.query)) { + qb.orWhere('user.usernameLower LIKE :username', { username: '%' + ps.query.toLowerCase() + '%' }); } + })) + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE'); - users = await usernameQuery - .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') - .limit(ps.limit) - .offset(ps.offset) - .getMany(); - } else { - const nameQuery = this.usersRepository.createQueryBuilder('user') - .where(new Brackets(qb => { - qb.where('user.name ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' }); + if (ps.origin === 'local') { + nameQuery.andWhere('user.host IS NULL'); + } else if (ps.origin === 'remote') { + nameQuery.andWhere('user.host IS NOT NULL'); + } - // Also search username if it qualifies as username - if (this.userEntityService.validateLocalUsername(ps.query)) { - qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(ps.query.toLowerCase()) + '%' }); - } - })) - .andWhere(new Brackets(qb => { - qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })) - .andWhere('user.isSuspended = FALSE'); + users = await nameQuery + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .take(ps.limit) + .skip(ps.offset) + .getMany(); - if (ps.origin === 'local') { - nameQuery.andWhere('user.host IS NULL'); - } else if (ps.origin === 'remote') { - nameQuery.andWhere('user.host IS NOT NULL'); - } + if (users.length < ps.limit) { + const profQuery = UserProfiles.createQueryBuilder('prof') + .select('prof.userId') + .where('prof.description ILIKE :query', { query: '%' + ps.query + '%' }); - users = await nameQuery - .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') - .limit(ps.limit) - .offset(ps.offset) - .getMany(); - - if (users.length < ps.limit) { - const profQuery = this.userProfilesRepository.createQueryBuilder('prof') - .select('prof.userId') - .where('prof.description ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' }); - - if (ps.origin === 'local') { - profQuery.andWhere('prof.userHost IS NULL'); - } else if (ps.origin === 'remote') { - profQuery.andWhere('prof.userHost IS NOT NULL'); - } - - const query = this.usersRepository.createQueryBuilder('user') - .where(`user.id IN (${ profQuery.getQuery() })`) - .andWhere(new Brackets(qb => { - qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })) - .andWhere('user.isSuspended = FALSE') - .setParameters(profQuery.getParameters()); - - users = users.concat(await query - .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') - .limit(ps.limit) - .offset(ps.offset) - .getMany(), - ); - } + if (ps.origin === 'local') { + profQuery.andWhere('prof.userHost IS NULL'); + } else if (ps.origin === 'remote') { + profQuery.andWhere('prof.userHost IS NOT NULL'); } - return await this.userEntityService.packMany(users, me, { detail: ps.detail }); - }); + const query = Users.createQueryBuilder('user') + .where(`user.id IN (${ profQuery.getQuery() })`) + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE') + .setParameters(profQuery.getParameters()); + + users = users.concat(await query + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .take(ps.limit) + .skip(ps.offset) + .getMany(), + ); + } } -} + + return await Users.packMany(users, me, { detail: ps.detail }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 920c7cad6d..846d83b49f 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -1,26 +1,15 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { In, IsNull } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; -import { DI } from '@/di-symbols.js'; -import PerUserPvChart from '@/core/chart/charts/per-user-pv.js'; -import { RoleService } from '@/core/RoleService.js'; +import { FindOptionsWhere, In, IsNull } from 'typeorm'; +import { resolveUser } from '@/remote/resolve-user.js'; +import { Users } from '@/models/index.js'; +import { User } from '@/models/entities/user.js'; +import define from '../../define.js'; +import { apiLogger } from '../../logger.js'; import { ApiError } from '../../error.js'; -import { ApiLoggerService } from '../../ApiLoggerService.js'; -import type { FindOptionsWhere } from 'typeorm'; export const meta = { tags: ['users'], - requireCredential: 'conditional', + requireCredential: false, description: 'Show the properties of a user.', @@ -53,102 +42,89 @@ export const meta = { message: 'No such user.', code: 'NO_SUCH_USER', id: '4362f8dc-731f-4ad8-a694-be5a88922a24', - httpStatusCode: 404, }, }, } as const; export const paramDef = { type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - userIds: { type: 'array', uniqueItems: true, items: { - type: 'string', format: 'misskey:id', - } }, - username: { type: 'string' }, - host: { - type: 'string', - nullable: true, - description: 'The local host is represented with `null`.', - }, - }, anyOf: [ - { required: ['userId'] }, - { required: ['userIds'] }, - { required: ['username'] }, + { + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], + }, + { + properties: { + userIds: { type: 'array', uniqueItems: true, items: { + type: 'string', format: 'misskey:id', + } }, + }, + required: ['userIds'], + }, + { + properties: { + username: { type: 'string' }, + host: { + type: 'string', + nullable: true, + description: 'The local host is represented with `null`.', + }, + }, + required: ['username'], + }, ], } as const; -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + let user; - private userEntityService: UserEntityService, - private remoteUserResolveService: RemoteUserResolveService, - private roleService: RoleService, - private perUserPvChart: PerUserPvChart, - private apiLoggerService: ApiLoggerService, - ) { - super(meta, paramDef, async (ps, me, _1, _2, _3, ip) => { - let user; + const isAdminOrModerator = me && (me.isAdmin || me.isModerator); - const isModerator = await this.roleService.isModerator(me); - ps.username = ps.username?.trim(); + if (ps.userIds) { + if (ps.userIds.length === 0) { + return []; + } - if (ps.userIds) { - if (ps.userIds.length === 0) { - return []; - } + const users = await Users.findBy(isAdminOrModerator ? { + id: In(ps.userIds), + } : { + id: In(ps.userIds), + isSuspended: false, + }); - const users = await this.usersRepository.findBy(isModerator ? { - id: In(ps.userIds), - } : { - id: In(ps.userIds), - isSuspended: false, - }); + // リクエストされた通りに並べ替え + const _users: User[] = []; + for (const id of ps.userIds) { + _users.push(users.find(x => x.id === id)!); + } - // リクエストされた通りに並べ替え - const _users: MiUser[] = []; - for (const id of ps.userIds) { - _users.push(users.find(x => x.id === id)!); - } + return await Promise.all(_users.map(u => Users.pack(u, me, { + detail: true, + }))); + } else { + // Lookup user + if (typeof ps.host === 'string' && typeof ps.username === 'string') { + user = await resolveUser(ps.username, ps.host).catch(e => { + apiLogger.warn(`failed to resolve remote user: ${e}`); + throw new ApiError(meta.errors.failedToResolveRemoteUser); + }); + } else { + const q: FindOptionsWhere = ps.userId != null + ? { id: ps.userId } + : { usernameLower: ps.username!.toLowerCase(), host: IsNull() }; - return await Promise.all(_users.map(u => this.userEntityService.pack(u, me, { - detail: true, - }))); - } else { - // Lookup user - if (typeof ps.host === 'string' && typeof ps.username === 'string') { - user = await this.remoteUserResolveService.resolveUser(ps.username, ps.host).catch(err => { - this.apiLoggerService.logger.warn(`failed to resolve remote user: ${err}`); - throw new ApiError(meta.errors.failedToResolveRemoteUser); - }); - } else { - const q: FindOptionsWhere = ps.userId != null - ? { id: ps.userId } - : { usernameLower: ps.username!.toLowerCase(), host: IsNull() }; + user = await Users.findOneBy(q); + } - user = await this.usersRepository.findOneBy(q); - } + if (user == null || (!isAdminOrModerator && user.isSuspended)) { + throw new ApiError(meta.errors.noSuchUser); + } - if (user == null || (!isModerator && user.isSuspended)) { - throw new ApiError(meta.errors.noSuchUser); - } - - if (user.host == null) { - if (me == null && ip != null) { - this.perUserPvChart.commitByVisitor(user, ip); - } else if (me && me.id !== user.id) { - this.perUserPvChart.commitByUser(user, me.id); - } - } - - return await this.userEntityService.pack(user, me, { - detail: true, - }); - } + return await Users.pack(user, me, { + detail: true, }); } -} +}); diff --git a/packages/backend/src/server/api/endpoints/users/stats.ts b/packages/backend/src/server/api/endpoints/users/stats.ts new file mode 100644 index 0000000000..47f322ee9b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/stats.ts @@ -0,0 +1,193 @@ +import { DriveFiles, Followings, NoteFavorites, NoteReactions, Notes, PageLikes, PollVotes, Users } from '@/models/index.js'; +import { awaitAll } from '@/prelude/await-all.js'; +import define from '../../define.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['users'], + + requireCredential: false, + + description: 'Show statistics about a user.', + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '9e638e45-3b25-4ef7-8f95-07e8498f1819', + }, + }, + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + notesCount: { + type: 'integer', + optional: false, nullable: false, + }, + repliesCount: { + type: 'integer', + optional: false, nullable: false, + }, + renotesCount: { + type: 'integer', + optional: false, nullable: false, + }, + repliedCount: { + type: 'integer', + optional: false, nullable: false, + }, + renotedCount: { + type: 'integer', + optional: false, nullable: false, + }, + pollVotesCount: { + type: 'integer', + optional: false, nullable: false, + }, + pollVotedCount: { + type: 'integer', + optional: false, nullable: false, + }, + localFollowingCount: { + type: 'integer', + optional: false, nullable: false, + }, + remoteFollowingCount: { + type: 'integer', + optional: false, nullable: false, + }, + localFollowersCount: { + type: 'integer', + optional: false, nullable: false, + }, + remoteFollowersCount: { + type: 'integer', + optional: false, nullable: false, + }, + followingCount: { + type: 'integer', + optional: false, nullable: false, + }, + followersCount: { + type: 'integer', + optional: false, nullable: false, + }, + sentReactionsCount: { + type: 'integer', + optional: false, nullable: false, + }, + receivedReactionsCount: { + type: 'integer', + optional: false, nullable: false, + }, + noteFavoritesCount: { + type: 'integer', + optional: false, nullable: false, + }, + pageLikesCount: { + type: 'integer', + optional: false, nullable: false, + }, + pageLikedCount: { + type: 'integer', + optional: false, nullable: false, + }, + driveFilesCount: { + type: 'integer', + optional: false, nullable: false, + }, + driveUsage: { + type: 'integer', + optional: false, nullable: false, + description: 'Drive usage in bytes', + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy({ id: ps.userId }); + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const result = await awaitAll({ + notesCount: Notes.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + repliesCount: Notes.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .andWhere('note.replyId IS NOT NULL') + .getCount(), + renotesCount: Notes.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .andWhere('note.renoteId IS NOT NULL') + .getCount(), + repliedCount: Notes.createQueryBuilder('note') + .where('note.replyUserId = :userId', { userId: user.id }) + .getCount(), + renotedCount: Notes.createQueryBuilder('note') + .where('note.renoteUserId = :userId', { userId: user.id }) + .getCount(), + pollVotesCount: PollVotes.createQueryBuilder('vote') + .where('vote.userId = :userId', { userId: user.id }) + .getCount(), + pollVotedCount: PollVotes.createQueryBuilder('vote') + .innerJoin('vote.note', 'note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + localFollowingCount: Followings.createQueryBuilder('following') + .where('following.followerId = :userId', { userId: user.id }) + .andWhere('following.followeeHost IS NULL') + .getCount(), + remoteFollowingCount: Followings.createQueryBuilder('following') + .where('following.followerId = :userId', { userId: user.id }) + .andWhere('following.followeeHost IS NOT NULL') + .getCount(), + localFollowersCount: Followings.createQueryBuilder('following') + .where('following.followeeId = :userId', { userId: user.id }) + .andWhere('following.followerHost IS NULL') + .getCount(), + remoteFollowersCount: Followings.createQueryBuilder('following') + .where('following.followeeId = :userId', { userId: user.id }) + .andWhere('following.followerHost IS NOT NULL') + .getCount(), + sentReactionsCount: NoteReactions.createQueryBuilder('reaction') + .where('reaction.userId = :userId', { userId: user.id }) + .getCount(), + receivedReactionsCount: NoteReactions.createQueryBuilder('reaction') + .innerJoin('reaction.note', 'note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + noteFavoritesCount: NoteFavorites.createQueryBuilder('favorite') + .where('favorite.userId = :userId', { userId: user.id }) + .getCount(), + pageLikesCount: PageLikes.createQueryBuilder('like') + .where('like.userId = :userId', { userId: user.id }) + .getCount(), + pageLikedCount: PageLikes.createQueryBuilder('like') + .innerJoin('like.page', 'page') + .where('page.userId = :userId', { userId: user.id }) + .getCount(), + driveFilesCount: DriveFiles.createQueryBuilder('file') + .where('file.userId = :userId', { userId: user.id }) + .getCount(), + driveUsage: DriveFiles.calcDriveUsageOf(user), + }); + + result.followingCount = result.localFollowingCount + result.remoteFollowingCount; + result.followersCount = result.localFollowersCount + result.remoteFollowersCount; + + return result; +}); diff --git a/packages/backend/src/server/api/endpoints/users/update-memo.ts b/packages/backend/src/server/api/endpoints/users/update-memo.ts deleted file mode 100644 index e108ff9334..0000000000 --- a/packages/backend/src/server/api/endpoints/users/update-memo.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { IdService } from '@/core/IdService.js'; -import type { UserMemoRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { GetterService } from '@/server/api/GetterService.js'; -import { ApiError } from '../../error.js'; - -export const meta = { - tags: ['account'], - - requireCredential: 'always', - - kind: 'write:account', - - errors: { - noSuchUser: { - message: 'No such user.', - code: 'NO_SUCH_USER', - id: '6fef56f3-e765-4957-88e5-c6f65329b8a5', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - memo: { - type: 'string', - nullable: true, - description: 'A personal memo for the target user. If null or empty, delete the memo.', - }, - }, - required: ['userId', 'memo'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.userMemosRepository) - private userMemosRepository: UserMemoRepository, - private getterService: GetterService, - private idService: IdService, - ) { - super(meta, paramDef, async (ps, me) => { - // Get target - const target = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); - - // 引数がnullか空文字であれば、パーソナルメモを削除する - if (ps.memo === '' || ps.memo == null) { - await this.userMemosRepository.delete({ - userId: me.id, - targetUserId: target.id, - }); - return; - } - - // 以前に作成されたパーソナルメモがあるかどうか確認 - const previousMemo = await this.userMemosRepository.findOneBy({ - userId: me.id, - targetUserId: target.id, - }); - - if (!previousMemo) { - await this.userMemosRepository.insert({ - id: this.idService.gen(), - userId: me.id, - targetUserId: target.id, - memo: ps.memo, - }); - } else { - await this.userMemosRepository.update(previousMemo.id, { - userId: me.id, - targetUserId: target.id, - memo: ps.memo, - }); - } - }); - } -} diff --git a/packages/backend/src/server/api/error.ts b/packages/backend/src/server/api/error.ts index 6506565a0d..3f0861fdb1 100644 --- a/packages/backend/src/server/api/error.ts +++ b/packages/backend/src/server/api/error.ts @@ -1,9 +1,4 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -type E = { message: string, code: string, id: string, kind?: 'client' | 'server' | 'permission', httpStatusCode?: number }; +type E = { message: string, code: string, id: string, kind?: 'client' | 'server', httpStatusCode?: number }; export class ApiError extends Error { public message: string; @@ -13,8 +8,8 @@ export class ApiError extends Error { public httpStatusCode?: number; public info?: any; - constructor(err?: E | null | undefined, info?: any | null | undefined) { - if (err == null) err = { + constructor(e?: E | null | undefined, info?: any | null | undefined) { + if (e == null) e = { message: 'Internal error occurred. Please contact us if the error persists.', code: 'INTERNAL_ERROR', id: '5d37dbcb-891e-41ca-a3d6-e690c97775ac', @@ -22,12 +17,12 @@ export class ApiError extends Error { httpStatusCode: 500, }; - super(err.message); - this.message = err.message; - this.code = err.code; - this.id = err.id; - this.kind = err.kind ?? 'client'; - this.httpStatusCode = err.httpStatusCode; + super(e.message); + this.message = e.message; + this.code = e.code; + this.id = e.id; + this.kind = e.kind || 'client'; + this.httpStatusCode = e.httpStatusCode; this.info = info; } } diff --git a/packages/backend/src/server/api/index.ts b/packages/backend/src/server/api/index.ts new file mode 100644 index 0000000000..83ece51f51 --- /dev/null +++ b/packages/backend/src/server/api/index.ts @@ -0,0 +1,126 @@ +/** + * API Server + */ + +import Koa from 'koa'; +import Router from '@koa/router'; +import multer from '@koa/multer'; +import bodyParser from 'koa-bodyparser'; +import cors from '@koa/cors'; + +import { Instances, AccessTokens, Users } from '@/models/index.js'; +import config from '@/config/index.js'; +import endpoints from './endpoints.js'; +import handler from './api-handler.js'; +import signup from './private/signup.js'; +import signin from './private/signin.js'; +import signupPending from './private/signup-pending.js'; +import discord from './service/discord.js'; +import github from './service/github.js'; +import twitter from './service/twitter.js'; + +// Init app +const app = new Koa(); + +app.use(cors({ + origin: '*', +})); + +// No caching +app.use(async (ctx, next) => { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + await next(); +}); + +app.use(bodyParser({ + // リクエストが multipart/form-data でない限りはJSONだと見なす + detectJSON: ctx => !ctx.is('multipart/form-data'), +})); + +// Init multer instance +const upload = multer({ + storage: multer.diskStorage({}), + limits: { + fileSize: config.maxFileSize || 262144000, + files: 1, + }, +}); + +// Init router +const router = new Router(); + +/** + * Register endpoint handlers + */ +for (const endpoint of endpoints) { + if (endpoint.meta.requireFile) { + router.post(`/${endpoint.name}`, upload.single('file'), handler.bind(null, endpoint)); + } else { + // 後方互換性のため + if (endpoint.name.includes('-')) { + router.post(`/${endpoint.name.replace(/-/g, '_')}`, handler.bind(null, endpoint)); + + if (endpoint.meta.allowGet) { + router.get(`/${endpoint.name.replace(/-/g, '_')}`, handler.bind(null, endpoint)); + } else { + router.get(`/${endpoint.name.replace(/-/g, '_')}`, async ctx => { ctx.status = 405; }); + } + } + + router.post(`/${endpoint.name}`, handler.bind(null, endpoint)); + + if (endpoint.meta.allowGet) { + router.get(`/${endpoint.name}`, handler.bind(null, endpoint)); + } else { + router.get(`/${endpoint.name}`, async ctx => { ctx.status = 405; }); + } + } +} + +router.post('/signup', signup); +router.post('/signin', signin); +router.post('/signup-pending', signupPending); + +router.use(discord.routes()); +router.use(github.routes()); +router.use(twitter.routes()); + +router.get('/v1/instance/peers', async ctx => { + const instances = await Instances.find({ + select: ['host'], + }); + + ctx.body = instances.map(instance => instance.host); +}); + +router.post('/miauth/:session/check', async ctx => { + const token = await AccessTokens.findOneBy({ + session: ctx.params.session, + }); + + if (token && token.session != null && !token.fetched) { + AccessTokens.update(token.id, { + fetched: true, + }); + + ctx.body = { + ok: true, + token: token.token, + user: await Users.pack(token.userId, null, { detail: true }), + }; + } else { + ctx.body = { + ok: false, + }; + } +}); + +// Return 404 for unknown API +router.all('(.*)', async ctx => { + ctx.status = 404; +}); + +// Register router +app.use(router.routes()); + +export default app; diff --git a/packages/backend/src/server/api/limiter.ts b/packages/backend/src/server/api/limiter.ts new file mode 100644 index 0000000000..9a7751716e --- /dev/null +++ b/packages/backend/src/server/api/limiter.ts @@ -0,0 +1,77 @@ +import Limiter from 'ratelimiter'; +import { CacheableLocalUser, User } from '@/models/entities/user.js'; +import Logger from '@/services/logger.js'; +import { redisClient } from '../../db/redis.js'; +import { IEndpointMeta } from './endpoints.js'; + +const logger = new Logger('limiter'); + +export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable }, actor: string) => new Promise((ok, reject) => { + if (process.env.NODE_ENV === 'test') ok(); + + const hasShortTermLimit = typeof limitation.minInterval === 'number'; + + const hasLongTermLimit = + typeof limitation.duration === 'number' && + typeof limitation.max === 'number'; + + if (hasShortTermLimit) { + min(); + } else if (hasLongTermLimit) { + max(); + } else { + ok(); + } + + // Short-term limit + function min(): void { + const minIntervalLimiter = new Limiter({ + id: `${actor}:${limitation.key}:min`, + duration: limitation.minInterval, + max: 1, + db: redisClient, + }); + + minIntervalLimiter.get((err, info) => { + if (err) { + return reject('ERR'); + } + + logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`); + + if (info.remaining === 0) { + reject('BRIEF_REQUEST_INTERVAL'); + } else { + if (hasLongTermLimit) { + max(); + } else { + ok(); + } + } + }); + } + + // Long term limit + function max(): void { + const limiter = new Limiter({ + id: `${actor}:${limitation.key}`, + duration: limitation.duration, + max: limitation.max, + db: redisClient, + }); + + limiter.get((err, info) => { + if (err) { + return reject('ERR'); + } + + logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`); + + if (info.remaining === 0) { + reject('RATE_LIMIT_EXCEEDED'); + } else { + ok(); + } + }); + } +}); diff --git a/packages/backend/src/server/api/logger.ts b/packages/backend/src/server/api/logger.ts new file mode 100644 index 0000000000..ec22d6c3e2 --- /dev/null +++ b/packages/backend/src/server/api/logger.ts @@ -0,0 +1,3 @@ +import Logger from '@/services/logger.js'; + +export const apiLogger = new Logger('api'); diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts deleted file mode 100644 index 5706a17786..0000000000 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ /dev/null @@ -1,906 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import megalodon, { Entity, MegalodonInterface } from 'megalodon'; -import querystring from 'querystring'; -import { IsNull } from 'typeorm'; -import multer from 'fastify-multer'; -import type { AccessTokensRepository, NoteEditRepository, NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; -import type { Config } from '@/config.js'; -import { MetaService } from '@/core/MetaService.js'; -import { convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag, convertList, MastoConverters } from './converters.js'; -import { getInstance } from './endpoints/meta.js'; -import { ApiAuthMastodon, ApiAccountMastodon, ApiFilterMastodon, ApiNotifyMastodon, ApiSearchMastodon, ApiTimelineMastodon, ApiStatusMastodon } from './endpoints.js'; -import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DriveService } from '@/core/DriveService.js'; - -export function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface { - const accessTokenArr = authorization?.split(' ') ?? [null]; - const accessToken = accessTokenArr[accessTokenArr.length - 1]; - const generator = (megalodon as any).default; - const client = generator('misskey', BASE_URL, accessToken) as MegalodonInterface; - return client; -} - -@Injectable() -export class MastodonApiServerService { - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - @Inject(DI.noteEditRepository) - private noteEditRepository: NoteEditRepository, - @Inject(DI.accessTokensRepository) - private accessTokensRepository: AccessTokensRepository, - @Inject(DI.config) - private config: Config, - private metaService: MetaService, - private userEntityService: UserEntityService, - private driveService: DriveService, - private mastoConverter: MastoConverters, - ) { } - - @bindThis - public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) { - const upload = multer({ - storage: multer.diskStorage({}), - limits: { - fileSize: this.config.maxFileSize || 262144000, - files: 1, - }, - }); - - fastify.addHook('onRequest', (request, reply, done) => { - reply.header('Access-Control-Allow-Origin', '*'); - done(); - }); - - fastify.addContentTypeParser('application/x-www-form-urlencoded', (request, payload, done) => { - let body = ''; - payload.on('data', (data) => { - body += data; - }); - payload.on('end', () => { - try { - const parsed = querystring.parse(body); - done(null, parsed); - } catch (e: any) { - done(e); - } - }); - payload.on('error', done); - }); - - fastify.register(multer.contentParser); - - fastify.get('/v1/custom_emojis', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getInstanceCustomEmojis(); - reply.send(data.data); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - - fastify.get('/v1/instance', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in - try { - const data = await client.getInstance(); - const admin = await this.usersRepository.findOne({ - where: { - host: IsNull(), - isRoot: true, - isDeleted: false, - isSuspended: false, - }, - order: { id: 'ASC' }, - }); - const contact = admin == null ? null : await this.mastoConverter.convertAccount((await client.getAccount(admin.id)).data); - reply.send(await getInstance(data.data, contact as Entity.Account, this.config, await this.metaService.fetch())); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get('/v1/announcements', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getInstanceAnnouncements(); - reply.send(data.data.map((announcement) => convertAnnouncement(announcement))); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.post<{ Body: { id: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.dismissInstanceAnnouncement( - _request.body['id'], - ); - reply.send(data.data); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); - } - }, - ); - - fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const multipartData = await _request.file; - if (!multipartData) { - reply.code(401).send({ error: 'No image' }); - return; - } - const data = await client.uploadMedia(multipartData); - reply.send(convertAttachment(data.data as Entity.Attachment)); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.post('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const multipartData = await _request.file; - if (!multipartData) { - reply.code(401).send({ error: 'No image' }); - return; - } - const data = await client.uploadMedia(multipartData, _request.body!); - reply.send(convertAttachment(data.data as Entity.Attachment)); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get('/v1/filters', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in - try { - const data = await client.getFilters(); - reply.send(data.data.map((filter) => convertFilter(filter))); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get('/v1/trends', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in - try { - const data = await client.getInstanceTrends(); - reply.send(data.data); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get('/v1/trends/tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in - try { - const data = await client.getInstanceTrends(); - reply.send(data.data); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get('/v1/trends/links', async (_request, reply) => { - // As we do not have any system for news/links this will just return empty - reply.send([]); - }); - - fastify.post('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const client = getClient(BASE_URL, ''); // we are using this here, because in private mode some info isnt - // displayed without being logged in - try { - const data = await ApiAuthMastodon(_request, client); - reply.send(data); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get('/v1/preferences', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in - try { - const data = await client.getPreferences(); - reply.send(data.data); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); - } - }); - - //#region Accounts - fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in - try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.verifyCredentials()); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.patch('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in - try { - // Check if there is an Header or Avatar being uploaded, if there is proceed to upload it to the drive of the user and then set it. - if (_request.files.length > 0 && accessTokens) { - const tokeninfo = await this.accessTokensRepository.findOneBy({ token: accessTokens.replace('Bearer ', '') }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const avatar = (_request.files as any).find((obj: any) => { - return obj.fieldname === 'avatar'; - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const header = (_request.files as any).find((obj: any) => { - return obj.fieldname === 'header'; - }); - - if (tokeninfo && avatar) { - const upload = await this.driveService.addFile({ - user: { id: tokeninfo.userId, host: null }, - path: avatar.path, - name: avatar.originalname !== null && avatar.originalname !== 'file' ? avatar.originalname : undefined, - sensitive: false, - }); - if (upload.type.startsWith('image/')) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_request.body as any).avatar = upload.id; - } - } else if (tokeninfo && header) { - const upload = await this.driveService.addFile({ - user: { id: tokeninfo.userId, host: null }, - path: header.path, - name: header.originalname !== null && header.originalname !== 'file' ? header.originalname : undefined, - sensitive: false, - }); - if (upload.type.startsWith('image/')) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_request.body as any).header = upload.id; - } - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if ((_request.body as any).fields_attributes) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const fields = (_request.body as any).fields_attributes.map((field: any) => { - if (!(field.name.trim() === '' && field.value.trim() === '')) { - if (field.name.trim() === '') return reply.code(400).send('Field name can not be empty'); - if (field.value.trim() === '') return reply.code(400).send('Field value can not be empty'); - } - return { - ...field, - }; - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_request.body as any).fields_attributes = fields.filter((field: any) => field.name.trim().length > 0 && field.value.length > 0); - } - - const data = await client.updateCredentials(_request.body!); - reply.send(await this.mastoConverter.convertAccount(data.data)); - } catch (e: any) { - //console.error(e); - reply.code(401).send(e.response.data); - } - }); - - fastify.get('/v1/accounts/lookup', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in - try { - const data = await client.search((_request.query as any).acct, { type: 'accounts' }); - const profile = await this.userProfilesRepository.findOneBy({ userId: data.data.accounts[0].id }); - data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) || []; - reply.send(await this.mastoConverter.convertAccount(data.data.accounts[0])); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get('/v1/accounts/relationships', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in - let users; - try { - let ids = _request.query ? (_request.query as any)['id[]'] ?? (_request.query as any)['id'] : null; - if (typeof ids === 'string') { - ids = [ids]; - } - users = ids; - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.getRelationships(users)); - } catch (e: any) { - /* console.error(e); */ - const data = e.response.data; - data.users = users; - console.error(data); - reply.code(401).send(data); - } - }); - - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const sharkId = _request.params.id; - const data = await client.getAccount(sharkId); - const account = await this.mastoConverter.convertAccount(data.data); - reply.send(account); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/statuses', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.getStatuses()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getFeaturedTags(); - reply.send(data.data.map((tag) => convertFeaturedTag(tag))); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/followers', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.getFollowers()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/following', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.getFollowing()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getAccountLists(_request.params.id); - reply.send(data.data.map((list) => convertList(list))); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.addFollow()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.rmFollow()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.addBlock()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.rmBlock()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.addMute()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.rmMute()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get('/v1/followed_tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getFollowedTags(); - reply.send(data.data); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get('/v1/bookmarks', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.getBookmarks()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get('/v1/favourites', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.getFavourites()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get('/v1/mutes', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.getMutes()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get('/v1/blocks', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.getBlocks()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get('/v1/follow_requests', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getFollowRequests( ((_request.query as any) || { limit: 20 }).limit ); - reply.send(await Promise.all(data.data.map(async (account) => await this.mastoConverter.convertAccount(account as Entity.Account)))); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.post<{ Params: { id: string } }>('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.acceptFollow()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.post<{ Params: { id: string } }>('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.rejectFollow()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - //#endregion - - //#region Search - fastify.get('/v1/search', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await search.SearchV1()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get('/v2/search', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await search.SearchV2()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get('/v1/trends/statuses', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await search.getStatusTrends()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get('/v2/suggestions', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await search.getSuggestions()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - //#endregion - - //#region Notifications - fastify.get('/v1/notifications', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const notify = new ApiNotifyMastodon(_request, client); - reply.send(await notify.getNotifications()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.get<{ Params: { id: string } }>('/v1/notification/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const notify = new ApiNotifyMastodon(_request, client); - reply.send(await notify.getNotification()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.post<{ Params: { id: string } }>('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const notify = new ApiNotifyMastodon(_request, client); - reply.send(await notify.rmNotification()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - - fastify.post('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const notify = new ApiNotifyMastodon(_request, client); - reply.send(await notify.rmNotifications()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); - } - }); - //#endregion - - //#region Filters - fastify.get<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const filter = new ApiFilterMastodon(_request, client); - !_request.params.id ? reply.send(await filter.getFilters()) : reply.send(await filter.getFilter()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - - fastify.post('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const filter = new ApiFilterMastodon(_request, client); - reply.send(await filter.createFilter()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - - fastify.post<{ Params: { id: string } }>('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const filter = new ApiFilterMastodon(_request, client); - reply.send(await filter.updateFilter()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - - fastify.delete<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const filter = new ApiFilterMastodon(_request, client); - reply.send(await filter.rmFilter()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - //#endregion - - //#region Timelines - const TLEndpoint = new ApiTimelineMastodon(fastify, this.config, this.mastoConverter); - - // GET Endpoints - TLEndpoint.getTL(); - TLEndpoint.getHomeTl(); - TLEndpoint.getListTL(); - TLEndpoint.getTagTl(); - TLEndpoint.getConversations(); - TLEndpoint.getList(); - TLEndpoint.getLists(); - TLEndpoint.getListAccounts(); - - // POST Endpoints - TLEndpoint.createList(); - TLEndpoint.addListAccount(); - - // PUT Endpoint - TLEndpoint.updateList(); - - // DELETE Endpoints - TLEndpoint.deleteList(); - TLEndpoint.rmListAccount(); - //#endregion - - //#region Status - const NoteEndpoint = new ApiStatusMastodon(fastify, this.mastoConverter); - - // GET Endpoints - NoteEndpoint.getStatus(); - NoteEndpoint.getStatusSource(); - NoteEndpoint.getContext(); - NoteEndpoint.getHistory(); - NoteEndpoint.getReblogged(); - NoteEndpoint.getFavourites(); - NoteEndpoint.getMedia(); - NoteEndpoint.getPoll(); - - //POST Endpoints - NoteEndpoint.postStatus(); - NoteEndpoint.addFavourite(); - NoteEndpoint.rmFavourite(); - NoteEndpoint.reblogStatus(); - NoteEndpoint.unreblogStatus(); - NoteEndpoint.bookmarkStatus(); - NoteEndpoint.unbookmarkStatus(); - NoteEndpoint.pinStatus(); - NoteEndpoint.unpinStatus(); - NoteEndpoint.reactStatus(); - NoteEndpoint.unreactStatus(); - NoteEndpoint.votePoll(); - - // PUT Endpoint - fastify.put<{ Params: { id: string } }>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.updateMedia(_request.params.id, _request.body!); - reply.send(convertAttachment(data.data)); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); - } - }); - NoteEndpoint.updateStatus(); - - // DELETE Endpoint - NoteEndpoint.deleteStatus(); - //#endregion - done(); - } -} diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts deleted file mode 100644 index cce7f798fe..0000000000 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Entity } from 'megalodon'; -import mfm from '@sharkey/sfm-js'; -import { DI } from '@/di-symbols.js'; -import { MfmService } from '@/core/MfmService.js'; -import type { Config } from '@/config.js'; -import type { IMentionedRemoteUsers } from '@/models/Note.js'; -import type { MiUser } from '@/models/User.js'; -import type { NoteEditRepository, NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import { CustomEmojiService } from '@/core/CustomEmojiService.js'; -import { GetterService } from '../GetterService.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { IdService } from '@/core/IdService.js'; - -export enum IdConvertType { - MastodonId, - SharkeyId, -} - -export const escapeMFM = (text: string): string => text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - .replace(/`/g, '`') - .replace(/\r?\n/g, '
'); - -@Injectable() -export class MastoConverters { - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.noteEditRepository) - private noteEditRepository: NoteEditRepository, - - private mfmService: MfmService, - private getterService: GetterService, - private customEmojiService: CustomEmojiService, - private idService: IdService, - private driveFileEntityService: DriveFileEntityService, - ) { - } - - private encode(u: MiUser, m: IMentionedRemoteUsers): Entity.Mention { - let acct = u.username; - let acctUrl = `https://${u.host || this.config.host}/@${u.username}`; - let url: string | null = null; - if (u.host) { - const info = m.find(r => r.username === u.username && r.host === u.host); - acct = `${u.username}@${u.host}`; - acctUrl = `https://${u.host}/@${u.username}`; - if (info) url = info.url ?? info.uri; - } - return { - id: u.id, - username: u.username, - acct: acct, - url: url ?? acctUrl, - }; - } - - public fileType(s: string): 'unknown' | 'image' | 'gifv' | 'video' | 'audio' { - if (s === 'image/gif') { - return 'gifv'; - } - if (s.includes('image')) { - return 'image'; - } - if (s.includes('video')) { - return 'video'; - } - if (s.includes('audio')) { - return 'audio'; - } - return 'unknown'; - } - - public encodeFile(f: any): Entity.Attachment { - return { - id: f.id, - type: this.fileType(f.type), - url: f.url, - remote_url: f.url, - preview_url: f.thumbnailUrl, - text_url: f.url, - meta: { - width: f.properties.width, - height: f.properties.height - }, - description: f.comment ? f.comment : null, - blurhash: f.blurhash ? f.blurhash : null - }; - } - - public async getUser(id: string): Promise { - return this.getterService.getUser(id).then(p => { - return p; - }); - } - - private async encodeField(f: Entity.Field): Promise { - return { - name: f.name, - value: await this.mfmService.toMastoHtml(mfm.parse(f.value), [], true) ?? escapeMFM(f.value), - verified_at: null, - }; - } - - public async convertAccount(account: Entity.Account | MiUser) { - const user = await this.getUser(account.id); - const profile = await this.userProfilesRepository.findOneBy({ userId: user.id }); - const emojis = await this.customEmojiService.populateEmojis(user.emojis, user.host ? user.host : this.config.host); - const emoji: Entity.Emoji[] = []; - Object.entries(emojis).forEach(entry => { - const [key, value] = entry; - emoji.push({ - shortcode: key, - static_url: value, - url: value, - visible_in_picker: true, - category: undefined, - }); - }); - const fqn = `${user.username}@${user.host ?? this.config.hostname}`; - let acct = user.username; - let acctUrl = `https://${user.host || this.config.host}/@${user.username}`; - const acctUri = `https://${this.config.host}/users/${user.id}`; - if (user.host) { - acct = `${user.username}@${user.host}`; - acctUrl = `https://${user.host}/@${user.username}`; - } - return awaitAll({ - id: account.id, - username: user.username, - acct: acct, - fqn: fqn, - display_name: user.name ?? user.username, - locked: user.isLocked, - created_at: this.idService.parse(user.id).date.toISOString(), - followers_count: user.followersCount, - following_count: user.followingCount, - statuses_count: user.notesCount, - note: profile?.description ?? '', - url: user.uri ?? acctUrl, - uri: user.uri ?? acctUri, - avatar: user.avatarUrl ? user.avatarUrl : 'https://dev.joinsharkey.org/static-assets/avatar.png', - avatar_static: user.avatarUrl ? user.avatarUrl : 'https://dev.joinsharkey.org/static-assets/avatar.png', - header: user.bannerUrl ? user.bannerUrl : 'https://dev.joinsharkey.org/static-assets/transparent.png', - header_static: user.bannerUrl ? user.bannerUrl : 'https://dev.joinsharkey.org/static-assets/transparent.png', - emojis: emoji, - moved: null, //FIXME - fields: Promise.all(profile?.fields.map(async p => this.encodeField(p)) ?? []), - bot: user.isBot, - discoverable: user.isExplorable, - }); - } - - public async getEdits(id: string) { - const note = await this.getterService.getNote(id); - if (!note) { - return {}; - } - - const noteUser = await this.getUser(note.userId).then(async (p) => await this.convertAccount(p)); - const edits = await this.noteEditRepository.find({ where: { noteId: note.id }, order: { id: 'ASC' } }); - const history: Promise[] = []; - - let lastDate = this.idService.parse(note.id).date; - for (const edit of edits) { - const files = this.driveFileEntityService.packManyByIds(edit.fileIds); - const item = { - account: noteUser, - content: this.mfmService.toMastoHtml(mfm.parse(edit.newText ?? ''), JSON.parse(note.mentionedRemoteUsers)).then(p => p ?? ''), - created_at: lastDate.toISOString(), - emojis: [], - sensitive: files.then(files => files.length > 0 ? files.some((f) => f.isSensitive) : false), - spoiler_text: edit.cw ?? '', - poll: null, - media_attachments: files.then(files => files.length > 0 ? files.map((f) => this.encodeFile(f)) : []) - }; - lastDate = edit.updatedAt; - history.push(awaitAll(item)); - } - - return await Promise.all(history); - } - - private async convertReblog(status: Entity.Status | null): Promise { - if (!status) return null; - return await this.convertStatus(status); - } - - public async convertStatus(status: Entity.Status) { - const convertedAccount = this.convertAccount(status.account); - const note = await this.getterService.getNote(status.id); - const noteUser = await this.getUser(status.account.id); - - const emojis = await this.customEmojiService.populateEmojis(note.emojis, noteUser.host ? noteUser.host : this.config.host); - const emoji: Entity.Emoji[] = []; - Object.entries(emojis).forEach(entry => { - const [key, value] = entry; - emoji.push({ - shortcode: key, - static_url: value, - url: value, - visible_in_picker: true, - category: undefined, - }); - }); - - const mentions = Promise.all(note.mentions.map(p => - this.getUser(p) - .then(u => this.encode(u, JSON.parse(note.mentionedRemoteUsers))) - .catch(() => null))) - .then(p => p.filter(m => m)) as Promise; - - const tags = note.tags.map(tag => { - return { - name: tag, - url: `${this.config.url}/tags/${tag}`, - } as Entity.Tag; - }); - - const isQuote = note.renoteId && note.text ? true : false; - - const renote = note.renoteId ? this.getterService.getNote(note.renoteId) : null; - - const quoteUri = Promise.resolve(renote).then(renote => { - if (!renote || !isQuote) return null; - return renote.url ?? renote.uri ?? `${this.config.url}/notes/${renote.id}`; - }); - - const content = note.text !== null - ? quoteUri.then(quoteUri => this.mfmService.toMastoHtml(mfm.parse(note.text!), JSON.parse(note.mentionedRemoteUsers), false, quoteUri)) - .then(p => p ?? escapeMFM(note.text!)) - : ''; - - // noinspection ES6MissingAwait - return await awaitAll({ - id: note.id, - uri: note.uri ?? `https://${this.config.host}/notes/${note.id}`, - url: note.url ?? note.uri ?? `https://${this.config.host}/notes/${note.id}`, - account: convertedAccount, - in_reply_to_id: note.replyId, - in_reply_to_account_id: note.replyUserId, - reblog: !isQuote ? await this.convertReblog(status.reblog) : null, - content: content, - content_type: 'text/x.misskeymarkdown', - text: note.text, - created_at: status.created_at, - emojis: emoji, - replies_count: note.repliesCount, - reblogs_count: note.renoteCount, - favourites_count: status.favourites_count, - reblogged: false, - favourited: status.favourited, - muted: status.muted, - sensitive: status.sensitive, - spoiler_text: note.cw ? note.cw : '', - visibility: status.visibility, - media_attachments: status.media_attachments, - mentions: mentions, - tags: tags, - card: null, //FIXME - poll: status.poll ?? null, - application: null, //FIXME - language: null, //FIXME - pinned: false, - reactions: status.emoji_reactions, - emoji_reactions: status.emoji_reactions, - bookmarked: false, - quote: isQuote ? await this.convertReblog(status.reblog) : null, - edited_at: note.updatedAt?.toISOString(), - }); - } -} - -function simpleConvert(data: any) { - // copy the object to bypass weird pass by reference bugs - const result = Object.assign({}, data); - return result; -} - -export function convertAccount(account: Entity.Account) { - return simpleConvert(account); -} -export function convertAnnouncement(announcement: Entity.Announcement) { - return simpleConvert(announcement); -} -export function convertAttachment(attachment: Entity.Attachment) { - return simpleConvert(attachment); -} -export function convertFilter(filter: Entity.Filter) { - return simpleConvert(filter); -} -export function convertList(list: Entity.List) { - return simpleConvert(list); -} -export function convertFeaturedTag(tag: Entity.FeaturedTag) { - return simpleConvert(tag); -} - -export function convertNotification(notification: Entity.Notification) { - notification.account = convertAccount(notification.account); - if (notification.status) notification.status = convertStatus(notification.status); - return notification; -} - -export function convertPoll(poll: Entity.Poll) { - return simpleConvert(poll); -} -export function convertReaction(reaction: Entity.Reaction) { - if (reaction.accounts) { - reaction.accounts = reaction.accounts.map(convertAccount); - } - return reaction; -} -export function convertRelationship(relationship: Entity.Relationship) { - return simpleConvert(relationship); -} - -export function convertStatus(status: Entity.Status) { - status.account = convertAccount(status.account); - status.media_attachments = status.media_attachments.map((attachment) => - convertAttachment(attachment), - ); - if (status.poll) status.poll = convertPoll(status.poll); - if (status.reblog) status.reblog = convertStatus(status.reblog); - - return status; -} - -export function convertStatusSource(status: Entity.StatusSource) { - return simpleConvert(status); -} - -export function convertConversation(conversation: Entity.Conversation) { - conversation.accounts = conversation.accounts.map(convertAccount); - if (conversation.last_status) { - conversation.last_status = convertStatus(conversation.last_status); - } - - return conversation; -} diff --git a/packages/backend/src/server/api/mastodon/endpoints.ts b/packages/backend/src/server/api/mastodon/endpoints.ts deleted file mode 100644 index 5a75823891..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ApiAuthMastodon } from './endpoints/auth.js'; -import { ApiAccountMastodon } from './endpoints/account.js'; -import { ApiSearchMastodon } from './endpoints/search.js'; -import { ApiNotifyMastodon } from './endpoints/notifications.js'; -import { ApiFilterMastodon } from './endpoints/filter.js'; -import { ApiTimelineMastodon } from './endpoints/timeline.js'; -import { ApiStatusMastodon } from './endpoints/status.js'; - -export { - ApiAccountMastodon, - ApiAuthMastodon, - ApiSearchMastodon, - ApiNotifyMastodon, - ApiFilterMastodon, - ApiTimelineMastodon, - ApiStatusMastodon, -}; diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts deleted file mode 100644 index 07d9efb8c1..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { MastoConverters, convertRelationship } from '../converters.js'; -import { argsToBools, limitToInt } from './timeline.js'; -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; -import { NoteEditRepository, NotesRepository, UsersRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import type { Config } from '@/config.js'; -import { Injectable } from '@nestjs/common'; - -const relationshipModel = { - id: '', - following: false, - followed_by: false, - delivery_following: false, - blocking: false, - blocked_by: false, - muting: false, - muting_notifications: false, - requested: false, - domain_blocking: false, - showing_reblogs: false, - endorsed: false, - notifying: false, - note: '', -}; - -@Injectable() -export class ApiAccountMastodon { - private request: FastifyRequest; - private client: MegalodonInterface; - private BASE_URL: string; - - constructor(request: FastifyRequest, client: MegalodonInterface, BASE_URL: string, private mastoconverter: MastoConverters) { - this.request = request; - this.client = client; - this.BASE_URL = BASE_URL; - } - - public async verifyCredentials() { - try { - const data = await this.client.verifyAccountCredentials(); - const acct = await this.mastoconverter.convertAccount(data.data); - const newAcct = Object.assign({}, acct, { - source: { - note: acct.note, - fields: acct.fields, - privacy: '', - sensitive: false, - language: '', - }, - }); - return newAcct; - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - return e.response; - } - } - - public async lookup() { - try { - const data = await this.client.search((this.request.query as any).acct, { type: 'accounts' }); - return this.mastoconverter.convertAccount(data.data.accounts[0]); - } catch (e: any) { - /* console.error(e) - console.error(e.response.data); */ - return e.response; - } - } - - public async getRelationships(users: [string]) { - try { - relationshipModel.id = users.toString() || '1'; - - if (!(users.length > 0)) { - return [relationshipModel]; - } - - const reqIds = []; - for (let i = 0; i < users.length; i++) { - reqIds.push(users[i]); - } - - const data = await this.client.getRelationships(reqIds); - return data.data.map((relationship) => convertRelationship(relationship)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } - } - - public async getStatuses() { - try { - const data = await this.client.getAccountStatuses((this.request.params as any).id, argsToBools(limitToInt(this.request.query as any))); - return await Promise.all(data.data.map(async (status) => await this.mastoconverter.convertStatus(status))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } - } - - public async getFollowers() { - try { - const data = await this.client.getAccountFollowers( - (this.request.params as any).id, - limitToInt(this.request.query as any), - ); - return await Promise.all(data.data.map(async (account) => await this.mastoconverter.convertAccount(account))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } - } - - public async getFollowing() { - try { - const data = await this.client.getAccountFollowing( - (this.request.params as any).id, - limitToInt(this.request.query as any), - ); - return await Promise.all(data.data.map(async (account) => await this.mastoconverter.convertAccount(account))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } - } - - public async addFollow() { - try { - const data = await this.client.followAccount( (this.request.params as any).id ); - const acct = convertRelationship(data.data); - acct.following = true; - return acct; - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } - } - - public async rmFollow() { - try { - const data = await this.client.unfollowAccount( (this.request.params as any).id ); - const acct = convertRelationship(data.data); - acct.following = false; - return acct; - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } - } - - public async addBlock() { - try { - const data = await this.client.blockAccount( (this.request.params as any).id ); - return convertRelationship(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } - } - - public async rmBlock() { - try { - const data = await this.client.unblockAccount( (this.request.params as any).id ); - return convertRelationship(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } - } - - public async addMute() { - try { - const data = await this.client.muteAccount( - (this.request.params as any).id, - this.request.body as any, - ); - return convertRelationship(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } - } - - public async rmMute() { - try { - const data = await this.client.unmuteAccount( (this.request.params as any).id ); - return convertRelationship(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } - } - - public async getBookmarks() { - try { - const data = await this.client.getBookmarks( limitToInt(this.request.query as any) ); - return data.data.map((status) => this.mastoconverter.convertStatus(status)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } - } - - public async getFavourites() { - try { - const data = await this.client.getFavourites( limitToInt(this.request.query as any) ); - return data.data.map((status) => this.mastoconverter.convertStatus(status)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } - } - - public async getMutes() { - try { - const data = await this.client.getMutes( limitToInt(this.request.query as any) ); - return data.data.map((account) => this.mastoconverter.convertAccount(account)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } - } - - public async getBlocks() { - try { - const data = await this.client.getBlocks( limitToInt(this.request.query as any) ); - return data.data.map((account) => this.mastoconverter.convertAccount(account)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } - } - - public async acceptFollow() { - try { - const data = await this.client.acceptFollowRequest( (this.request.params as any).id ); - return convertRelationship(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } - } - - public async rejectFollow() { - try { - const data = await this.client.rejectFollowRequest( (this.request.params as any).id ); - return convertRelationship(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } - } -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts deleted file mode 100644 index 80a5e89ca3..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints/auth.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; - -const readScope = [ - 'read:account', - 'read:drive', - 'read:blocks', - 'read:favorites', - 'read:following', - 'read:messaging', - 'read:mutes', - 'read:notifications', - 'read:reactions', - 'read:pages', - 'read:page-likes', - 'read:user-groups', - 'read:channels', - 'read:gallery', - 'read:gallery-likes', -]; - -const writeScope = [ - 'write:account', - 'write:drive', - 'write:blocks', - 'write:favorites', - 'write:following', - 'write:messaging', - 'write:mutes', - 'write:notes', - 'write:notifications', - 'write:reactions', - 'write:votes', - 'write:pages', - 'write:page-likes', - 'write:user-groups', - 'write:channels', - 'write:gallery', - 'write:gallery-likes', -]; - -export async function ApiAuthMastodon(request: FastifyRequest, client: MegalodonInterface) { - const body: any = request.body || request.query; - try { - let scope = body.scopes; - if (typeof scope === 'string') scope = scope.split(' ') || scope.split('+'); - const pushScope = new Set(); - for (const s of scope) { - if (s.match(/^read/)) for (const r of readScope) pushScope.add(r); - if (s.match(/^write/)) for (const r of writeScope) pushScope.add(r); - } - const scopeArr = Array.from(pushScope); - - const red = body.redirect_uris; - const appData = await client.registerApp(body.client_name, { - scopes: scopeArr, - redirect_uris: red, - website: body.website, - }); - const returns = { - id: Math.floor(Math.random() * 100).toString(), - name: appData.name, - website: body.website, - redirect_uri: red, - client_id: Buffer.from(appData.url || '').toString('base64'), - client_secret: appData.clientSecret, - }; - - return returns; - } catch (e: any) { - console.error(e); - return e.response.data; - } -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts deleted file mode 100644 index 212c79b251..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { convertFilter } from '../converters.js'; -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; - -export class ApiFilterMastodon { - private request: FastifyRequest; - private client: MegalodonInterface; - - constructor(request: FastifyRequest, client: MegalodonInterface) { - this.request = request; - this.client = client; - } - - public async getFilters() { - try { - const data = await this.client.getFilters(); - return data.data.map((filter) => convertFilter(filter)); - } catch (e: any) { - console.error(e); - return e.response.data; - } - } - - public async getFilter() { - try { - const data = await this.client.getFilter( (this.request.params as any).id ); - return convertFilter(data.data); - } catch (e: any) { - console.error(e); - return e.response.data; - } - } - - public async createFilter() { - try { - const body: any = this.request.body; - const data = await this.client.createFilter(body.pharse, body.context, body); - return convertFilter(data.data); - } catch (e: any) { - console.error(e); - return e.response.data; - } - } - - public async updateFilter() { - try { - const body: any = this.request.body; - const data = await this.client.updateFilter((this.request.params as any).id, body.pharse, body.context); - return convertFilter(data.data); - } catch (e: any) { - console.error(e); - return e.response.data; - } - } - - public async rmFilter() { - try { - const data = await this.client.deleteFilter( (this.request.params as any).id ); - return data.data; - } catch (e: any) { - console.error(e); - return e.response.data; - } - } -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/meta.ts b/packages/backend/src/server/api/mastodon/endpoints/meta.ts deleted file mode 100644 index efb39ef939..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints/meta.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Entity } from 'megalodon'; -import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; -import type { Config } from '@/config.js'; -import type { MiMeta } from '@/models/Meta.js'; - -export async function getInstance( - response: Entity.Instance, - contact: Entity.Account, - config: Config, - meta: MiMeta, -) { - return { - uri: config.url, - title: meta.name || 'Sharkey', - short_description: - meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', - description: - meta.description || - 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', - email: response.email || '', - version: `3.0.0 (compatible; Sharkey ${config.version})`, - urls: response.urls, - stats: { - user_count: response.stats.user_count, - status_count: response.stats.status_count, - domain_count: response.stats.domain_count, - }, - thumbnail: meta.backgroundImageUrl || '/static-assets/transparent.png', - languages: meta.langs, - registrations: !meta.disableRegistration || response.registrations, - approval_required: meta.approvalRequiredForSignup, - invites_enabled: response.registrations, - configuration: { - accounts: { - max_featured_tags: 20, - }, - statuses: { - max_characters: config.maxNoteLength, - max_media_attachments: 16, - characters_reserved_per_url: response.uri.length, - }, - media_attachments: { - supported_mime_types: FILE_TYPE_BROWSERSAFE, - image_size_limit: 10485760, - image_matrix_limit: 16777216, - video_size_limit: 41943040, - video_frame_rate_limit: 60, - video_matrix_limit: 2304000, - }, - polls: { - max_options: 10, - max_characters_per_option: 50, - min_expiration: 50, - max_expiration: 2629746, - }, - reactions: { - max_reactions: 1, - }, - }, - contact_account: contact, - rules: [], - }; -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts deleted file mode 100644 index c4628b58c4..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { convertNotification } from '../converters.js'; -import type { MegalodonInterface, Entity } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; - -function toLimitToInt(q: any) { - if (q.limit) if (typeof q.limit === 'string') q.limit = parseInt(q.limit, 10); - return q; -} - -export class ApiNotifyMastodon { - private request: FastifyRequest; - private client: MegalodonInterface; - - constructor(request: FastifyRequest, client: MegalodonInterface) { - this.request = request; - this.client = client; - } - - public async getNotifications() { - try { - const data = await this.client.getNotifications( toLimitToInt(this.request.query) ); - const notifs = data.data; - const processed = notifs.map((n: Entity.Notification) => { - const convertedn = convertNotification(n); - if (convertedn.type !== 'follow' && convertedn.type !== 'follow_request') { - if (convertedn.type === 'reaction') convertedn.type = 'favourite'; - return convertedn; - } else { - return convertedn; - } - }); - return processed; - } catch (e: any) { - console.error(e); - return e.response.data; - } - } - - public async getNotification() { - try { - const data = await this.client.getNotification( (this.request.params as any).id ); - const notif = convertNotification(data.data); - if (notif.type !== 'follow' && notif.type !== 'follow_request' && notif.type === 'reaction') notif.type = 'favourite'; - return notif; - } catch (e: any) { - console.error(e); - return e.response.data; - } - } - - public async rmNotification() { - try { - const data = await this.client.dismissNotification( (this.request.params as any).id ); - return data.data; - } catch (e: any) { - console.error(e); - return e.response.data; - } - } - - public async rmNotifications() { - try { - const data = await this.client.dismissNotifications(); - return data.data; - } catch (e: any) { - console.error(e); - return e.response.data; - } - } -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts deleted file mode 100644 index 500129c901..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { MastoConverters } from '../converters.js'; -import { limitToInt } from './timeline.js'; -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; - -export class ApiSearchMastodon { - private request: FastifyRequest; - private client: MegalodonInterface; - private BASE_URL: string; - - constructor(request: FastifyRequest, client: MegalodonInterface, BASE_URL: string, private mastoConverter: MastoConverters) { - this.request = request; - this.client = client; - this.BASE_URL = BASE_URL; - } - - public async SearchV1() { - try { - const query: any = limitToInt(this.request.query as any); - const type = query.type || ''; - const data = await this.client.search(query.q, { type: type, ...query }); - return data.data; - } catch (e: any) { - console.error(e); - return e.response.data; - } - } - - public async SearchV2() { - try { - const query: any = limitToInt(this.request.query as any); - const type = query.type; - const acct = !type || type === 'accounts' ? await this.client.search(query.q, { type: 'accounts', ...query }) : null; - const stat = !type || type === 'statuses' ? await this.client.search(query.q, { type: 'statuses', ...query }) : null; - const tags = !type || type === 'hashtags' ? await this.client.search(query.q, { type: 'hashtags', ...query }) : null; - const data = { - accounts: await Promise.all(acct?.data.accounts.map(async (account: any) => await this.mastoConverter.convertAccount(account)) ?? []), - statuses: await Promise.all(stat?.data.statuses.map(async (status: any) => await this.mastoConverter.convertStatus(status)) ?? []), - hashtags: tags?.data.hashtags ?? [], - }; - return data; - } catch (e: any) { - console.error(e); - return e.response.data; - } - } - - public async getStatusTrends() { - try { - const data = await fetch(`${this.BASE_URL}/api/notes/featured`, - { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({}), - }) - .then(res => res.json()) - .then(data => data.map((status: any) => this.mastoConverter.convertStatus(status))); - return data; - } catch (e: any) { - console.error(e); - return []; - } - } - - public async getSuggestions() { - try { - const data = await fetch(`${this.BASE_URL}/api/users`, - { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ i: this.request.headers.authorization?.replace('Bearer ', ''), limit: parseInt((this.request.query as any).limit) || 20, origin: 'local', sort: '+follower', state: 'alive' }), - }).then((res) => res.json()).then(data => data.map(((entry: any) => { return { source: 'global', account: entry }; }))); - return Promise.all(data.map(async (suggestion: any) => { suggestion.account = await this.mastoConverter.convertAccount(suggestion.account); return suggestion; })); - } catch (e: any) { - console.error(e); - return []; - } - } -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts deleted file mode 100644 index fe77646af4..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ /dev/null @@ -1,410 +0,0 @@ -import querystring from 'querystring'; -import { emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js'; -import { convertAttachment, convertPoll, convertStatusSource, MastoConverters } from '../converters.js'; -import { getClient } from '../MastodonApiServerService.js'; -import { limitToInt } from './timeline.js'; -import type { Entity } from 'megalodon'; -import type { FastifyInstance } from 'fastify'; -import type { Config } from '@/config.js'; -import { NoteEditRepository, NotesRepository, UsersRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; - -function normalizeQuery(data: any) { - const str = querystring.stringify(data); - return querystring.parse(str); -} - -export class ApiStatusMastodon { - private fastify: FastifyInstance; - private mastoconverter: MastoConverters; - - constructor(fastify: FastifyInstance, mastoconverter: MastoConverters) { - this.fastify = fastify; - this.mastoconverter = mastoconverter; - } - - public async getStatus() { - this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getStatus(_request.params.id); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(_request.is404 ? 404 : 401).send(e.response.data); - } - }); - } - - public async getStatusSource() { - this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/source', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getStatusSource(_request.params.id); - reply.send(data.data); - } catch (e: any) { - console.error(e); - reply.code(_request.is404 ? 404 : 401).send(e.response.data); - } - }); - } - - public async getContext() { - this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/context', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const query: any = _request.query; - try { - const data = await client.getStatusContext(_request.params.id, limitToInt(query)); - data.data.ancestors = await Promise.all(data.data.ancestors.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status))); - data.data.descendants = await Promise.all(data.data.descendants.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status))); - reply.send(data.data); - } catch (e: any) { - console.error(e); - reply.code(_request.is404 ? 404 : 401).send(e.response.data); - } - }); - } - - public async getHistory() { - this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/history', async (_request, reply) => { - try { - const edits = await this.mastoconverter.getEdits(_request.params.id); - reply.send(edits); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async getReblogged() { - this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getStatusRebloggedBy(_request.params.id); - reply.send(await Promise.all(data.data.map(async (account: Entity.Account) => await this.mastoconverter.convertAccount(account)))); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async getFavourites() { - this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getStatusFavouritedBy(_request.params.id); - reply.send(await Promise.all(data.data.map(async (account: Entity.Account) => await this.mastoconverter.convertAccount(account)))); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async getMedia() { - this.fastify.get<{ Params: { id: string } }>('/v1/media/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getMedia(_request.params.id); - reply.send(convertAttachment(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async getPoll() { - this.fastify.get<{ Params: { id: string } }>('/v1/polls/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getPoll(_request.params.id); - reply.send(convertPoll(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async votePoll() { - this.fastify.post<{ Params: { id: string } }>('/v1/polls/:id/votes', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const body: any = _request.body; - try { - const data = await client.votePoll(_request.params.id, body.choices); - reply.send(convertPoll(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async postStatus() { - this.fastify.post('/v1/statuses', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - let body: any = _request.body; - try { - if ( - (!body.poll && body['poll[options][]']) || - (!body.media_ids && body['media_ids[]']) - ) { - body = normalizeQuery(body); - } - const text = body.status ? body.status : ' '; - const removed = text.replace(/@\S+/g, '').replace(/\s|/g, ''); - const isDefaultEmoji = emojiRegexAtStartToEnd.test(removed); - const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed); - if ((body.in_reply_to_id && isDefaultEmoji) || (body.in_reply_to_id && isCustomEmoji)) { - const a = await client.createEmojiReaction( - body.in_reply_to_id, - removed, - ); - reply.send(a.data); - } - if (body.in_reply_to_id && removed === '/unreact') { - try { - const id = body.in_reply_to_id; - const post = await client.getStatus(id); - const react = post.data.emoji_reactions.filter((e: any) => e.me)[0].name; - const data = await client.deleteEmojiReaction(id, react); - reply.send(data.data); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - } - if (!body.media_ids) body.media_ids = undefined; - if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; - - const { sensitive } = body; - body.sensitive = typeof sensitive === 'string' ? sensitive === 'true' : sensitive; - - if (body.poll) { - if ( - body.poll.expires_in != null && - typeof body.poll.expires_in === 'string' - ) body.poll.expires_in = parseInt(body.poll.expires_in); - if ( - body.poll.multiple != null && - typeof body.poll.multiple === 'string' - ) body.poll.multiple = body.poll.multiple === 'true'; - if ( - body.poll.hide_totals != null && - typeof body.poll.hide_totals === 'string' - ) body.poll.hide_totals = body.poll.hide_totals === 'true'; - } - - const data = await client.postStatus(text, body); - reply.send(await this.mastoconverter.convertStatus(data.data as Entity.Status)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async updateStatus() { - this.fastify.put<{ Params: { id: string } }>('/v1/statuses/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const body: any = _request.body; - try { - if (!body.media_ids) body.media_ids = undefined; - if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; - const data = await client.editStatus(_request.params.id, body); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(_request.is404 ? 404 : 401).send(e.response.data); - } - }); - } - - public async addFavourite() { - this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = (await client.createEmojiReaction(_request.params.id, '❤')) as any; - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async rmFavourite() { - this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.deleteEmojiReaction(_request.params.id, '❤'); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async reblogStatus() { - this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.reblogStatus(_request.params.id); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async unreblogStatus() { - this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.unreblogStatus(_request.params.id); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async bookmarkStatus() { - this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.bookmarkStatus(_request.params.id); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async unbookmarkStatus() { - this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.unbookmarkStatus(_request.params.id); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async pinStatus() { - this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/pin', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.pinStatus(_request.params.id); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async unpinStatus() { - this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.unpinStatus(_request.params.id); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async reactStatus() { - this.fastify.post<{ Params: { id: string, name: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.createEmojiReaction(_request.params.id, _request.params.name); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async unreactStatus() { - this.fastify.post<{ Params: { id: string, name: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async deleteStatus() { - this.fastify.delete<{ Params: { id: string } }>('/v1/statuses/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.deleteStatus(_request.params.id); - reply.send(data.data); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts deleted file mode 100644 index f81b63b9ac..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { ParsedUrlQuery } from 'querystring'; -import { convertConversation, convertList, MastoConverters } from '../converters.js'; -import { getClient } from '../MastodonApiServerService.js'; -import type { Entity } from 'megalodon'; -import type { FastifyInstance } from 'fastify'; -import type { Config } from '@/config.js'; -import { NoteEditRepository, NotesRepository, UsersRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; - -export function limitToInt(q: ParsedUrlQuery) { - const object: any = q; - if (q.limit) if (typeof q.limit === 'string') object.limit = parseInt(q.limit, 10); - if (q.offset) if (typeof q.offset === 'string') object.offset = parseInt(q.offset, 10); - return object; -} - -export function argsToBools(q: ParsedUrlQuery) { - // Values taken from https://docs.joinmastodon.org/client/intro/#boolean - const toBoolean = (value: string) => - !['0', 'f', 'F', 'false', 'FALSE', 'off', 'OFF'].includes(value); - - // Keys taken from: - // - https://docs.joinmastodon.org/methods/accounts/#statuses - // - https://docs.joinmastodon.org/methods/timelines/#public - // - https://docs.joinmastodon.org/methods/timelines/#tag - const object: any = q; - if (q.only_media) if (typeof q.only_media === 'string') object.only_media = toBoolean(q.only_media); - if (q.exclude_replies) if (typeof q.exclude_replies === 'string') object.exclude_replies = toBoolean(q.exclude_replies); - if (q.exclude_reblogs) if (typeof q.exclude_reblogs === 'string') object.exclude_reblogs = toBoolean(q.exclude_reblogs); - if (q.pinned) if (typeof q.pinned === 'string') object.pinned = toBoolean(q.pinned); - if (q.local) if (typeof q.local === 'string') object.local = toBoolean(q.local); - return q; -} - -export class ApiTimelineMastodon { - private fastify: FastifyInstance; - - constructor(fastify: FastifyInstance, config: Config, private mastoconverter: MastoConverters) { - this.fastify = fastify; - } - - public async getTL() { - this.fastify.get('/v1/timelines/public', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const query: any = _request.query; - const data = query.local === 'true' - ? await client.getLocalTimeline(argsToBools(limitToInt(query))) - : await client.getPublicTimeline(argsToBools(limitToInt(query))); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status)))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - } - - public async getHomeTl() { - this.fastify.get('/v1/timelines/home', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const query: any = _request.query; - const data = await client.getHomeTimeline(limitToInt(query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status)))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - } - - public async getTagTl() { - this.fastify.get<{ Params: { hashtag: string } }>('/v1/timelines/tag/:hashtag', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const query: any = _request.query; - const params: any = _request.params; - const data = await client.getTagTimeline(params.hashtag, limitToInt(query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status)))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - } - - public async getListTL() { - this.fastify.get<{ Params: { id: string } }>('/v1/timelines/list/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const query: any = _request.query; - const params: any = _request.params; - const data = await client.getListTimeline(params.id, limitToInt(query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status)))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - } - - public async getConversations() { - this.fastify.get('/v1/conversations', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const query: any = _request.query; - const data = await client.getConversationTimeline(limitToInt(query)); - reply.send(data.data.map((conversation: Entity.Conversation) => convertConversation(conversation))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - } - - public async getList() { - this.fastify.get<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => { - try { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const params: any = _request.params; - const data = await client.getList(params.id); - reply.send(convertList(data.data)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - } - - public async getLists() { - this.fastify.get('/v1/lists', async (_request, reply) => { - try { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const data = await client.getLists(); - reply.send(data.data.map((list: Entity.List) => convertList(list))); - } catch (e: any) { - console.error(e); - return e.response.data; - } - }); - } - - public async getListAccounts() { - this.fastify.get<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { - try { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const params: any = _request.params; - const query: any = _request.query; - const data = await client.getAccountsInList(params.id, query); - reply.send(data.data.map((account: Entity.Account) => this.mastoconverter.convertAccount(account))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - } - - public async addListAccount() { - this.fastify.post<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { - try { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const params: any = _request.params; - const query: any = _request.query; - const data = await client.addAccountsToList(params.id, query.accounts_id); - reply.send(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - } - - public async rmListAccount() { - this.fastify.delete<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { - try { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const params: any = _request.params; - const query: any = _request.query; - const data = await client.deleteAccountsFromList(params.id, query.accounts_id); - reply.send(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - } - - public async createList() { - this.fastify.post('/v1/lists', async (_request, reply) => { - try { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const body: any = _request.body; - const data = await client.createList(body.title); - reply.send(convertList(data.data)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - } - - public async updateList() { - this.fastify.put<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => { - try { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const body: any = _request.body; - const params: any = _request.params; - const data = await client.updateList(params.id, body.title); - reply.send(convertList(data.data)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - } - - public async deleteList() { - this.fastify.delete<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => { - try { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const params: any = _request.params; - const data = await client.deleteList(params.id); - reply.send({}); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - } -} diff --git a/packages/backend/src/server/api/openapi/OpenApiServerService.ts b/packages/backend/src/server/api/openapi/OpenApiServerService.ts deleted file mode 100644 index cb22d0f7c9..0000000000 --- a/packages/backend/src/server/api/openapi/OpenApiServerService.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { fileURLToPath } from 'node:url'; -import { Inject, Injectable } from '@nestjs/common'; -import type { Config } from '@/config.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; -import { genOpenapiSpec } from './gen-spec.js'; -import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; - -const staticAssets = fileURLToPath(new URL('../../../../assets/', import.meta.url)); - -@Injectable() -export class OpenApiServerService { - constructor( - @Inject(DI.config) - private config: Config, - ) { - } - - @bindThis - public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) { - fastify.get('/api-doc', async (_request, reply) => { - reply.header('Cache-Control', 'public, max-age=86400'); - return await reply.sendFile('/redoc.html', staticAssets); - }); - fastify.get('/api.json', (_request, reply) => { - reply.header('Cache-Control', 'public, max-age=600'); - reply.send(genOpenapiSpec(this.config)); - }); - done(); - } -} diff --git a/packages/backend/src/server/api/openapi/errors.ts b/packages/backend/src/server/api/openapi/errors.ts index 84c3c638fa..d7f791c6da 100644 --- a/packages/backend/src/server/api/openapi/errors.ts +++ b/packages/backend/src/server/api/openapi/errors.ts @@ -1,7 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ export const errors = { '400': { diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts index 0e71510b48..68fa814041 100644 --- a/packages/backend/src/server/api/openapi/gen-spec.ts +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -1,19 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Config } from '@/config.js'; -import endpoints, { IEndpoint } from '../endpoints.js'; +import endpoints from '../endpoints.js'; +import config from '@/config/index.js'; import { errors as basicErrors } from './errors.js'; import { schemas, convertSchemaToOpenApiSchema } from './schemas.js'; -export function genOpenapiSpec(config: Config) { +export function genOpenapiSpec() { const spec = { openapi: '3.0.0', info: { - version: config.version, + version: 'v1', title: 'Misskey API', 'x-logo': { url: '/static-assets/api-doc.png' }, }, @@ -33,17 +28,16 @@ export function genOpenapiSpec(config: Config) { schemas: schemas, securitySchemes: { - bearerAuth: { - type: 'http', - scheme: 'bearer', + ApiKeyAuth: { + type: 'apiKey', + in: 'body', + name: 'i', }, }, }, }; - // 書き換えたりするのでディープコピーしておく。そのまま編集するとメモリ上の値が汚れて次回以降の出力に影響する - const copiedEndpoints = JSON.parse(JSON.stringify(endpoints)) as IEndpoint[]; - for (const endpoint of copiedEndpoints) { + for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) { const errors = {} as any; if (endpoint.meta.errors) { @@ -59,11 +53,6 @@ export function genOpenapiSpec(config: Config) { const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res) : {}; let desc = (endpoint.meta.description ? endpoint.meta.description : 'No description provided.') + '\n\n'; - - if (endpoint.meta.secure) { - desc += '**Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.\n'; - } - desc += `**Credential required**: *${endpoint.meta.requireCredential ? 'Yes' : 'No'}*`; if (endpoint.meta.kind) { const kind = endpoint.meta.kind; @@ -71,27 +60,17 @@ export function genOpenapiSpec(config: Config) { } const requestType = endpoint.meta.requireFile ? 'multipart/form-data' : 'application/json'; - const schema = { ...endpoint.params }; + const schema = endpoint.params; if (endpoint.meta.requireFile) { - schema.properties = { - ...schema.properties, - file: { - type: 'string', - format: 'binary', - description: 'The file contents.', - }, + schema.properties.file = { + type: 'string', + format: 'binary', + description: 'The file contents.', }; - schema.required = [...schema.required ?? [], 'file']; + schema.required.push('file'); } - if (schema.required && schema.required.length <= 0) { - // 空配列は許可されない - schema.required = undefined; - } - - const hasBody = (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length >= 1); - const info = { operationId: endpoint.name, summary: endpoint.name, @@ -105,19 +84,17 @@ export function genOpenapiSpec(config: Config) { } : {}), ...(endpoint.meta.requireCredential ? { security: [{ - bearerAuth: [], + ApiKeyAuth: [], }], } : {}), - ...(hasBody ? { - requestBody: { - required: true, - content: { - [requestType]: { - schema, - }, + requestBody: { + required: true, + content: { + [requestType]: { + schema, }, }, - } : {}), + }, responses: { ...(endpoint.meta.res ? { '200': { @@ -133,11 +110,6 @@ export function genOpenapiSpec(config: Config) { description: 'OK (without any results)', }, }), - ...(endpoint.meta.res?.optional === true || endpoint.meta.res?.nullable === true ? { - '204': { - description: 'OK (without any results)', - }, - } : {}), '400': { description: 'Client error', content: { @@ -210,7 +182,6 @@ export function genOpenapiSpec(config: Config) { }; spec.paths['/' + endpoint.name] = { - ...(endpoint.meta.allowGet ? { get: info } : {}), post: info, }; } diff --git a/packages/backend/src/server/api/openapi/schemas.ts b/packages/backend/src/server/api/openapi/schemas.ts index 2716f5f162..14bef9cab1 100644 --- a/packages/backend/src/server/api/openapi/schemas.ts +++ b/packages/backend/src/server/api/openapi/schemas.ts @@ -1,22 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Schema } from '@/misc/json-schema.js'; -import { refs } from '@/misc/json-schema.js'; +import { refs, Schema } from '@/misc/schema.js'; export function convertSchemaToOpenApiSchema(schema: Schema) { - // optional, refはスキーマ定義に含まれないので分離しておく - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { optional, ref, ...res }: any = schema; + const res: any = schema; if (schema.type === 'object' && schema.properties) { - const required = Object.entries(schema.properties).filter(([k, v]) => !v.optional).map(([k]) => k); - if (required.length > 0) { - // 空配列は許可されない - res.required = required; - } + res.required = Object.entries(schema.properties).filter(([k, v]) => !v.optional).map(([k]) => k); for (const k of Object.keys(schema.properties)) { res.properties[k] = convertSchemaToOpenApiSchema(schema.properties[k]); @@ -32,12 +20,7 @@ export function convertSchemaToOpenApiSchema(schema: Schema) { if (schema.allOf) res.allOf = schema.allOf.map(convertSchemaToOpenApiSchema); if (schema.ref) { - const $ref = `#/components/schemas/${schema.ref}`; - if (schema.nullable || schema.optional) { - res.allOf = [{ $ref }]; - } else { - res.$ref = $ref; - } + res.$ref = `#/components/schemas/${schema.ref}`; } return res; @@ -72,6 +55,6 @@ export const schemas = { }, ...Object.fromEntries( - Object.entries(refs).map(([key, schema]) => [key, convertSchemaToOpenApiSchema(schema)]), + Object.entries(refs).map(([key, schema]) => [key, convertSchemaToOpenApiSchema(schema)]) ), }; diff --git a/packages/backend/src/server/api/private/signin.ts b/packages/backend/src/server/api/private/signin.ts new file mode 100644 index 0000000000..79b31764fd --- /dev/null +++ b/packages/backend/src/server/api/private/signin.ts @@ -0,0 +1,250 @@ +import Koa from 'koa'; +import bcrypt from 'bcryptjs'; +import * as speakeasy from 'speakeasy'; +import signin from '../common/signin.js'; +import config from '@/config/index.js'; +import { Users, Signins, UserProfiles, UserSecurityKeys, AttestationChallenges } from '@/models/index.js'; +import { ILocalUser } from '@/models/entities/user.js'; +import { genId } from '@/misc/gen-id.js'; +import { verifyLogin, hash } from '../2fa.js'; +import { randomBytes } from 'node:crypto'; +import { IsNull } from 'typeorm'; +import { limiter } from '../limiter.js'; +import { getIpHash } from '@/misc/get-ip-hash.js'; + +export default async (ctx: Koa.Context) => { + ctx.set('Access-Control-Allow-Origin', config.url); + ctx.set('Access-Control-Allow-Credentials', 'true'); + + const body = ctx.request.body as any; + const username = body['username']; + const password = body['password']; + const token = body['token']; + + function error(status: number, error: { id: string }) { + ctx.status = status; + ctx.body = { error }; + } + + try { + // not more than 1 attempt per second and not more than 10 attempts per hour + await limiter({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(ctx.ip)); + } catch (err) { + ctx.status = 429; + ctx.body = { + error: { + message: 'Too many failed attempts to sign in. Try again later.', + code: 'TOO_MANY_AUTHENTICATION_FAILURES', + id: '22d05606-fbcf-421a-a2db-b32610dcfd1b', + }, + }; + return; + } + + if (typeof username !== 'string') { + ctx.status = 400; + return; + } + + if (typeof password !== 'string') { + ctx.status = 400; + return; + } + + if (token != null && typeof token !== 'string') { + ctx.status = 400; + return; + } + + // Fetch user + const user = await Users.findOneBy({ + usernameLower: username.toLowerCase(), + host: IsNull(), + }) as ILocalUser; + + if (user == null) { + error(404, { + id: '6cc579cc-885d-43d8-95c2-b8c7fc963280', + }); + return; + } + + if (user.isSuspended) { + error(403, { + id: 'e03a5f46-d309-4865-9b69-56282d94e1eb', + }); + return; + } + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + // Compare password + const same = await bcrypt.compare(password, profile.password!); + + async function fail(status?: number, failure?: { id: string }) { + // Append signin history + await Signins.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + ip: ctx.ip, + headers: ctx.headers, + success: false, + }); + + error(status || 500, failure || { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' }); + } + + if (!profile.twoFactorEnabled) { + if (same) { + signin(ctx, user); + return; + } else { + await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', + }); + return; + } + } + + if (token) { + if (!same) { + await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', + }); + return; + } + + const verified = (speakeasy as any).totp.verify({ + secret: profile.twoFactorSecret, + encoding: 'base32', + token: token, + window: 2, + }); + + if (verified) { + signin(ctx, user); + return; + } else { + await fail(403, { + id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f', + }); + return; + } + } else if (body.credentialId) { + if (!same && !profile.usePasswordLessLogin) { + await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', + }); + return; + } + + const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex'); + const clientData = JSON.parse(clientDataJSON.toString('utf-8')); + const challenge = await AttestationChallenges.findOneBy({ + userId: user.id, + id: body.challengeId, + registrationChallenge: false, + challenge: hash(clientData.challenge).toString('hex'), + }); + + if (!challenge) { + await fail(403, { + id: '2715a88a-2125-4013-932f-aa6fe72792da', + }); + return; + } + + await AttestationChallenges.delete({ + userId: user.id, + id: body.challengeId, + }); + + if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) { + await fail(403, { + id: '2715a88a-2125-4013-932f-aa6fe72792da', + }); + return; + } + + const securityKey = await UserSecurityKeys.findOneBy({ + id: Buffer.from( + body.credentialId + .replace(/-/g, '+') + .replace(/_/g, '/'), + 'base64' + ).toString('hex'), + }); + + if (!securityKey) { + await fail(403, { + id: '66269679-aeaf-4474-862b-eb761197e046', + }); + return; + } + + const isValid = verifyLogin({ + publicKey: Buffer.from(securityKey.publicKey, 'hex'), + authenticatorData: Buffer.from(body.authenticatorData, 'hex'), + clientDataJSON, + clientData, + signature: Buffer.from(body.signature, 'hex'), + challenge: challenge.challenge, + }); + + if (isValid) { + signin(ctx, user); + return; + } else { + await fail(403, { + id: '93b86c4b-72f9-40eb-9815-798928603d1e', + }); + return; + } + } else { + if (!same && !profile.usePasswordLessLogin) { + await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', + }); + return; + } + + const keys = await UserSecurityKeys.findBy({ + userId: user.id, + }); + + if (keys.length === 0) { + await fail(403, { + id: 'f27fd449-9af4-4841-9249-1f989b9fa4a4', + }); + return; + } + + // 32 byte challenge + const challenge = randomBytes(32).toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + const challengeId = genId(); + + await AttestationChallenges.insert({ + userId: user.id, + id: challengeId, + challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'), + createdAt: new Date(), + registrationChallenge: false, + }); + + ctx.body = { + challenge, + challengeId, + securityKeys: keys.map(key => ({ + id: key.id, + })), + }; + ctx.status = 200; + return; + } + // never get here +}; diff --git a/packages/backend/src/server/api/private/signup-pending.ts b/packages/backend/src/server/api/private/signup-pending.ts new file mode 100644 index 0000000000..e5e39ba00d --- /dev/null +++ b/packages/backend/src/server/api/private/signup-pending.ts @@ -0,0 +1,35 @@ +import Koa from 'koa'; +import { Users, UserPendings, UserProfiles } from '@/models/index.js'; +import { signup } from '../common/signup.js'; +import signin from '../common/signin.js'; + +export default async (ctx: Koa.Context) => { + const body = ctx.request.body; + + const code = body['code']; + + try { + const pendingUser = await UserPendings.findOneByOrFail({ code }); + + const { account, secret } = await signup({ + username: pendingUser.username, + passwordHash: pendingUser.password, + }); + + UserPendings.delete({ + id: pendingUser.id, + }); + + const profile = await UserProfiles.findOneByOrFail({ userId: account.id }); + + await UserProfiles.update({ userId: profile.userId }, { + email: pendingUser.email, + emailVerified: true, + emailVerifyCode: null, + }); + + signin(ctx, account); + } catch (e) { + ctx.throw(400, e); + } +}; diff --git a/packages/backend/src/server/api/private/signup.ts b/packages/backend/src/server/api/private/signup.ts new file mode 100644 index 0000000000..26f172637c --- /dev/null +++ b/packages/backend/src/server/api/private/signup.ts @@ -0,0 +1,112 @@ +import Koa from 'koa'; +import rndstr from 'rndstr'; +import bcrypt from 'bcryptjs'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { verifyHcaptcha, verifyRecaptcha } from '@/misc/captcha.js'; +import { Users, RegistrationTickets, UserPendings } from '@/models/index.js'; +import { signup } from '../common/signup.js'; +import config from '@/config/index.js'; +import { sendEmail } from '@/services/send-email.js'; +import { genId } from '@/misc/gen-id.js'; +import { validateEmailForAccount } from '@/services/validate-email-for-account.js'; + +export default async (ctx: Koa.Context) => { + const body = ctx.request.body; + + const instance = await fetchMeta(true); + + // Verify *Captcha + // ただしテスト時はこの機構は障害となるため無効にする + if (process.env.NODE_ENV !== 'test') { + if (instance.enableHcaptcha && instance.hcaptchaSecretKey) { + await verifyHcaptcha(instance.hcaptchaSecretKey, body['hcaptcha-response']).catch(e => { + ctx.throw(400, e); + }); + } + + if (instance.enableRecaptcha && instance.recaptchaSecretKey) { + await verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(e => { + ctx.throw(400, e); + }); + } + } + + const username = body['username']; + const password = body['password']; + const host: string | null = process.env.NODE_ENV === 'test' ? (body['host'] || null) : null; + const invitationCode = body['invitationCode']; + const emailAddress = body['emailAddress']; + + if (instance.emailRequiredForSignup) { + if (emailAddress == null || typeof emailAddress !== 'string') { + ctx.status = 400; + return; + } + + const available = await validateEmailForAccount(emailAddress); + if (!available) { + ctx.status = 400; + return; + } + } + + if (instance.disableRegistration) { + if (invitationCode == null || typeof invitationCode !== 'string') { + ctx.status = 400; + return; + } + + const ticket = await RegistrationTickets.findOneBy({ + code: invitationCode, + }); + + if (ticket == null) { + ctx.status = 400; + return; + } + + RegistrationTickets.delete(ticket.id); + } + + if (instance.emailRequiredForSignup) { + const code = rndstr('a-z0-9', 16); + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(password, salt); + + await UserPendings.insert({ + id: genId(), + createdAt: new Date(), + code, + email: emailAddress, + username: username, + password: hash, + }); + + const link = `${config.url}/signup-complete/${code}`; + + sendEmail(emailAddress, 'Signup', + `To complete signup, please click this link:
${link}`, + `To complete signup, please click this link: ${link}`); + + ctx.status = 204; + } else { + try { + const { account, secret } = await signup({ + username, password, host, + }); + + const res = await Users.pack(account, account, { + detail: true, + includeSecrets: true, + }); + + (res as any).token = secret; + + ctx.body = res; + } catch (e) { + ctx.throw(400, e); + } + } +}; diff --git a/packages/backend/src/server/api/service/discord.ts b/packages/backend/src/server/api/service/discord.ts new file mode 100644 index 0000000000..97cbcbecdb --- /dev/null +++ b/packages/backend/src/server/api/service/discord.ts @@ -0,0 +1,287 @@ +import Koa from 'koa'; +import Router from '@koa/router'; +import { OAuth2 } from 'oauth'; +import { v4 as uuid } from 'uuid'; +import { IsNull } from 'typeorm'; +import { getJson } from '@/misc/fetch.js'; +import config from '@/config/index.js'; +import { publishMainStream } from '@/services/stream.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Users, UserProfiles } from '@/models/index.js'; +import { ILocalUser } from '@/models/entities/user.js'; +import { redisClient } from '../../../db/redis.js'; +import signin from '../common/signin.js'; + +function getUserToken(ctx: Koa.BaseContext): string | null { + return ((ctx.headers['cookie'] || '').match(/igi=(\w+)/) || [null, null])[1]; +} + +function compareOrigin(ctx: Koa.BaseContext): boolean { + function normalizeUrl(url?: string): string { + return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : ''; + } + + const referer = ctx.headers['referer']; + + return (normalizeUrl(referer) === normalizeUrl(config.url)); +} + +// Init router +const router = new Router(); + +router.get('/disconnect/discord', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const user = await Users.findOneByOrFail({ + host: IsNull(), + token: userToken, + }); + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + delete profile.integrations.discord; + + await UserProfiles.update(user.id, { + integrations: profile.integrations, + }); + + ctx.body = 'Discordの連携を解除しました :v:'; + + // Publish i updated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { + detail: true, + includeSecrets: true, + })); +}); + +async function getOAuth2() { + const meta = await fetchMeta(true); + + if (meta.enableDiscordIntegration) { + return new OAuth2( + meta.discordClientId!, + meta.discordClientSecret!, + 'https://discord.com/', + 'api/oauth2/authorize', + 'api/oauth2/token'); + } else { + return null; + } +} + +router.get('/connect/discord', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const params = { + redirect_uri: `${config.url}/api/dc/cb`, + scope: ['identify'], + state: uuid(), + response_type: 'code', + }; + + redisClient.set(userToken, JSON.stringify(params)); + + const oauth2 = await getOAuth2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); +}); + +router.get('/signin/discord', async ctx => { + const sessid = uuid(); + + const params = { + redirect_uri: `${config.url}/api/dc/cb`, + scope: ['identify'], + state: uuid(), + response_type: 'code', + }; + + ctx.cookies.set('signin_with_discord_sid', sessid, { + path: '/', + secure: config.url.startsWith('https'), + httpOnly: true, + }); + + redisClient.set(sessid, JSON.stringify(params)); + + const oauth2 = await getOAuth2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); +}); + +router.get('/dc/cb', async ctx => { + const userToken = getUserToken(ctx); + + const oauth2 = await getOAuth2(); + + if (!userToken) { + const sessid = ctx.cookies.get('signin_with_discord_sid'); + + if (!sessid) { + ctx.throw(400, 'invalid session'); + return; + } + + const code = ctx.query.code; + + if (!code || typeof code !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise((res, rej) => { + redisClient.get(sessid, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken, refreshToken, expiresDate } = await new Promise((res, rej) => + oauth2!.getOAuthAccessToken(code, { + grant_type: 'authorization_code', + redirect_uri, + }, (err, accessToken, refreshToken, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ + accessToken, + refreshToken, + expiresDate: Date.now() + Number(result.expires_in) * 1000, + }); + } + })); + + const { id, username, discriminator } = (await getJson('https://discord.com/api/users/@me', '*/*', 10 * 1000, { + 'Authorization': `Bearer ${accessToken}`, + })) as Record; + + if (typeof id !== 'string' || typeof username !== 'string' || typeof discriminator !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const profile = await UserProfiles.createQueryBuilder() + .where('"integrations"->\'discord\'->>\'id\' = :id', { id: id }) + .andWhere('"userHost" IS NULL') + .getOne(); + + if (profile == null) { + ctx.throw(404, `@${username}#${discriminator}と連携しているMisskeyアカウントはありませんでした...`); + return; + } + + await UserProfiles.update(profile.userId, { + integrations: { + ...profile.integrations, + discord: { + id: id, + accessToken: accessToken, + refreshToken: refreshToken, + expiresDate: expiresDate, + username: username, + discriminator: discriminator, + }, + }, + }); + + signin(ctx, await Users.findOneBy({ id: profile.userId }) as ILocalUser, true); + } else { + const code = ctx.query.code; + + if (!code || typeof code !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise((res, rej) => { + redisClient.get(userToken, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken, refreshToken, expiresDate } = await new Promise((res, rej) => + oauth2!.getOAuthAccessToken(code, { + grant_type: 'authorization_code', + redirect_uri, + }, (err, accessToken, refreshToken, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ + accessToken, + refreshToken, + expiresDate: Date.now() + Number(result.expires_in) * 1000, + }); + } + })); + + const { id, username, discriminator } = (await getJson('https://discord.com/api/users/@me', '*/*', 10 * 1000, { + 'Authorization': `Bearer ${accessToken}`, + })) as Record; + if (typeof id !== 'string' || typeof username !== 'string' || typeof discriminator !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const user = await Users.findOneByOrFail({ + host: IsNull(), + token: userToken, + }); + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + await UserProfiles.update(user.id, { + integrations: { + ...profile.integrations, + discord: { + accessToken: accessToken, + refreshToken: refreshToken, + expiresDate: expiresDate, + id: id, + username: username, + discriminator: discriminator, + }, + }, + }); + + ctx.body = `Discord: @${username}#${discriminator} を、Misskey: @${user.username} に接続しました!`; + + // Publish i updated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { + detail: true, + includeSecrets: true, + })); + } +}); + +export default router; diff --git a/packages/backend/src/server/api/service/github.ts b/packages/backend/src/server/api/service/github.ts new file mode 100644 index 0000000000..04dbd1f7ab --- /dev/null +++ b/packages/backend/src/server/api/service/github.ts @@ -0,0 +1,259 @@ +import Koa from 'koa'; +import Router from '@koa/router'; +import { OAuth2 } from 'oauth'; +import { v4 as uuid } from 'uuid'; +import { IsNull } from 'typeorm'; +import { getJson } from '@/misc/fetch.js'; +import config from '@/config/index.js'; +import { publishMainStream } from '@/services/stream.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Users, UserProfiles } from '@/models/index.js'; +import { ILocalUser } from '@/models/entities/user.js'; +import { redisClient } from '../../../db/redis.js'; +import signin from '../common/signin.js'; + +function getUserToken(ctx: Koa.BaseContext): string | null { + return ((ctx.headers['cookie'] || '').match(/igi=(\w+)/) || [null, null])[1]; +} + +function compareOrigin(ctx: Koa.BaseContext): boolean { + function normalizeUrl(url?: string): string { + return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : ''; + } + + const referer = ctx.headers['referer']; + + return (normalizeUrl(referer) === normalizeUrl(config.url)); +} + +// Init router +const router = new Router(); + +router.get('/disconnect/github', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const user = await Users.findOneByOrFail({ + host: IsNull(), + token: userToken, + }); + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + delete profile.integrations.github; + + await UserProfiles.update(user.id, { + integrations: profile.integrations, + }); + + ctx.body = 'GitHubの連携を解除しました :v:'; + + // Publish i updated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { + detail: true, + includeSecrets: true, + })); +}); + +async function getOath2() { + const meta = await fetchMeta(true); + + if (meta.enableGithubIntegration && meta.githubClientId && meta.githubClientSecret) { + return new OAuth2( + meta.githubClientId, + meta.githubClientSecret, + 'https://github.com/', + 'login/oauth/authorize', + 'login/oauth/access_token'); + } else { + return null; + } +} + +router.get('/connect/github', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const params = { + redirect_uri: `${config.url}/api/gh/cb`, + scope: ['read:user'], + state: uuid(), + }; + + redisClient.set(userToken, JSON.stringify(params)); + + const oauth2 = await getOath2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); +}); + +router.get('/signin/github', async ctx => { + const sessid = uuid(); + + const params = { + redirect_uri: `${config.url}/api/gh/cb`, + scope: ['read:user'], + state: uuid(), + }; + + ctx.cookies.set('signin_with_github_sid', sessid, { + path: '/', + secure: config.url.startsWith('https'), + httpOnly: true, + }); + + redisClient.set(sessid, JSON.stringify(params)); + + const oauth2 = await getOath2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); +}); + +router.get('/gh/cb', async ctx => { + const userToken = getUserToken(ctx); + + const oauth2 = await getOath2(); + + if (!userToken) { + const sessid = ctx.cookies.get('signin_with_github_sid'); + + if (!sessid) { + ctx.throw(400, 'invalid session'); + return; + } + + const code = ctx.query.code; + + if (!code || typeof code !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise((res, rej) => { + redisClient.get(sessid, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken } = await new Promise((res, rej) => + oauth2!.getOAuthAccessToken(code, { + redirect_uri, + }, (err, accessToken, refresh, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ accessToken }); + } + })); + + const { login, id } = (await getJson('https://api.github.com/user', 'application/vnd.github.v3+json', 10 * 1000, { + 'Authorization': `bearer ${accessToken}`, + })) as Record; + if (typeof login !== 'string' || typeof id !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const link = await UserProfiles.createQueryBuilder() + .where('"integrations"->\'github\'->>\'id\' = :id', { id: id }) + .andWhere('"userHost" IS NULL') + .getOne(); + + if (link == null) { + ctx.throw(404, `@${login}と連携しているMisskeyアカウントはありませんでした...`); + return; + } + + signin(ctx, await Users.findOneBy({ id: link.userId }) as ILocalUser, true); + } else { + const code = ctx.query.code; + + if (!code || typeof code !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise((res, rej) => { + redisClient.get(userToken, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken } = await new Promise((res, rej) => + oauth2!.getOAuthAccessToken( + code, + { redirect_uri }, + (err, accessToken, refresh, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ accessToken }); + } + })); + + const { login, id } = (await getJson('https://api.github.com/user', 'application/vnd.github.v3+json', 10 * 1000, { + 'Authorization': `bearer ${accessToken}`, + })) as Record; + + if (typeof login !== 'string' || typeof id !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const user = await Users.findOneByOrFail({ + host: IsNull(), + token: userToken, + }); + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + await UserProfiles.update(user.id, { + integrations: { + ...profile.integrations, + github: { + accessToken: accessToken, + id: id, + login: login, + }, + }, + }); + + ctx.body = `GitHub: @${login} を、Misskey: @${user.username} に接続しました!`; + + // Publish i updated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { + detail: true, + includeSecrets: true, + })); + } +}); + +export default router; diff --git a/packages/backend/src/server/api/service/twitter.ts b/packages/backend/src/server/api/service/twitter.ts new file mode 100644 index 0000000000..2b4f9f6daa --- /dev/null +++ b/packages/backend/src/server/api/service/twitter.ts @@ -0,0 +1,201 @@ +import Koa from 'koa'; +import Router from '@koa/router'; +import { v4 as uuid } from 'uuid'; +import autwh from 'autwh'; +import { IsNull } from 'typeorm'; +import { publishMainStream } from '@/services/stream.js'; +import config from '@/config/index.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Users, UserProfiles } from '@/models/index.js'; +import { ILocalUser } from '@/models/entities/user.js'; +import signin from '../common/signin.js'; +import { redisClient } from '../../../db/redis.js'; + +function getUserToken(ctx: Koa.BaseContext): string | null { + return ((ctx.headers['cookie'] || '').match(/igi=(\w+)/) || [null, null])[1]; +} + +function compareOrigin(ctx: Koa.BaseContext): boolean { + function normalizeUrl(url?: string): string { + return url == null ? '' : url.endsWith('/') ? url.substr(0, url.length - 1) : url; + } + + const referer = ctx.headers['referer']; + + return (normalizeUrl(referer) === normalizeUrl(config.url)); +} + +// Init router +const router = new Router(); + +router.get('/disconnect/twitter', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (userToken == null) { + ctx.throw(400, 'signin required'); + return; + } + + const user = await Users.findOneByOrFail({ + host: IsNull(), + token: userToken, + }); + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + delete profile.integrations.twitter; + + await UserProfiles.update(user.id, { + integrations: profile.integrations, + }); + + ctx.body = 'Twitterの連携を解除しました :v:'; + + // Publish i updated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { + detail: true, + includeSecrets: true, + })); +}); + +async function getTwAuth() { + const meta = await fetchMeta(true); + + if (meta.enableTwitterIntegration && meta.twitterConsumerKey && meta.twitterConsumerSecret) { + return autwh({ + consumerKey: meta.twitterConsumerKey, + consumerSecret: meta.twitterConsumerSecret, + callbackUrl: `${config.url}/api/tw/cb`, + }); + } else { + return null; + } +} + +router.get('/connect/twitter', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (userToken == null) { + ctx.throw(400, 'signin required'); + return; + } + + const twAuth = await getTwAuth(); + const twCtx = await twAuth!.begin(); + redisClient.set(userToken, JSON.stringify(twCtx)); + ctx.redirect(twCtx.url); +}); + +router.get('/signin/twitter', async ctx => { + const twAuth = await getTwAuth(); + const twCtx = await twAuth!.begin(); + + const sessid = uuid(); + + redisClient.set(sessid, JSON.stringify(twCtx)); + + ctx.cookies.set('signin_with_twitter_sid', sessid, { + path: '/', + secure: config.url.startsWith('https'), + httpOnly: true, + }); + + ctx.redirect(twCtx.url); +}); + +router.get('/tw/cb', async ctx => { + const userToken = getUserToken(ctx); + + const twAuth = await getTwAuth(); + + if (userToken == null) { + const sessid = ctx.cookies.get('signin_with_twitter_sid'); + + if (sessid == null) { + ctx.throw(400, 'invalid session'); + return; + } + + const get = new Promise((res, rej) => { + redisClient.get(sessid, async (_, twCtx) => { + res(twCtx); + }); + }); + + const twCtx = await get; + + const verifier = ctx.query.oauth_verifier; + if (!verifier || typeof verifier !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const result = await twAuth!.done(JSON.parse(twCtx), verifier); + + const link = await UserProfiles.createQueryBuilder() + .where('"integrations"->\'twitter\'->>\'userId\' = :id', { id: result.userId }) + .andWhere('"userHost" IS NULL') + .getOne(); + + if (link == null) { + ctx.throw(404, `@${result.screenName}と連携しているMisskeyアカウントはありませんでした...`); + return; + } + + signin(ctx, await Users.findOneBy({ id: link.userId }) as ILocalUser, true); + } else { + const verifier = ctx.query.oauth_verifier; + + if (!verifier || typeof verifier !== 'string') { + ctx.throw(400, 'invalid session'); + return; + } + + const get = new Promise((res, rej) => { + redisClient.get(userToken, async (_, twCtx) => { + res(twCtx); + }); + }); + + const twCtx = await get; + + const result = await twAuth!.done(JSON.parse(twCtx), verifier); + + const user = await Users.findOneByOrFail({ + host: IsNull(), + token: userToken, + }); + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + await UserProfiles.update(user.id, { + integrations: { + ...profile.integrations, + twitter: { + accessToken: result.accessToken, + accessTokenSecret: result.accessTokenSecret, + userId: result.userId, + screenName: result.screenName, + }, + }, + }); + + ctx.body = `Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`; + + // Publish i updated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { + detail: true, + includeSecrets: true, + })); + } +}); + +export default router; diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts deleted file mode 100644 index 3fc3f4d31a..0000000000 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { bindThis } from '@/decorators.js'; -import { HybridTimelineChannelService } from './channels/hybrid-timeline.js'; -import { LocalTimelineChannelService } from './channels/local-timeline.js'; -import { HomeTimelineChannelService } from './channels/home-timeline.js'; -import { BubbleTimelineChannelService } from './channels/bubble-timeline.js'; -import { GlobalTimelineChannelService } from './channels/global-timeline.js'; -import { MainChannelService } from './channels/main.js'; -import { ChannelChannelService } from './channels/channel.js'; -import { AdminChannelService } from './channels/admin.js'; -import { ServerStatsChannelService } from './channels/server-stats.js'; -import { QueueStatsChannelService } from './channels/queue-stats.js'; -import { UserListChannelService } from './channels/user-list.js'; -import { AntennaChannelService } from './channels/antenna.js'; -import { DriveChannelService } from './channels/drive.js'; -import { HashtagChannelService } from './channels/hashtag.js'; -import { RoleTimelineChannelService } from './channels/role-timeline.js'; -import { type MiChannelService } from './channel.js'; - -@Injectable() -export class ChannelsService { - constructor( - private mainChannelService: MainChannelService, - private homeTimelineChannelService: HomeTimelineChannelService, - private localTimelineChannelService: LocalTimelineChannelService, - private hybridTimelineChannelService: HybridTimelineChannelService, - private globalTimelineChannelService: GlobalTimelineChannelService, - private bubbleTimelineChannelService: BubbleTimelineChannelService, - private userListChannelService: UserListChannelService, - private hashtagChannelService: HashtagChannelService, - private roleTimelineChannelService: RoleTimelineChannelService, - private antennaChannelService: AntennaChannelService, - private channelChannelService: ChannelChannelService, - private driveChannelService: DriveChannelService, - private serverStatsChannelService: ServerStatsChannelService, - private queueStatsChannelService: QueueStatsChannelService, - private adminChannelService: AdminChannelService, - ) { - } - - @bindThis - public getChannelService(name: string): MiChannelService { - switch (name) { - case 'main': return this.mainChannelService; - case 'homeTimeline': return this.homeTimelineChannelService; - case 'localTimeline': return this.localTimelineChannelService; - case 'hybridTimeline': return this.hybridTimelineChannelService; - case 'globalTimeline': return this.globalTimelineChannelService; - case 'bubbleTimeline': return this.bubbleTimelineChannelService; - case 'userList': return this.userListChannelService; - case 'hashtag': return this.hashtagChannelService; - case 'roleTimeline': return this.roleTimelineChannelService; - case 'antenna': return this.antennaChannelService; - case 'channel': return this.channelChannelService; - case 'drive': return this.driveChannelService; - case 'serverStats': return this.serverStatsChannelService; - case 'queueStats': return this.queueStatsChannelService; - case 'admin': return this.adminChannelService; - - default: - throw new Error(`no such channel: ${name}`); - } - } -} diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts deleted file mode 100644 index a89fbcc5e5..0000000000 --- a/packages/backend/src/server/api/stream/Connection.ts +++ /dev/null @@ -1,308 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as WebSocket from 'ws'; -import type { MiUser } from '@/models/User.js'; -import type { MiAccessToken } from '@/models/AccessToken.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { NoteReadService } from '@/core/NoteReadService.js'; -import type { NotificationService } from '@/core/NotificationService.js'; -import { bindThis } from '@/decorators.js'; -import { CacheService } from '@/core/CacheService.js'; -import { MiFollowing, MiUserProfile } from '@/models/_.js'; -import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js'; -import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; -import type { ChannelsService } from './ChannelsService.js'; -import type { EventEmitter } from 'events'; -import type Channel from './channel.js'; - -/** - * Main stream connection - */ -// eslint-disable-next-line import/no-default-export -export default class Connection { - public user?: MiUser; - public token?: MiAccessToken; - private wsConnection: WebSocket.WebSocket; - public subscriber: StreamEventEmitter; - private channels: Channel[] = []; - private subscribingNotes: any = {}; - private cachedNotes: Packed<'Note'>[] = []; - public userProfile: MiUserProfile | null = null; - public following: Record | undefined> = {}; - public followingChannels: Set = new Set(); - public userIdsWhoMeMuting: Set = new Set(); - public userIdsWhoBlockingMe: Set = new Set(); - public userIdsWhoMeMutingRenotes: Set = new Set(); - public userMutedInstances: Set = new Set(); - private fetchIntervalId: NodeJS.Timeout | null = null; - - constructor( - private channelsService: ChannelsService, - private noteReadService: NoteReadService, - private notificationService: NotificationService, - private cacheService: CacheService, - private channelFollowingService: ChannelFollowingService, - - user: MiUser | null | undefined, - token: MiAccessToken | null | undefined, - ) { - if (user) this.user = user; - if (token) this.token = token; - } - - @bindThis - public async fetch() { - if (this.user == null) return; - const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes] = await Promise.all([ - this.cacheService.userProfileCache.fetch(this.user.id), - this.cacheService.userFollowingsCache.fetch(this.user.id), - this.channelFollowingService.userFollowingChannelsCache.fetch(this.user.id), - this.cacheService.userMutingsCache.fetch(this.user.id), - this.cacheService.userBlockedCache.fetch(this.user.id), - this.cacheService.renoteMutingsCache.fetch(this.user.id), - ]); - this.userProfile = userProfile; - this.following = following; - this.followingChannels = followingChannels; - this.userIdsWhoMeMuting = userIdsWhoMeMuting; - this.userIdsWhoBlockingMe = userIdsWhoBlockingMe; - this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes; - this.userMutedInstances = new Set(userProfile.mutedInstances); - } - - @bindThis - public async init() { - if (this.user != null) { - await this.fetch(); - - if (!this.fetchIntervalId) { - this.fetchIntervalId = setInterval(this.fetch, 1000 * 10); - } - } - } - - @bindThis - public async listen(subscriber: EventEmitter, wsConnection: WebSocket.WebSocket) { - this.subscriber = subscriber; - - this.wsConnection = wsConnection; - this.wsConnection.on('message', this.onWsConnectionMessage); - - this.subscriber.on('broadcast', data => { - this.onBroadcastMessage(data); - }); - } - - /** - * クライアントからメッセージ受信時 - */ - @bindThis - private async onWsConnectionMessage(data: WebSocket.RawData) { - let obj: Record; - - try { - obj = JSON.parse(data.toString()); - } catch (e) { - return; - } - - const { type, body } = obj; - - switch (type) { - case 'readNotification': this.onReadNotification(body); break; - case 'subNote': this.onSubscribeNote(body); break; - case 's': this.onSubscribeNote(body); break; // alias - case 'sr': this.onSubscribeNote(body); this.readNote(body); break; - case 'unsubNote': this.onUnsubscribeNote(body); break; - case 'un': this.onUnsubscribeNote(body); break; // alias - case 'connect': this.onChannelConnectRequested(body); break; - case 'disconnect': this.onChannelDisconnectRequested(body); break; - case 'channel': this.onChannelMessageRequested(body); break; - case 'ch': this.onChannelMessageRequested(body); break; // alias - } - } - - @bindThis - private onBroadcastMessage(data: GlobalEvents['broadcast']['payload']) { - this.sendMessageToWs(data.type, data.body); - } - - @bindThis - public cacheNote(note: Packed<'Note'>) { - const add = (note: Packed<'Note'>) => { - const existIndex = this.cachedNotes.findIndex(n => n.id === note.id); - if (existIndex > -1) { - this.cachedNotes[existIndex] = note; - return; - } - - this.cachedNotes.unshift(note); - if (this.cachedNotes.length > 32) { - this.cachedNotes.splice(32); - } - }; - - add(note); - if (note.reply) add(note.reply); - if (note.renote) add(note.renote); - } - - @bindThis - private readNote(body: any) { - const id = body.id; - - const note = this.cachedNotes.find(n => n.id === id); - if (note == null) return; - - if (this.user && (note.userId !== this.user.id)) { - this.noteReadService.read(this.user.id, [note]); - } - } - - @bindThis - private onReadNotification(payload: any) { - this.notificationService.readAllNotification(this.user!.id); - } - - /** - * 投稿購読要求時 - */ - @bindThis - private onSubscribeNote(payload: any) { - if (!payload.id) return; - - if (this.subscribingNotes[payload.id] == null) { - this.subscribingNotes[payload.id] = 0; - } - - this.subscribingNotes[payload.id]++; - - if (this.subscribingNotes[payload.id] === 1) { - this.subscriber.on(`noteStream:${payload.id}`, this.onNoteStreamMessage); - } - } - - /** - * 投稿購読解除要求時 - */ - @bindThis - private onUnsubscribeNote(payload: any) { - if (!payload.id) return; - - this.subscribingNotes[payload.id]--; - if (this.subscribingNotes[payload.id] <= 0) { - delete this.subscribingNotes[payload.id]; - this.subscriber.off(`noteStream:${payload.id}`, this.onNoteStreamMessage); - } - } - - @bindThis - private async onNoteStreamMessage(data: GlobalEvents['note']['payload']) { - this.sendMessageToWs('noteUpdated', { - id: data.body.id, - type: data.type, - body: data.body.body, - }); - } - - /** - * チャンネル接続要求時 - */ - @bindThis - private onChannelConnectRequested(payload: any) { - const { channel, id, params, pong } = payload; - this.connectChannel(id, params, channel, pong); - } - - /** - * チャンネル切断要求時 - */ - @bindThis - private onChannelDisconnectRequested(payload: any) { - const { id } = payload; - this.disconnectChannel(id); - } - - /** - * クライアントにメッセージ送信 - */ - @bindThis - public sendMessageToWs(type: string, payload: any) { - this.wsConnection.send(JSON.stringify({ - type: type, - body: payload, - })); - } - - /** - * チャンネルに接続 - */ - @bindThis - public connectChannel(id: string, params: any, channel: string, pong = false) { - const channelService = this.channelsService.getChannelService(channel); - - if (channelService.requireCredential && this.user == null) { - return; - } - - if (this.token && ((channelService.kind && !this.token.permission.some(p => p === channelService.kind)) - || (!channelService.kind && channelService.requireCredential))) { - return; - } - - // 共有可能チャンネルに接続しようとしていて、かつそのチャンネルに既に接続していたら無意味なので無視 - if (channelService.shouldShare && this.channels.some(c => c.chName === channel)) { - return; - } - - const ch: Channel = channelService.create(id, this); - this.channels.push(ch); - ch.init(params ?? {}); - - if (pong) { - this.sendMessageToWs('connected', { - id: id, - }); - } - } - - /** - * チャンネルから切断 - * @param id チャンネルコネクションID - */ - @bindThis - public disconnectChannel(id: string) { - const channel = this.channels.find(c => c.id === id); - - if (channel) { - if (channel.dispose) channel.dispose(); - this.channels = this.channels.filter(c => c.id !== id); - } - } - - /** - * チャンネルへメッセージ送信要求時 - * @param data メッセージ - */ - @bindThis - private onChannelMessageRequested(data: any) { - const channel = this.channels.find(c => c.id === data.id); - if (channel != null && channel.onMessage != null) { - channel.onMessage(data.type, data.body); - } - } - - /** - * ストリームが切れたとき - */ - @bindThis - public dispose() { - if (this.fetchIntervalId) clearInterval(this.fetchIntervalId); - for (const c of this.channels.filter(c => c.dispose)) { - if (c.dispose) c.dispose(); - } - } -} diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 80df3803eb..d2cc5122d5 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -1,22 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { bindThis } from '@/decorators.js'; -import type Connection from './Connection.js'; +import Connection from '.'; /** * Stream channel */ -// eslint-disable-next-line import/no-default-export export default abstract class Channel { protected connection: Connection; public id: string; public abstract readonly chName: string; public static readonly shouldShare: boolean; public static readonly requireCredential: boolean; - public static readonly kind?: string | null; protected get user() { return this.connection.user; @@ -30,20 +22,12 @@ export default abstract class Channel { return this.connection.following; } - protected get userIdsWhoMeMuting() { - return this.connection.userIdsWhoMeMuting; + protected get muting() { + return this.connection.muting; } - protected get userIdsWhoMeMutingRenotes() { - return this.connection.userIdsWhoMeMutingRenotes; - } - - protected get userIdsWhoBlockingMe() { - return this.connection.userIdsWhoBlockingMe; - } - - protected get userMutedInstances() { - return this.connection.userMutedInstances; + protected get blocking() { + return this.connection.blocking; } protected get followingChannels() { @@ -59,7 +43,6 @@ export default abstract class Channel { this.connection = connection; } - @bindThis public send(typeOrPayload: any, payload?: any) { const type = payload === undefined ? typeOrPayload.type : typeOrPayload; const body = payload === undefined ? typeOrPayload.body : payload; @@ -72,15 +55,6 @@ export default abstract class Channel { } public abstract init(params: any): void; - public dispose?(): void; - public onMessage?(type: string, body: any): void; } - -export type MiChannelService = { - shouldShare: boolean; - requireCredential: T; - kind: T extends true ? string : string | null | undefined; - create: (id: string, connection: Connection) => Channel; -} diff --git a/packages/backend/src/server/api/stream/channels/admin.ts b/packages/backend/src/server/api/stream/channels/admin.ts index b8f369ce84..945182ea10 100644 --- a/packages/backend/src/server/api/stream/channels/admin.ts +++ b/packages/backend/src/server/api/stream/channels/admin.ts @@ -1,19 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ +import Channel from '../channel.js'; -import { Injectable } from '@nestjs/common'; -import { bindThis } from '@/decorators.js'; -import Channel, { type MiChannelService } from '../channel.js'; - -class AdminChannel extends Channel { +export default class extends Channel { public readonly chName = 'admin'; public static shouldShare = true; - public static requireCredential = true as const; - public static kind = 'read:admin:stream'; + public static requireCredential = true; - @bindThis public async init(params: any) { // Subscribe admin stream this.subscriber.on(`adminStream:${this.user!.id}`, data => { @@ -21,22 +12,3 @@ class AdminChannel extends Channel { }); } } - -@Injectable() -export class AdminChannelService implements MiChannelService { - public readonly shouldShare = AdminChannel.shouldShare; - public readonly requireCredential = AdminChannel.requireCredential; - public readonly kind = AdminChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): AdminChannel { - return new AdminChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index 200db8eb0e..d28320d928 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -1,33 +1,19 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; +import Channel from '../channel.js'; +import { Notes } from '@/models/index.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { bindThis } from '@/decorators.js'; -import type { GlobalEvents } from '@/core/GlobalEventService.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import { StreamMessages } from '../types.js'; -class AntennaChannel extends Channel { +export default class extends Channel { public readonly chName = 'antenna'; public static shouldShare = false; - public static requireCredential = true as const; - public static kind = 'read:account'; + public static requireCredential = false; private antennaId: string; - constructor( - private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], - ) { + constructor(id: string, connection: Channel['connection']) { super(id, connection); - //this.onEvent = this.onEvent.bind(this); + this.onEvent = this.onEvent.bind(this); } - @bindThis public async init(params: any) { this.antennaId = params.antennaId as string; @@ -35,17 +21,14 @@ class AntennaChannel extends Channel { this.subscriber.on(`antennaStream:${this.antennaId}`, this.onEvent); } - @bindThis - private async onEvent(data: GlobalEvents['antenna']['payload']) { + private async onEvent(data: StreamMessages['antenna']['payload']) { if (data.type === 'note') { - const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true }); + const note = await Notes.pack(data.body.id, this.user, { detail: true }); // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; + if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + if (isUserRelated(note, this.blocking)) return; this.connection.cacheNote(note); @@ -55,30 +38,8 @@ class AntennaChannel extends Channel { } } - @bindThis public dispose() { // Unsubscribe events this.subscriber.off(`antennaStream:${this.antennaId}`, this.onEvent); } } - -@Injectable() -export class AntennaChannelService implements MiChannelService { - public readonly shouldShare = AntennaChannel.shouldShare; - public readonly requireCredential = AntennaChannel.requireCredential; - public readonly kind = AntennaChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): AntennaChannel { - return new AntennaChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts deleted file mode 100644 index 4f8809edbe..0000000000 --- a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts +++ /dev/null @@ -1,125 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { checkWordMute } from '@/misc/check-word-mute.js'; -import { isInstanceMuted } from '@/misc/is-instance-muted.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { MetaService } from '@/core/MetaService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { bindThis } from '@/decorators.js'; -import { RoleService } from '@/core/RoleService.js'; -import type { MiMeta } from '@/models/Meta.js'; -import Channel, { MiChannelService } from '../channel.js'; - -class BubbleTimelineChannel extends Channel { - public readonly chName = 'bubbleTimeline'; - public static shouldShare = false; - public static requireCredential = false as const; - private withRenotes: boolean; - private withFiles: boolean; - private withBots: boolean; - private instance: MiMeta; - - constructor( - private metaService: MetaService, - private roleService: RoleService, - private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], - ) { - super(id, connection); - //this.onNote = this.onNote.bind(this); - } - - @bindThis - public async init(params: any) { - const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); - if (!policies.btlAvailable) return; - - this.withRenotes = params.withRenotes ?? true; - this.withFiles = params.withFiles ?? false; - this.withBots = params.withBots ?? true; - this.instance = await this.metaService.fetch(); - - // Subscribe events - this.subscriber.on('notesStream', this.onNote); - } - - @bindThis - private async onNote(note: Packed<'Note'>) { - if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; - if (!this.withBots && note.user.isBot) return; - - if (!(note.user.host != null && this.instance.bubbleInstances.includes(note.user.host) && note.visibility === 'public' )) return; - - if (note.channelId != null) return; - - // 関係ない返信は除外 - if (note.reply && !this.following[note.userId]?.withReplies) { - const reply = note.reply; - // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; - } - - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; - - // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? [])) && !this.following[note.userId]) return; - - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { - if (note.renote && Object.keys(note.renote.reactions).length > 0) { - const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); - note.renote.myReaction = myRenoteReaction; - } - } - - this.connection.cacheNote(note); - - this.send('note', note); - } - - @bindThis - public dispose() { - // Unsubscribe events - this.subscriber.off('notesStream', this.onNote); - } -} - -@Injectable() -export class BubbleTimelineChannelService implements MiChannelService { - public readonly shouldShare = BubbleTimelineChannel.shouldShare; - public readonly requireCredential = BubbleTimelineChannel.requireCredential; - public readonly kind = BubbleTimelineChannel.kind; - - constructor( - private metaService: MetaService, - private roleService: RoleService, - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): BubbleTimelineChannel { - return new BubbleTimelineChannel( - this.metaService, - this.roleService, - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 20275249b8..3cdd89a8b3 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -1,86 +1,91 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; +import Channel from '../channel.js'; +import { Notes, Users } from '@/models/index.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { bindThis } from '@/decorators.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import { User } from '@/models/entities/user.js'; +import { StreamMessages } from '../types.js'; +import { Packed } from '@/misc/schema.js'; -class ChannelChannel extends Channel { +export default class extends Channel { public readonly chName = 'channel'; public static shouldShare = false; - public static requireCredential = false as const; + public static requireCredential = false; private channelId: string; + private typers: Record = {}; + private emitTypersIntervalId: ReturnType; - constructor( - private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], - ) { + constructor(id: string, connection: Channel['connection']) { super(id, connection); - //this.onNote = this.onNote.bind(this); + this.onNote = this.onNote.bind(this); + this.emitTypers = this.emitTypers.bind(this); } - @bindThis public async init(params: any) { this.channelId = params.channelId as string; // Subscribe stream this.subscriber.on('notesStream', this.onNote); + this.subscriber.on(`channelStream:${this.channelId}`, this.onEvent); + this.emitTypersIntervalId = setInterval(this.emitTypers, 5000); } - @bindThis private async onNote(note: Packed<'Note'>) { if (note.channelId !== this.channelId) return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { - if (note.renote && Object.keys(note.renote.reactions).length > 0) { - const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); - note.renote.myReaction = myRenoteReaction; - } + // リプライなら再pack + if (note.replyId != null) { + note.reply = await Notes.pack(note.replyId, this.user, { + detail: true, + }); } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user, { + detail: true, + }); + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.blocking)) return; this.connection.cacheNote(note); this.send('note', note); } - @bindThis + private onEvent(data: StreamMessages['channel']['payload']) { + if (data.type === 'typing') { + const id = data.body; + const begin = this.typers[id] == null; + this.typers[id] = new Date(); + if (begin) { + this.emitTypers(); + } + } + } + + private async emitTypers() { + const now = new Date(); + + // Remove not typing users + for (const [userId, date] of Object.entries(this.typers)) { + if (now.getTime() - date.getTime() > 5000) delete this.typers[userId]; + } + + const users = await Users.packMany(Object.keys(this.typers), null, { detail: false }); + + this.send({ + type: 'typers', + body: users, + }); + } + public dispose() { // Unsubscribe events this.subscriber.off('notesStream', this.onNote); - } -} - -@Injectable() -export class ChannelChannelService implements MiChannelService { - public readonly shouldShare = ChannelChannel.shouldShare; - public readonly requireCredential = ChannelChannel.requireCredential; - public readonly kind = ChannelChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ChannelChannel { - return new ChannelChannel( - this.noteEntityService, - id, - connection, - ); + this.subscriber.off(`channelStream:${this.channelId}`, this.onEvent); + + clearInterval(this.emitTypersIntervalId); } } diff --git a/packages/backend/src/server/api/stream/channels/drive.ts b/packages/backend/src/server/api/stream/channels/drive.ts index 4bf34a72c9..140255acd1 100644 --- a/packages/backend/src/server/api/stream/channels/drive.ts +++ b/packages/backend/src/server/api/stream/channels/drive.ts @@ -1,19 +1,10 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ +import Channel from '../channel.js'; -import { Injectable } from '@nestjs/common'; -import { bindThis } from '@/decorators.js'; -import Channel, { type MiChannelService } from '../channel.js'; - -class DriveChannel extends Channel { +export default class extends Channel { public readonly chName = 'drive'; public static shouldShare = true; - public static requireCredential = true as const; - public static kind = 'read:account'; + public static requireCredential = true; - @bindThis public async init(params: any) { // Subscribe drive stream this.subscriber.on(`driveStream:${this.user!.id}`, data => { @@ -21,22 +12,3 @@ class DriveChannel extends Channel { }); } } - -@Injectable() -export class DriveChannelService implements MiChannelService { - public readonly shouldShare = DriveChannel.shouldShare; - public readonly requireCredential = DriveChannel.requireCredential; - public readonly kind = DriveChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): DriveChannel { - return new DriveChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index e05e380aae..5b4ae850ec 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -1,121 +1,77 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; +import Channel from '../channel.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Notes } from '@/models/index.js'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { MetaService } from '@/core/MetaService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { bindThis } from '@/decorators.js'; -import { RoleService } from '@/core/RoleService.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import { Packed } from '@/misc/schema.js'; -class GlobalTimelineChannel extends Channel { +export default class extends Channel { public readonly chName = 'globalTimeline'; - public static shouldShare = false; - public static requireCredential = false as const; - private withRenotes: boolean; - private withFiles: boolean; - private withBots: boolean; + public static shouldShare = true; + public static requireCredential = false; - constructor( - private metaService: MetaService, - private roleService: RoleService, - private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], - ) { + constructor(id: string, connection: Channel['connection']) { super(id, connection); - //this.onNote = this.onNote.bind(this); + this.onNote = this.onNote.bind(this); } - @bindThis public async init(params: any) { - const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); - if (!policies.gtlAvailable) return; - - this.withRenotes = params.withRenotes ?? true; - this.withFiles = params.withFiles ?? false; - this.withBots = params.withBots ?? true; + const meta = await fetchMeta(); + if (meta.disableGlobalTimeline) { + if (this.user == null || (!this.user.isAdmin && !this.user.isModerator)) return; + } // Subscribe events this.subscriber.on('notesStream', this.onNote); } - @bindThis private async onNote(note: Packed<'Note'>) { - if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; - if (!this.withBots && note.user.isBot) return; - if (note.visibility !== 'public') return; if (note.channelId != null) return; + // リプライなら再pack + if (note.replyId != null) { + note.reply = await Notes.pack(note.replyId, this.user, { + detail: true, + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user, { + detail: true, + }); + } + // 関係ない返信は除外 - if (note.reply && !this.following[note.userId]?.withReplies) { + if (note.reply && !this.user!.showTimelineReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; } - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; - // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? [])) && !this.following[note.userId]) return; + if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return; // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; + if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + if (isUserRelated(note, this.blocking)) return; - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { - if (note.renote && Object.keys(note.renote.reactions).length > 0) { - const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); - note.renote.myReaction = myRenoteReaction; - } - } + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; this.connection.cacheNote(note); this.send('note', note); } - @bindThis public dispose() { // Unsubscribe events this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class GlobalTimelineChannelService implements MiChannelService { - public readonly shouldShare = GlobalTimelineChannel.shouldShare; - public readonly requireCredential = GlobalTimelineChannel.requireCredential; - public readonly kind = GlobalTimelineChannel.kind; - - constructor( - private metaService: MetaService, - private roleService: RoleService, - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): GlobalTimelineChannel { - return new GlobalTimelineChannel( - this.metaService, - this.roleService, - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 3d4f2fc528..741db447e6 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -1,33 +1,20 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; +import Channel from '../channel.js'; +import { Notes } from '@/models/index.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { bindThis } from '@/decorators.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import { Packed } from '@/misc/schema.js'; -class HashtagChannel extends Channel { +export default class extends Channel { public readonly chName = 'hashtag'; public static shouldShare = false; - public static requireCredential = false as const; + public static requireCredential = false; private q: string[][]; - constructor( - private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], - ) { + constructor(id: string, connection: Channel['connection']) { super(id, connection); - //this.onNote = this.onNote.bind(this); + this.onNote = this.onNote.bind(this); } - @bindThis public async init(params: any) { this.q = params.q; @@ -37,55 +24,30 @@ class HashtagChannel extends Channel { this.subscriber.on('notesStream', this.onNote); } - @bindThis private async onNote(note: Packed<'Note'>) { const noteTags = note.tags ? note.tags.map((t: string) => t.toLowerCase()) : []; const matched = this.q.some(tags => tags.every(tag => noteTags.includes(normalizeForSearch(tag)))); if (!matched) return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { - if (note.renote && Object.keys(note.renote.reactions).length > 0) { - const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); - note.renote.myReaction = myRenoteReaction; - } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user, { + detail: true, + }); } + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.blocking)) return; + this.connection.cacheNote(note); this.send('note', note); } - @bindThis public dispose() { // Unsubscribe events this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class HashtagChannelService implements MiChannelService { - public readonly shouldShare = HashtagChannel.shouldShare; - public readonly requireCredential = HashtagChannel.requireCredential; - public readonly kind = HashtagChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): HashtagChannel { - return new HashtagChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index 3b499b7bf3..075a242ef0 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -1,123 +1,85 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; +import Channel from '../channel.js'; +import { Notes } from '@/models/index.js'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { bindThis } from '@/decorators.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import { Packed } from '@/misc/schema.js'; -class HomeTimelineChannel extends Channel { +export default class extends Channel { public readonly chName = 'homeTimeline'; - public static shouldShare = false; - public static requireCredential = true as const; - public static kind = 'read:account'; - private withRenotes: boolean; - private withFiles: boolean; + public static shouldShare = true; + public static requireCredential = true; - constructor( - private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], - ) { + constructor(id: string, connection: Channel['connection']) { super(id, connection); - //this.onNote = this.onNote.bind(this); + this.onNote = this.onNote.bind(this); } - @bindThis public async init(params: any) { - this.withRenotes = params.withRenotes ?? true; - this.withFiles = params.withFiles ?? false; - + // Subscribe events this.subscriber.on('notesStream', this.onNote); } - @bindThis private async onNote(note: Packed<'Note'>) { - const isMe = this.user!.id === note.userId; - - if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; - if (note.channelId) { if (!this.followingChannels.has(note.channelId)) return; } else { // その投稿のユーザーをフォローしていなかったら弾く - if (!isMe && !Object.hasOwn(this.following, note.userId)) return; + if ((this.user!.id !== note.userId) && !this.following.has(note.userId)) return; } // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set(this.userProfile!.mutedInstances)) && !this.following[note.userId]) return; + if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return; - if (note.visibility === 'followers') { - if (!isMe && !Object.hasOwn(this.following, note.userId)) return; - } else if (note.visibility === 'specified') { - if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return; - } + if (['followers', 'specified'].includes(note.visibility)) { + note = await Notes.pack(note.id, this.user!, { + detail: true, + }); - if (note.reply) { - const reply = note.reply; - if (this.following[note.userId]?.withReplies) { - // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; - } else { - // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; + if (note.isHidden) { + return; + } + } else { + // リプライなら再pack + if (note.replyId != null) { + note.reply = await Notes.pack(note.replyId, this.user!, { + detail: true, + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user!, { + detail: true, + }); } } - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; + // 関係ない返信は除外 + if (note.reply && !this.user!.showTimelineReplies) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; + } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; + if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + if (isUserRelated(note, this.blocking)) return; - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { - if (note.renote && Object.keys(note.renote.reactions).length > 0) { - const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); - note.renote.myReaction = myRenoteReaction; - } - } + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; this.connection.cacheNote(note); this.send('note', note); } - @bindThis public dispose() { // Unsubscribe events this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class HomeTimelineChannelService implements MiChannelService { - public readonly shouldShare = HomeTimelineChannel.shouldShare; - public readonly requireCredential = HomeTimelineChannel.requireCredential; - public readonly kind = HomeTimelineChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): HomeTimelineChannel { - return new HomeTimelineChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 26cbbebe83..f5dedf77ce 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -1,145 +1,93 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; +import Channel from '../channel.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Notes } from '@/models/index.js'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { MetaService } from '@/core/MetaService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { bindThis } from '@/decorators.js'; -import { RoleService } from '@/core/RoleService.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import { Packed } from '@/misc/schema.js'; -class HybridTimelineChannel extends Channel { +export default class extends Channel { public readonly chName = 'hybridTimeline'; - public static shouldShare = false; - public static requireCredential = true as const; - public static kind = 'read:account'; - private withRenotes: boolean; - private withReplies: boolean; - private withBots: boolean; - private withFiles: boolean; + public static shouldShare = true; + public static requireCredential = true; - constructor( - private metaService: MetaService, - private roleService: RoleService, - private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], - ) { + constructor(id: string, connection: Channel['connection']) { super(id, connection); - //this.onNote = this.onNote.bind(this); + this.onNote = this.onNote.bind(this); } - @bindThis - public async init(params: any): Promise { - const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); - if (!policies.ltlAvailable) return; - - this.withRenotes = params.withRenotes ?? true; - this.withReplies = params.withReplies ?? false; - this.withBots = params.withBots ?? true; - this.withFiles = params.withFiles ?? false; + public async init(params: any) { + const meta = await fetchMeta(); + if (meta.disableLocalTimeline && !this.user!.isAdmin && !this.user!.isModerator) return; // Subscribe events this.subscriber.on('notesStream', this.onNote); } - @bindThis private async onNote(note: Packed<'Note'>) { - const isMe = this.user!.id === note.userId; - - if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; - if (!this.withBots && note.user.isBot) return; - // チャンネルの投稿ではなく、自分自身の投稿 または // チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または // チャンネルの投稿ではなく、全体公開のローカルの投稿 または // フォローしているチャンネルの投稿 の場合だけ if (!( - (note.channelId == null && isMe) || - (note.channelId == null && Object.hasOwn(this.following, note.userId)) || + (note.channelId == null && this.user!.id === note.userId) || + (note.channelId == null && this.following.has(note.userId)) || (note.channelId == null && (note.user.host == null && note.visibility === 'public')) || (note.channelId != null && this.followingChannels.has(note.channelId)) )) return; - if (note.visibility === 'followers') { - if (!isMe && !Object.hasOwn(this.following, note.userId)) return; - } else if (note.visibility === 'specified') { - if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return; + if (['followers', 'specified'].includes(note.visibility)) { + note = await Notes.pack(note.id, this.user!, { + detail: true, + }); + + if (note.isHidden) { + return; + } + } else { + // リプライなら再pack + if (note.replyId != null) { + note.reply = await Notes.pack(note.replyId, this.user!, { + detail: true, + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user!, { + detail: true, + }); + } } // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set(this.userProfile!.mutedInstances)) && !this.following[note.userId]) return; + if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return; - if (note.reply) { + // 関係ない返信は除外 + if (note.reply && !this.user!.showTimelineReplies) { const reply = note.reply; - if ((this.following[note.userId]?.withReplies ?? false) || this.withReplies) { - // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; - } else { - // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; - } + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; } - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; + if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + if (isUserRelated(note, this.blocking)) return; - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { - if (note.renote && Object.keys(note.renote.reactions).length > 0) { - console.log(note.renote.reactionAndUserPairCache); - const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); - note.renote.myReaction = myRenoteReaction; - } - } + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; this.connection.cacheNote(note); this.send('note', note); } - @bindThis - public dispose(): void { + public dispose() { // Unsubscribe events this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class HybridTimelineChannelService implements MiChannelService { - public readonly shouldShare = HybridTimelineChannel.shouldShare; - public readonly requireCredential = HybridTimelineChannel.requireCredential; - public readonly kind = HybridTimelineChannel.kind; - - constructor( - private metaService: MetaService, - private roleService: RoleService, - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): HybridTimelineChannel { - return new HybridTimelineChannel( - this.metaService, - this.roleService, - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/index.ts b/packages/backend/src/server/api/stream/channels/index.ts new file mode 100644 index 0000000000..d422edde87 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/index.ts @@ -0,0 +1,33 @@ +import main from './main.js'; +import homeTimeline from './home-timeline.js'; +import localTimeline from './local-timeline.js'; +import hybridTimeline from './hybrid-timeline.js'; +import globalTimeline from './global-timeline.js'; +import serverStats from './server-stats.js'; +import queueStats from './queue-stats.js'; +import userList from './user-list.js'; +import antenna from './antenna.js'; +import messaging from './messaging.js'; +import messagingIndex from './messaging-index.js'; +import drive from './drive.js'; +import hashtag from './hashtag.js'; +import channel from './channel.js'; +import admin from './admin.js'; + +export default { + main, + homeTimeline, + localTimeline, + hybridTimeline, + globalTimeline, + serverStats, + queueStats, + userList, + antenna, + messaging, + messagingIndex, + drive, + hashtag, + channel, + admin, +}; diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 40342b6c7b..f01f477238 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -1,120 +1,74 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; +import Channel from '../channel.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Notes } from '@/models/index.js'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { MetaService } from '@/core/MetaService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { bindThis } from '@/decorators.js'; -import { RoleService } from '@/core/RoleService.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import { Packed } from '@/misc/schema.js'; -class LocalTimelineChannel extends Channel { +export default class extends Channel { public readonly chName = 'localTimeline'; - public static shouldShare = false; - public static requireCredential = false as const; - private withRenotes: boolean; - private withReplies: boolean; - private withBots: boolean; - private withFiles: boolean; + public static shouldShare = true; + public static requireCredential = false; - constructor( - private metaService: MetaService, - private roleService: RoleService, - private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], - ) { + constructor(id: string, connection: Channel['connection']) { super(id, connection); - //this.onNote = this.onNote.bind(this); + this.onNote = this.onNote.bind(this); } - @bindThis public async init(params: any) { - const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); - if (!policies.ltlAvailable) return; - - this.withRenotes = params.withRenotes ?? true; - this.withReplies = params.withReplies ?? false; - this.withBots = params.withBots ?? true; - this.withFiles = params.withFiles ?? false; + const meta = await fetchMeta(); + if (meta.disableLocalTimeline) { + if (this.user == null || (!this.user.isAdmin && !this.user.isModerator)) return; + } // Subscribe events this.subscriber.on('notesStream', this.onNote); } - @bindThis private async onNote(note: Packed<'Note'>) { - if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; - if (!this.withBots && note.user.isBot) return; - if (note.user.host !== null) return; if (note.visibility !== 'public') return; - if (note.channelId != null) return; + if (note.channelId != null && !this.followingChannels.has(note.channelId)) return; + + // リプライなら再pack + if (note.replyId != null) { + note.reply = await Notes.pack(note.replyId, this.user, { + detail: true, + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user, { + detail: true, + }); + } // 関係ない返信は除外 - if (note.reply && this.user && !this.following[note.userId]?.withReplies && !this.withReplies) { + if (note.reply && !this.user!.showTimelineReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; + if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; } - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; + if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + if (isUserRelated(note, this.blocking)) return; - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { - if (note.renote && Object.keys(note.renote.reactions).length > 0) { - const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); - note.renote.myReaction = myRenoteReaction; - } - } + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; this.connection.cacheNote(note); this.send('note', note); } - @bindThis public dispose() { // Unsubscribe events this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class LocalTimelineChannelService implements MiChannelService { - public readonly shouldShare = LocalTimelineChannel.shouldShare; - public readonly requireCredential = LocalTimelineChannel.requireCredential; - public readonly kind = LocalTimelineChannel.kind; - - constructor( - private metaService: MetaService, - private roleService: RoleService, - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): LocalTimelineChannel { - return new LocalTimelineChannel( - this.metaService, - this.roleService, - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/main.ts b/packages/backend/src/server/api/stream/channels/main.ts index ab605e3ec5..9cfea0bfc4 100644 --- a/packages/backend/src/server/api/stream/channels/main.ts +++ b/packages/backend/src/server/api/stream/channels/main.ts @@ -1,30 +1,12 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; +import Channel from '../channel.js'; +import { Notes } from '@/models/index.js'; import { isInstanceMuted, isUserFromMutedInstance } from '@/misc/is-instance-muted.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { bindThis } from '@/decorators.js'; -import Channel, { type MiChannelService } from '../channel.js'; -class MainChannel extends Channel { +export default class extends Channel { public readonly chName = 'main'; public static shouldShare = true; - public static requireCredential = true as const; - public static kind = 'read:account'; + public static requireCredential = true; - constructor( - private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], - ) { - super(id, connection); - } - - @bindThis public async init(params: any) { // Subscribe main stream channel this.subscriber.on(`mainStream:${this.user!.id}`, async data => { @@ -32,10 +14,10 @@ class MainChannel extends Channel { case 'notification': { // Ignore notifications from instances the user has muted if (isUserFromMutedInstance(data.body, new Set(this.userProfile?.mutedInstances ?? []))) return; - if (data.body.userId && this.userIdsWhoMeMuting.has(data.body.userId)) return; + if (data.body.userId && this.muting.has(data.body.userId)) return; if (data.body.note && data.body.note.isHidden) { - const note = await this.noteEntityService.pack(data.body.note.id, this.user, { + const note = await Notes.pack(data.body.note.id, this.user, { detail: true, }); this.connection.cacheNote(note); @@ -46,9 +28,9 @@ class MainChannel extends Channel { case 'mention': { if (isInstanceMuted(data.body, new Set(this.userProfile?.mutedInstances ?? []))) return; - if (this.userIdsWhoMeMuting.has(data.body.userId)) return; + if (this.muting.has(data.body.userId)) return; if (data.body.isHidden) { - const note = await this.noteEntityService.pack(data.body.id, this.user, { + const note = await Notes.pack(data.body.id, this.user, { detail: true, }); this.connection.cacheNote(note); @@ -62,24 +44,3 @@ class MainChannel extends Channel { }); } } - -@Injectable() -export class MainChannelService implements MiChannelService { - public readonly shouldShare = MainChannel.shouldShare; - public readonly requireCredential = MainChannel.requireCredential; - public readonly kind = MainChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): MainChannel { - return new MainChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/messaging-index.ts b/packages/backend/src/server/api/stream/channels/messaging-index.ts new file mode 100644 index 0000000000..b930785d20 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/messaging-index.ts @@ -0,0 +1,14 @@ +import Channel from '../channel.js'; + +export default class extends Channel { + public readonly chName = 'messagingIndex'; + public static shouldShare = true; + public static requireCredential = true; + + public async init(params: any) { + // Subscribe messaging index stream + this.subscriber.on(`messagingIndexStream:${this.user!.id}`, data => { + this.send(data); + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/messaging.ts b/packages/backend/src/server/api/stream/channels/messaging.ts new file mode 100644 index 0000000000..877d44c38e --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/messaging.ts @@ -0,0 +1,107 @@ +import { readUserMessagingMessage, readGroupMessagingMessage, deliverReadActivity } from '../../common/read-messaging-message.js'; +import Channel from '../channel.js'; +import { UserGroupJoinings, Users, MessagingMessages } from '@/models/index.js'; +import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js'; +import { UserGroup } from '@/models/entities/user-group.js'; +import { StreamMessages } from '../types.js'; + +export default class extends Channel { + public readonly chName = 'messaging'; + public static shouldShare = false; + public static requireCredential = true; + + private otherpartyId: string | null; + private otherparty: User | null; + private groupId: string | null; + private subCh: `messagingStream:${User['id']}-${User['id']}` | `messagingStream:${UserGroup['id']}`; + private typers: Record = {}; + private emitTypersIntervalId: ReturnType; + + constructor(id: string, connection: Channel['connection']) { + super(id, connection); + this.onEvent = this.onEvent.bind(this); + this.onMessage = this.onMessage.bind(this); + this.emitTypers = this.emitTypers.bind(this); + } + + public async init(params: any) { + this.otherpartyId = params.otherparty; + this.otherparty = this.otherpartyId ? await Users.findOneByOrFail({ id: this.otherpartyId }) : null; + this.groupId = params.group; + + // Check joining + if (this.groupId) { + const joining = await UserGroupJoinings.findOneBy({ + userId: this.user!.id, + userGroupId: this.groupId, + }); + + if (joining == null) { + return; + } + } + + this.emitTypersIntervalId = setInterval(this.emitTypers, 5000); + + this.subCh = this.otherpartyId + ? `messagingStream:${this.user!.id}-${this.otherpartyId}` + : `messagingStream:${this.groupId}`; + + // Subscribe messaging stream + this.subscriber.on(this.subCh, this.onEvent); + } + + private onEvent(data: StreamMessages['messaging']['payload'] | StreamMessages['groupMessaging']['payload']) { + if (data.type === 'typing') { + const id = data.body; + const begin = this.typers[id] == null; + this.typers[id] = new Date(); + if (begin) { + this.emitTypers(); + } + } else { + this.send(data); + } + } + + public onMessage(type: string, body: any) { + switch (type) { + case 'read': + if (this.otherpartyId) { + readUserMessagingMessage(this.user!.id, this.otherpartyId, [body.id]); + + // リモートユーザーからのメッセージだったら既読配信 + if (Users.isLocalUser(this.user!) && Users.isRemoteUser(this.otherparty!)) { + MessagingMessages.findOneBy({ id: body.id }).then(message => { + if (message) deliverReadActivity(this.user as ILocalUser, this.otherparty as IRemoteUser, message); + }); + } + } else if (this.groupId) { + readGroupMessagingMessage(this.user!.id, this.groupId, [body.id]); + } + break; + } + } + + private async emitTypers() { + const now = new Date(); + + // Remove not typing users + for (const [userId, date] of Object.entries(this.typers)) { + if (now.getTime() - date.getTime() > 5000) delete this.typers[userId]; + } + + const users = await Users.packMany(Object.keys(this.typers), null, { detail: false }); + + this.send({ + type: 'typers', + body: users, + }); + } + + public dispose() { + this.subscriber.off(this.subCh, this.onEvent); + + clearInterval(this.emitTypersIntervalId); + } +} diff --git a/packages/backend/src/server/api/stream/channels/queue-stats.ts b/packages/backend/src/server/api/stream/channels/queue-stats.ts index 5ceb2c3bbc..b67600474b 100644 --- a/packages/backend/src/server/api/stream/channels/queue-stats.ts +++ b/packages/backend/src/server/api/stream/channels/queue-stats.ts @@ -1,37 +1,27 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import Xev from 'xev'; -import { Injectable } from '@nestjs/common'; -import { bindThis } from '@/decorators.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel from '../channel.js'; const ev = new Xev(); -class QueueStatsChannel extends Channel { +export default class extends Channel { public readonly chName = 'queueStats'; public static shouldShare = true; - public static requireCredential = false as const; + public static requireCredential = false; constructor(id: string, connection: Channel['connection']) { super(id, connection); - //this.onStats = this.onStats.bind(this); - //this.onMessage = this.onMessage.bind(this); + this.onStats = this.onStats.bind(this); + this.onMessage = this.onMessage.bind(this); } - @bindThis public async init(params: any) { ev.addListener('queueStats', this.onStats); } - @bindThis private onStats(stats: any) { this.send('stats', stats); } - @bindThis public onMessage(type: string, body: any) { switch (type) { case 'requestLog': @@ -46,27 +36,7 @@ class QueueStatsChannel extends Channel { } } - @bindThis public dispose() { ev.removeListener('queueStats', this.onStats); } } - -@Injectable() -export class QueueStatsChannelService implements MiChannelService { - public readonly shouldShare = QueueStatsChannel.shouldShare; - public readonly requireCredential = QueueStatsChannel.requireCredential; - public readonly kind = QueueStatsChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): QueueStatsChannel { - return new QueueStatsChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts deleted file mode 100644 index b3bbb77dbf..0000000000 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { bindThis } from '@/decorators.js'; -import { RoleService } from '@/core/RoleService.js'; -import type { GlobalEvents } from '@/core/GlobalEventService.js'; -import Channel, { type MiChannelService } from '../channel.js'; - -class RoleTimelineChannel extends Channel { - public readonly chName = 'roleTimeline'; - public static shouldShare = false; - public static requireCredential = false as const; - private roleId: string; - - constructor( - private noteEntityService: NoteEntityService, - private roleservice: RoleService, - - id: string, - connection: Channel['connection'], - ) { - super(id, connection); - //this.onNote = this.onNote.bind(this); - } - - @bindThis - public async init(params: any) { - this.roleId = params.roleId as string; - - this.subscriber.on(`roleTimelineStream:${this.roleId}`, this.onEvent); - } - - @bindThis - private async onEvent(data: GlobalEvents['roleTimeline']['payload']) { - if (data.type === 'note') { - const note = data.body; - - if (!(await this.roleservice.isExplorable({ id: this.roleId }))) { - return; - } - if (note.visibility !== 'public') return; - - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - this.send('note', note); - } else { - this.send(data.type, data.body); - } - } - - @bindThis - public dispose() { - // Unsubscribe events - this.subscriber.off(`roleTimelineStream:${this.roleId}`, this.onEvent); - } -} - -@Injectable() -export class RoleTimelineChannelService implements MiChannelService { - public readonly shouldShare = RoleTimelineChannel.shouldShare; - public readonly requireCredential = RoleTimelineChannel.requireCredential; - public readonly kind = RoleTimelineChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - private roleservice: RoleService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): RoleTimelineChannel { - return new RoleTimelineChannel( - this.noteEntityService, - this.roleservice, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/server-stats.ts b/packages/backend/src/server/api/stream/channels/server-stats.ts index 615b6946cc..db75a6fa38 100644 --- a/packages/backend/src/server/api/stream/channels/server-stats.ts +++ b/packages/backend/src/server/api/stream/channels/server-stats.ts @@ -1,37 +1,27 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import Xev from 'xev'; -import { Injectable } from '@nestjs/common'; -import { bindThis } from '@/decorators.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel from '../channel.js'; const ev = new Xev(); -class ServerStatsChannel extends Channel { +export default class extends Channel { public readonly chName = 'serverStats'; public static shouldShare = true; - public static requireCredential = false as const; + public static requireCredential = false; constructor(id: string, connection: Channel['connection']) { super(id, connection); - //this.onStats = this.onStats.bind(this); - //this.onMessage = this.onMessage.bind(this); + this.onStats = this.onStats.bind(this); + this.onMessage = this.onMessage.bind(this); } - @bindThis public async init(params: any) { ev.addListener('serverStats', this.onStats); } - @bindThis private onStats(stats: any) { this.send('stats', stats); } - @bindThis public onMessage(type: string, body: any) { switch (type) { case 'requestLog': @@ -46,27 +36,7 @@ class ServerStatsChannel extends Channel { } } - @bindThis public dispose() { ev.removeListener('serverStats', this.onStats); } } - -@Injectable() -export class ServerStatsChannelService implements MiChannelService { - public readonly shouldShare = ServerStatsChannel.shouldShare; - public readonly requireCredential = ServerStatsChannel.requireCredential; - public readonly kind = ServerStatsChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ServerStatsChannel { - return new ServerStatsChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 909b5a5e03..97ad2983c5 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -1,53 +1,32 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; +import Channel from '../channel.js'; +import { Notes, UserListJoinings, UserLists } from '@/models/index.js'; +import { User } from '@/models/entities/user.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; -import { isInstanceMuted } from '@/misc/is-instance-muted.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import { Packed } from '@/misc/schema.js'; -class UserListChannel extends Channel { +export default class extends Channel { public readonly chName = 'userList'; public static shouldShare = false; - public static requireCredential = false as const; + public static requireCredential = false; private listId: string; - private membershipsMap: Record | undefined> = {}; - private listUsersClock: NodeJS.Timeout; - private withFiles: boolean; + public listUsers: User['id'][] = []; + private listUsersClock: NodeJS.Timer; - constructor( - private userListsRepository: UserListsRepository, - private userListMembershipsRepository: UserListMembershipsRepository, - private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], - ) { + constructor(id: string, connection: Channel['connection']) { super(id, connection); - //this.updateListUsers = this.updateListUsers.bind(this); - //this.onNote = this.onNote.bind(this); + this.updateListUsers = this.updateListUsers.bind(this); + this.onNote = this.onNote.bind(this); } - @bindThis public async init(params: any) { this.listId = params.listId as string; - this.withFiles = params.withFiles ?? false; // Check existence and owner - const listExist = await this.userListsRepository.exist({ - where: { - id: this.listId, - userId: this.user!.id, - }, + const list = await UserLists.findOneBy({ + id: this.listId, + userId: this.user!.id, }); - if (!listExist) return; + if (!list) return; // Subscribe stream this.subscriber.on(`userListStream:${this.listId}`, this.send); @@ -58,75 +37,51 @@ class UserListChannel extends Channel { this.listUsersClock = setInterval(this.updateListUsers, 5000); } - @bindThis private async updateListUsers() { - const memberships = await this.userListMembershipsRepository.find({ + const users = await UserListJoinings.find({ where: { userListId: this.listId, }, select: ['userId'], }); - const membershipsMap: Record | undefined> = {}; - for (const membership of memberships) { - membershipsMap[membership.userId] = { - withReplies: membership.withReplies, - }; - } - this.membershipsMap = membershipsMap; + this.listUsers = users.map(x => x.userId); } - @bindThis private async onNote(note: Packed<'Note'>) { - const isMe = this.user!.id === note.userId; + if (!this.listUsers.includes(note.userId)) return; - // チャンネル投稿は無視する - if (note.channelId) return; + if (['followers', 'specified'].includes(note.visibility)) { + note = await Notes.pack(note.id, this.user, { + detail: true, + }); - if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; - - if (!Object.hasOwn(this.membershipsMap, note.userId)) return; - - if (note.visibility === 'followers') { - if (!isMe && !Object.hasOwn(this.following, note.userId)) return; - } else if (note.visibility === 'specified') { - if (!note.visibleUserIds!.includes(this.user!.id)) return; - } - - if (note.reply) { - const reply = note.reply; - if (this.membershipsMap[note.userId]?.withReplies) { - // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; - } else { - // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; + if (note.isHidden) { + return; + } + } else { + // リプライなら再pack + if (note.replyId != null) { + note.reply = await Notes.pack(note.replyId, this.user, { + detail: true, + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user, { + detail: true, + }); } } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; + if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { - if (note.renote && Object.keys(note.renote.reactions).length > 0) { - const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); - note.renote.myReaction = myRenoteReaction; - } - } - - // 流れてきたNoteがミュートしているインスタンスに関わるものだったら無視する - if (isInstanceMuted(note, this.userMutedInstances)) return; - - this.connection.cacheNote(note); + if (isUserRelated(note, this.blocking)) return; this.send('note', note); } - @bindThis public dispose() { // Unsubscribe events this.subscriber.off(`userListStream:${this.listId}`, this.send); @@ -135,32 +90,3 @@ class UserListChannel extends Channel { clearInterval(this.listUsersClock); } } - -@Injectable() -export class UserListChannelService implements MiChannelService { - public readonly shouldShare = UserListChannel.shouldShare; - public readonly requireCredential = UserListChannel.requireCredential; - public readonly kind = UserListChannel.kind; - - constructor( - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): UserListChannel { - return new UserListChannel( - this.userListsRepository, - this.userListMembershipsRepository, - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts new file mode 100644 index 0000000000..2d23145f14 --- /dev/null +++ b/packages/backend/src/server/api/stream/index.ts @@ -0,0 +1,374 @@ +import { EventEmitter } from 'events'; +import * as websocket from 'websocket'; +import readNote from '@/services/note/read.js'; +import { User } from '@/models/entities/user.js'; +import { Channel as ChannelModel } from '@/models/entities/channel.js'; +import { Users, Followings, Mutings, UserProfiles, ChannelFollowings, Blockings } from '@/models/index.js'; +import { AccessToken } from '@/models/entities/access-token.js'; +import { UserProfile } from '@/models/entities/user-profile.js'; +import { publishChannelStream, publishGroupMessagingStream, publishMessagingStream } from '@/services/stream.js'; +import { UserGroup } from '@/models/entities/user-group.js'; +import { Packed } from '@/misc/schema.js'; +import { readNotification } from '../common/read-notification.js'; +import channels from './channels/index.js'; +import Channel from './channel.js'; +import { StreamEventEmitter, StreamMessages } from './types.js'; + +/** + * Main stream connection + */ +export default class Connection { + public user?: User; + public userProfile?: UserProfile | null; + public following: Set = new Set(); + public muting: Set = new Set(); + public blocking: Set = new Set(); // "被"blocking + public followingChannels: Set = new Set(); + public token?: AccessToken; + private wsConnection: websocket.connection; + public subscriber: StreamEventEmitter; + private channels: Channel[] = []; + private subscribingNotes: any = {}; + private cachedNotes: Packed<'Note'>[] = []; + + constructor( + wsConnection: websocket.connection, + subscriber: EventEmitter, + user: User | null | undefined, + token: AccessToken | null | undefined, + ) { + this.wsConnection = wsConnection; + this.subscriber = subscriber; + if (user) this.user = user; + if (token) this.token = token; + + this.onWsConnectionMessage = this.onWsConnectionMessage.bind(this); + this.onUserEvent = this.onUserEvent.bind(this); + this.onNoteStreamMessage = this.onNoteStreamMessage.bind(this); + this.onBroadcastMessage = this.onBroadcastMessage.bind(this); + + this.wsConnection.on('message', this.onWsConnectionMessage); + + this.subscriber.on('broadcast', data => { + this.onBroadcastMessage(data); + }); + + if (this.user) { + this.updateFollowing(); + this.updateMuting(); + this.updateBlocking(); + this.updateFollowingChannels(); + this.updateUserProfile(); + + this.subscriber.on(`user:${this.user.id}`, this.onUserEvent); + } + } + + private onUserEvent(data: StreamMessages['user']['payload']) { // { type, body }と展開するとそれぞれ型が分離してしまう + switch (data.type) { + case 'follow': + this.following.add(data.body.id); + break; + + case 'unfollow': + this.following.delete(data.body.id); + break; + + case 'mute': + this.muting.add(data.body.id); + break; + + case 'unmute': + this.muting.delete(data.body.id); + break; + + // TODO: block events + + case 'followChannel': + this.followingChannels.add(data.body.id); + break; + + case 'unfollowChannel': + this.followingChannels.delete(data.body.id); + break; + + case 'updateUserProfile': + this.userProfile = data.body; + break; + + case 'terminate': + this.wsConnection.close(); + this.dispose(); + break; + + default: + break; + } + } + + /** + * クライアントからメッセージ受信時 + */ + private async onWsConnectionMessage(data: websocket.Message) { + if (data.type !== 'utf8') return; + if (data.utf8Data == null) return; + + let obj: Record; + + try { + obj = JSON.parse(data.utf8Data); + } catch (e) { + return; + } + + const { type, body } = obj; + + switch (type) { + case 'readNotification': this.onReadNotification(body); break; + case 'subNote': this.onSubscribeNote(body); break; + case 's': this.onSubscribeNote(body); break; // alias + case 'sr': this.onSubscribeNote(body); this.readNote(body); break; + case 'unsubNote': this.onUnsubscribeNote(body); break; + case 'un': this.onUnsubscribeNote(body); break; // alias + case 'connect': this.onChannelConnectRequested(body); break; + case 'disconnect': this.onChannelDisconnectRequested(body); break; + case 'channel': this.onChannelMessageRequested(body); break; + case 'ch': this.onChannelMessageRequested(body); break; // alias + + // 個々のチャンネルではなくルートレベルでこれらのメッセージを受け取る理由は、 + // クライアントの事情を考慮したとき、入力フォームはノートチャンネルやメッセージのメインコンポーネントとは別 + // なこともあるため、それらのコンポーネントがそれぞれ各チャンネルに接続するようにするのは面倒なため。 + case 'typingOnChannel': this.typingOnChannel(body.channel); break; + case 'typingOnMessaging': this.typingOnMessaging(body); break; + } + } + + private onBroadcastMessage(data: StreamMessages['broadcast']['payload']) { + this.sendMessageToWs(data.type, data.body); + } + + public cacheNote(note: Packed<'Note'>) { + const add = (note: Packed<'Note'>) => { + const existIndex = this.cachedNotes.findIndex(n => n.id === note.id); + if (existIndex > -1) { + this.cachedNotes[existIndex] = note; + return; + } + + this.cachedNotes.unshift(note); + if (this.cachedNotes.length > 32) { + this.cachedNotes.splice(32); + } + }; + + add(note); + if (note.reply) add(note.reply); + if (note.renote) add(note.renote); + } + + private readNote(body: any) { + const id = body.id; + + const note = this.cachedNotes.find(n => n.id === id); + if (note == null) return; + + if (this.user && (note.userId !== this.user.id)) { + readNote(this.user.id, [note], { + following: this.following, + followingChannels: this.followingChannels, + }); + } + } + + private onReadNotification(payload: any) { + if (!payload.id) return; + readNotification(this.user!.id, [payload.id]); + } + + /** + * 投稿購読要求時 + */ + private onSubscribeNote(payload: any) { + if (!payload.id) return; + + if (this.subscribingNotes[payload.id] == null) { + this.subscribingNotes[payload.id] = 0; + } + + this.subscribingNotes[payload.id]++; + + if (this.subscribingNotes[payload.id] === 1) { + this.subscriber.on(`noteStream:${payload.id}`, this.onNoteStreamMessage); + } + } + + /** + * 投稿購読解除要求時 + */ + private onUnsubscribeNote(payload: any) { + if (!payload.id) return; + + this.subscribingNotes[payload.id]--; + if (this.subscribingNotes[payload.id] <= 0) { + delete this.subscribingNotes[payload.id]; + this.subscriber.off(`noteStream:${payload.id}`, this.onNoteStreamMessage); + } + } + + private async onNoteStreamMessage(data: StreamMessages['note']['payload']) { + this.sendMessageToWs('noteUpdated', { + id: data.body.id, + type: data.type, + body: data.body.body, + }); + } + + /** + * チャンネル接続要求時 + */ + private onChannelConnectRequested(payload: any) { + const { channel, id, params, pong } = payload; + this.connectChannel(id, params, channel, pong); + } + + /** + * チャンネル切断要求時 + */ + private onChannelDisconnectRequested(payload: any) { + const { id } = payload; + this.disconnectChannel(id); + } + + /** + * クライアントにメッセージ送信 + */ + public sendMessageToWs(type: string, payload: any) { + this.wsConnection.send(JSON.stringify({ + type: type, + body: payload, + })); + } + + /** + * チャンネルに接続 + */ + public connectChannel(id: string, params: any, channel: string, pong = false) { + if ((channels as any)[channel].requireCredential && this.user == null) { + return; + } + + // 共有可能チャンネルに接続しようとしていて、かつそのチャンネルに既に接続していたら無意味なので無視 + if ((channels as any)[channel].shouldShare && this.channels.some(c => c.chName === channel)) { + return; + } + + const ch: Channel = new (channels as any)[channel](id, this); + this.channels.push(ch); + ch.init(params); + + if (pong) { + this.sendMessageToWs('connected', { + id: id, + }); + } + } + + /** + * チャンネルから切断 + * @param id チャンネルコネクションID + */ + public disconnectChannel(id: string) { + const channel = this.channels.find(c => c.id === id); + + if (channel) { + if (channel.dispose) channel.dispose(); + this.channels = this.channels.filter(c => c.id !== id); + } + } + + /** + * チャンネルへメッセージ送信要求時 + * @param data メッセージ + */ + private onChannelMessageRequested(data: any) { + const channel = this.channels.find(c => c.id === data.id); + if (channel != null && channel.onMessage != null) { + channel.onMessage(data.type, data.body); + } + } + + private typingOnChannel(channel: ChannelModel['id']) { + if (this.user) { + publishChannelStream(channel, 'typing', this.user.id); + } + } + + private typingOnMessaging(param: { partner?: User['id']; group?: UserGroup['id']; }) { + if (this.user) { + if (param.partner) { + publishMessagingStream(param.partner, this.user.id, 'typing', this.user.id); + } else if (param.group) { + publishGroupMessagingStream(param.group, 'typing', this.user.id); + } + } + } + + private async updateFollowing() { + const followings = await Followings.find({ + where: { + followerId: this.user!.id, + }, + select: ['followeeId'], + }); + + this.following = new Set(followings.map(x => x.followeeId)); + } + + private async updateMuting() { + const mutings = await Mutings.find({ + where: { + muterId: this.user!.id, + }, + select: ['muteeId'], + }); + + this.muting = new Set(mutings.map(x => x.muteeId)); + } + + private async updateBlocking() { // ここでいうBlockingは被Blockingの意 + const blockings = await Blockings.find({ + where: { + blockeeId: this.user!.id, + }, + select: ['blockerId'], + }); + + this.blocking = new Set(blockings.map(x => x.blockerId)); + } + + private async updateFollowingChannels() { + const followings = await ChannelFollowings.find({ + where: { + followerId: this.user!.id, + }, + select: ['followeeId'], + }); + + this.followingChannels = new Set(followings.map(x => x.followeeId)); + } + + private async updateUserProfile() { + this.userProfile = await UserProfiles.findOneBy({ + userId: this.user!.id, + }); + } + + /** + * ストリームが切れたとき + */ + public dispose() { + for (const c of this.channels.filter(c => c.dispose)) { + if (c.dispose) c.dispose(); + } + } +} diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts new file mode 100644 index 0000000000..3b0a75d793 --- /dev/null +++ b/packages/backend/src/server/api/stream/types.ts @@ -0,0 +1,260 @@ +import { EventEmitter } from 'events'; +import Emitter from 'strict-event-emitter-types'; +import { Channel } from '@/models/entities/channel.js'; +import { User } from '@/models/entities/user.js'; +import { UserProfile } from '@/models/entities/user-profile.js'; +import { Note } from '@/models/entities/note.js'; +import { Antenna } from '@/models/entities/antenna.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { DriveFolder } from '@/models/entities/drive-folder.js'; +import { Emoji } from '@/models/entities/emoji.js'; +import { UserList } from '@/models/entities/user-list.js'; +import { MessagingMessage } from '@/models/entities/messaging-message.js'; +import { UserGroup } from '@/models/entities/user-group.js'; +import { AbuseUserReport } from '@/models/entities/abuse-user-report.js'; +import { Signin } from '@/models/entities/signin.js'; +import { Page } from '@/models/entities/page.js'; +import { Packed } from '@/misc/schema.js'; +import { Webhook } from '@/models/entities/webhook'; + +//#region Stream type-body definitions +export interface InternalStreamTypes { + userChangeSuspendedState: { id: User['id']; isSuspended: User['isSuspended']; }; + userChangeSilencedState: { id: User['id']; isSilenced: User['isSilenced']; }; + userChangeModeratorState: { id: User['id']; isModerator: User['isModerator']; }; + userTokenRegenerated: { id: User['id']; oldToken: User['token']; newToken: User['token']; }; + remoteUserUpdated: { id: User['id']; }; + webhookCreated: Webhook; + webhookDeleted: Webhook; + webhookUpdated: Webhook; + antennaCreated: Antenna; + antennaDeleted: Antenna; + antennaUpdated: Antenna; +} + +export interface BroadcastTypes { + emojiAdded: { + emoji: Packed<'Emoji'>; + }; +} + +export interface UserStreamTypes { + terminate: Record; + followChannel: Channel; + unfollowChannel: Channel; + updateUserProfile: UserProfile; + mute: User; + unmute: User; + follow: Packed<'UserDetailedNotMe'>; + unfollow: Packed<'User'>; + userAdded: Packed<'User'>; +} + +export interface MainStreamTypes { + notification: Packed<'Notification'>; + mention: Packed<'Note'>; + reply: Packed<'Note'>; + renote: Packed<'Note'>; + follow: Packed<'UserDetailedNotMe'>; + followed: Packed<'User'>; + unfollow: Packed<'User'>; + meUpdated: Packed<'User'>; + pageEvent: { + pageId: Page['id']; + event: string; + var: any; + userId: User['id']; + user: Packed<'User'>; + }; + urlUploadFinished: { + marker?: string | null; + file: Packed<'DriveFile'>; + }; + readAllNotifications: undefined; + unreadNotification: Packed<'Notification'>; + unreadMention: Note['id']; + readAllUnreadMentions: undefined; + unreadSpecifiedNote: Note['id']; + readAllUnreadSpecifiedNotes: undefined; + readAllMessagingMessages: undefined; + messagingMessage: Packed<'MessagingMessage'>; + unreadMessagingMessage: Packed<'MessagingMessage'>; + readAllAntennas: undefined; + unreadAntenna: Antenna; + readAllAnnouncements: undefined; + readAllChannels: undefined; + unreadChannel: Note['id']; + myTokenRegenerated: undefined; + signin: Signin; + registryUpdated: { + scope?: string[]; + key: string; + value: any | null; + }; + driveFileCreated: Packed<'DriveFile'>; + readAntenna: Antenna; + receiveFollowRequest: Packed<'User'>; +} + +export interface DriveStreamTypes { + fileCreated: Packed<'DriveFile'>; + fileDeleted: DriveFile['id']; + fileUpdated: Packed<'DriveFile'>; + folderCreated: Packed<'DriveFolder'>; + folderDeleted: DriveFolder['id']; + folderUpdated: Packed<'DriveFolder'>; +} + +export interface NoteStreamTypes { + pollVoted: { + choice: number; + userId: User['id']; + }; + deleted: { + deletedAt: Date; + }; + reacted: { + reaction: string; + emoji?: { + name: string; + url: string; + } | null; + userId: User['id']; + }; + unreacted: { + reaction: string; + userId: User['id']; + }; +} +type NoteStreamEventTypes = { + [key in keyof NoteStreamTypes]: { + id: Note['id']; + body: NoteStreamTypes[key]; + }; +}; + +export interface ChannelStreamTypes { + typing: User['id']; +} + +export interface UserListStreamTypes { + userAdded: Packed<'User'>; + userRemoved: Packed<'User'>; +} + +export interface AntennaStreamTypes { + note: Note; +} + +export interface MessagingStreamTypes { + read: MessagingMessage['id'][]; + typing: User['id']; + message: Packed<'MessagingMessage'>; + deleted: MessagingMessage['id']; +} + +export interface GroupMessagingStreamTypes { + read: { + ids: MessagingMessage['id'][]; + userId: User['id']; + }; + typing: User['id']; + message: Packed<'MessagingMessage'>; + deleted: MessagingMessage['id']; +} + +export interface MessagingIndexStreamTypes { + read: MessagingMessage['id'][]; + message: Packed<'MessagingMessage'>; +} + +export interface AdminStreamTypes { + newAbuseUserReport: { + id: AbuseUserReport['id']; + targetUserId: User['id'], + reporterId: User['id'], + comment: string; + }; +} +//#endregion + +// 辞書(interface or type)から{ type, body }ユニオンを定義 +// https://stackoverflow.com/questions/49311989/can-i-infer-the-type-of-a-value-using-extends-keyof-type +// VS Codeの展開を防止するためにEvents型を定義 +type Events = { [K in keyof T]: { type: K; body: T[K]; } }; +type EventUnionFromDictionary< + T extends object, + U = Events +> = U[keyof U]; + +// name/messages(spec) pairs dictionary +export type StreamMessages = { + internal: { + name: 'internal'; + payload: EventUnionFromDictionary; + }; + broadcast: { + name: 'broadcast'; + payload: EventUnionFromDictionary; + }; + user: { + name: `user:${User['id']}`; + payload: EventUnionFromDictionary; + }; + main: { + name: `mainStream:${User['id']}`; + payload: EventUnionFromDictionary; + }; + drive: { + name: `driveStream:${User['id']}`; + payload: EventUnionFromDictionary; + }; + note: { + name: `noteStream:${Note['id']}`; + payload: EventUnionFromDictionary; + }; + channel: { + name: `channelStream:${Channel['id']}`; + payload: EventUnionFromDictionary; + }; + userList: { + name: `userListStream:${UserList['id']}`; + payload: EventUnionFromDictionary; + }; + antenna: { + name: `antennaStream:${Antenna['id']}`; + payload: EventUnionFromDictionary; + }; + messaging: { + name: `messagingStream:${User['id']}-${User['id']}`; + payload: EventUnionFromDictionary; + }; + groupMessaging: { + name: `messagingStream:${UserGroup['id']}`; + payload: EventUnionFromDictionary; + }; + messagingIndex: { + name: `messagingIndexStream:${User['id']}`; + payload: EventUnionFromDictionary; + }; + admin: { + name: `adminStream:${User['id']}`; + payload: EventUnionFromDictionary; + }; + notes: { + name: 'notesStream'; + payload: Packed<'Note'>; + }; +}; + +// API event definitions +// ストリームごとのEmitterの辞書を用意 +type EventEmitterDictionary = { [x in keyof StreamMessages]: Emitter void }> }; +// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; +// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする +export type StreamEventEmitter = UnionToIntersection; +// { [y in name]: (e: spec) => void }をまとめてその交差型をEmitterにかけるとts(2590)にひっかかる + +// provide stream channels union +export type StreamChannels = StreamMessages[keyof StreamMessages]['name']; diff --git a/packages/backend/src/server/api/streaming.ts b/packages/backend/src/server/api/streaming.ts new file mode 100644 index 0000000000..f8e42d27fe --- /dev/null +++ b/packages/backend/src/server/api/streaming.ts @@ -0,0 +1,67 @@ +import * as http from 'node:http'; +import * as websocket from 'websocket'; + +import MainStreamConnection from './stream/index.js'; +import { ParsedUrlQuery } from 'querystring'; +import authenticate from './authenticate.js'; +import { EventEmitter } from 'events'; +import { subsdcriber as redisClient } from '../../db/redis.js'; +import { Users } from '@/models/index.js'; + +export const initializeStreamingServer = (server: http.Server) => { + // Init websocket server + const ws = new websocket.server({ + httpServer: server, + }); + + ws.on('request', async (request) => { + const q = request.resourceURL.query as ParsedUrlQuery; + + // TODO: トークンが間違ってるなどしてauthenticateに失敗したら + // コネクション切断するなりエラーメッセージ返すなりする + // (現状はエラーがキャッチされておらずサーバーのログに流れて邪魔なので) + const [user, app] = await authenticate(q.i as string); + + if (user?.isSuspended) { + request.reject(400); + return; + } + + const connection = request.accept(); + + const ev = new EventEmitter(); + + async function onRedisMessage(_: string, data: string) { + const parsed = JSON.parse(data); + ev.emit(parsed.channel, parsed.message); + } + + redisClient.on('message', onRedisMessage); + + const main = new MainStreamConnection(connection, ev, user, app); + + const intervalId = user ? setInterval(() => { + Users.update(user.id, { + lastActiveDate: new Date(), + }); + }, 1000 * 60 * 5) : null; + if (user) { + Users.update(user.id, { + lastActiveDate: new Date(), + }); + } + + connection.once('close', () => { + ev.removeAllListeners(); + main.dispose(); + redisClient.off('message', onRedisMessage); + if (intervalId) clearInterval(intervalId); + }); + + connection.on('message', async (data) => { + if (data.type === 'utf8' && data.utf8Data === 'ping') { + connection.send('pong'); + } + }); + }); +}; diff --git a/packages/backend/src/server/assets/bad-egg.png b/packages/backend/src/server/file/assets/bad-egg.png similarity index 100% rename from packages/backend/src/server/assets/bad-egg.png rename to packages/backend/src/server/file/assets/bad-egg.png diff --git a/packages/backend/src/server/assets/cache-expired.png b/packages/backend/src/server/file/assets/cache-expired.png similarity index 100% rename from packages/backend/src/server/assets/cache-expired.png rename to packages/backend/src/server/file/assets/cache-expired.png diff --git a/packages/backend/src/server/assets/dummy.png b/packages/backend/src/server/file/assets/dummy.png similarity index 100% rename from packages/backend/src/server/assets/dummy.png rename to packages/backend/src/server/file/assets/dummy.png diff --git a/packages/backend/src/server/assets/not-an-image.png b/packages/backend/src/server/file/assets/not-an-image.png similarity index 100% rename from packages/backend/src/server/assets/not-an-image.png rename to packages/backend/src/server/file/assets/not-an-image.png diff --git a/packages/backend/src/server/assets/thumbnail-not-available.png b/packages/backend/src/server/file/assets/thumbnail-not-available.png similarity index 100% rename from packages/backend/src/server/assets/thumbnail-not-available.png rename to packages/backend/src/server/file/assets/thumbnail-not-available.png diff --git a/packages/backend/src/server/assets/tombstone.png b/packages/backend/src/server/file/assets/tombstone.png similarity index 100% rename from packages/backend/src/server/assets/tombstone.png rename to packages/backend/src/server/file/assets/tombstone.png diff --git a/packages/backend/src/server/file/index.ts b/packages/backend/src/server/file/index.ts new file mode 100644 index 0000000000..07a493700a --- /dev/null +++ b/packages/backend/src/server/file/index.ts @@ -0,0 +1,40 @@ +/** + * File Server + */ + +import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import Koa from 'koa'; +import cors from '@koa/cors'; +import Router from '@koa/router'; +import sendDriveFile from './send-drive-file.js'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +// Init app +const app = new Koa(); +app.use(cors()); +app.use(async (ctx, next) => { + ctx.set('Content-Security-Policy', `default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`); + await next(); +}); + +// Init router +const router = new Router(); + +router.get('/app-default.jpg', ctx => { + const file = fs.createReadStream(`${_dirname}/assets/dummy.png`); + ctx.body = file; + ctx.set('Content-Type', 'image/jpeg'); + ctx.set('Cache-Control', 'max-age=31536000, immutable'); +}); + +router.get('/:key', sendDriveFile); +router.get('/:key/(.*)', sendDriveFile); + +// Register router +app.use(router.routes()); + +export default app; diff --git a/packages/backend/src/server/file/send-drive-file.ts b/packages/backend/src/server/file/send-drive-file.ts new file mode 100644 index 0000000000..c34e043145 --- /dev/null +++ b/packages/backend/src/server/file/send-drive-file.ts @@ -0,0 +1,126 @@ +import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import Koa from 'koa'; +import send from 'koa-send'; +import rename from 'rename'; +import { serverLogger } from '../index.js'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import { DriveFiles } from '@/models/index.js'; +import { InternalStorage } from '@/services/drive/internal-storage.js'; +import { createTemp } from '@/misc/create-temp.js'; +import { downloadUrl } from '@/misc/download-url.js'; +import { detectType } from '@/misc/get-file-info.js'; +import { convertToWebp, convertToJpeg, convertToPng } from '@/services/drive/image-processor.js'; +import { GenerateVideoThumbnail } from '@/services/drive/generate-video-thumbnail.js'; +import { StatusError } from '@/misc/fetch.js'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +const assets = `${_dirname}/../../server/file/assets/`; + +const commonReadableHandlerGenerator = (ctx: Koa.Context) => (e: Error): void => { + serverLogger.error(e); + ctx.status = 500; + ctx.set('Cache-Control', 'max-age=300'); +}; + +// eslint-disable-next-line import/no-default-export +export default async function(ctx: Koa.Context) { + const key = ctx.params.key; + + // Fetch drive file + const file = await DriveFiles.createQueryBuilder('file') + .where('file.accessKey = :accessKey', { accessKey: key }) + .orWhere('file.thumbnailAccessKey = :thumbnailAccessKey', { thumbnailAccessKey: key }) + .orWhere('file.webpublicAccessKey = :webpublicAccessKey', { webpublicAccessKey: key }) + .getOne(); + + if (file == null) { + ctx.status = 404; + ctx.set('Cache-Control', 'max-age=86400'); + await send(ctx as any, '/dummy.png', { root: assets }); + return; + } + + const isThumbnail = file.thumbnailAccessKey === key; + const isWebpublic = file.webpublicAccessKey === key; + + if (!file.storedInternal) { + if (file.isLink && file.uri) { // 期限切れリモートファイル + const [path, cleanup] = await createTemp(); + + try { + await downloadUrl(file.uri, path); + + const { mime, ext } = await detectType(path); + + const convertFile = async () => { + if (isThumbnail) { + if (['image/jpeg', 'image/webp', 'image/png', 'image/svg+xml'].includes(mime)) { + return await convertToWebp(path, 498, 280); + } else if (mime.startsWith('video/')) { + return await GenerateVideoThumbnail(path); + } + } + + if (isWebpublic) { + if (['image/svg+xml'].includes(mime)) { + return await convertToPng(path, 2048, 2048); + } + } + + return { + data: fs.readFileSync(path), + ext, + type: mime, + }; + }; + + const image = await convertFile(); + ctx.body = image.data; + ctx.set('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); + ctx.set('Cache-Control', 'max-age=31536000, immutable'); + } catch (e) { + serverLogger.error(`${e}`); + + if (e instanceof StatusError && e.isClientError) { + ctx.status = e.statusCode; + ctx.set('Cache-Control', 'max-age=86400'); + } else { + ctx.status = 500; + ctx.set('Cache-Control', 'max-age=300'); + } + } finally { + cleanup(); + } + return; + } + + ctx.status = 204; + ctx.set('Cache-Control', 'max-age=86400'); + return; + } + + if (isThumbnail || isWebpublic) { + const { mime, ext } = await detectType(InternalStorage.resolvePath(key)); + const filename = rename(file.name, { + suffix: isThumbnail ? '-thumb' : '-web', + extname: ext ? `.${ext}` : undefined, + }).toString(); + + ctx.body = InternalStorage.read(key); + ctx.set('Content-Type', FILE_TYPE_BROWSERSAFE.includes(mime) ? mime : 'application/octet-stream'); + ctx.set('Cache-Control', 'max-age=31536000, immutable'); + ctx.set('Content-Disposition', contentDisposition('inline', filename)); + } else { + const readable = InternalStorage.read(file.accessKey!); + readable.on('error', commonReadableHandlerGenerator(ctx)); + ctx.body = readable; + ctx.set('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.type) ? file.type : 'application/octet-stream'); + ctx.set('Cache-Control', 'max-age=31536000, immutable'); + ctx.set('Content-Disposition', contentDisposition('inline', file.name)); + } +} diff --git a/packages/backend/src/server/index.ts b/packages/backend/src/server/index.ts new file mode 100644 index 0000000000..f31de2b7f4 --- /dev/null +++ b/packages/backend/src/server/index.ts @@ -0,0 +1,168 @@ +/** + * Core Server + */ + +import cluster from 'node:cluster'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import Koa from 'koa'; +import Router from '@koa/router'; +import mount from 'koa-mount'; +import koaLogger from 'koa-logger'; +import * as slow from 'koa-slow'; + +import { IsNull } from 'typeorm'; +import config from '@/config/index.js'; +import Logger from '@/services/logger.js'; +import { UserProfiles, Users } from '@/models/index.js'; +import { genIdenticon } from '@/misc/gen-identicon.js'; +import { createTemp } from '@/misc/create-temp.js'; +import { publishMainStream } from '@/services/stream.js'; +import * as Acct from '@/misc/acct.js'; +import { envOption } from '../env.js'; +import activityPub from './activitypub.js'; +import nodeinfo from './nodeinfo.js'; +import wellKnown from './well-known.js'; +import apiServer from './api/index.js'; +import fileServer from './file/index.js'; +import proxyServer from './proxy/index.js'; +import webServer from './web/index.js'; +import { initializeStreamingServer } from './api/streaming.js'; + +export const serverLogger = new Logger('server', 'gray', false); + +// Init app +const app = new Koa(); +app.proxy = true; + +if (!['production', 'test'].includes(process.env.NODE_ENV || '')) { + // Logger + app.use(koaLogger(str => { + serverLogger.info(str); + })); + + // Delay + if (envOption.slow) { + app.use(slow({ + delay: 3000, + })); + } +} + +// HSTS +// 6months (15552000sec) +if (config.url.startsWith('https') && !config.disableHsts) { + app.use(async (ctx, next) => { + ctx.set('strict-transport-security', 'max-age=15552000; preload'); + await next(); + }); +} + +app.use(mount('/api', apiServer)); +app.use(mount('/files', fileServer)); +app.use(mount('/proxy', proxyServer)); + +// Init router +const router = new Router(); + +// Routing +router.use(activityPub.routes()); +router.use(nodeinfo.routes()); +router.use(wellKnown.routes()); + +router.get('/avatar/@:acct', async ctx => { + const { username, host } = Acct.parse(ctx.params.acct); + const user = await Users.findOne({ + where: { + usernameLower: username.toLowerCase(), + host: (host == null) || (host === config.host) ? IsNull() : host, + isSuspended: false, + }, + relations: ['avatar'], + }); + + if (user) { + ctx.redirect(Users.getAvatarUrlSync(user)); + } else { + ctx.redirect('/static-assets/user-unknown.png'); + } +}); + +router.get('/identicon/:x', async ctx => { + const [temp, cleanup] = await createTemp(); + await genIdenticon(ctx.params.x, fs.createWriteStream(temp)); + ctx.set('Content-Type', 'image/png'); + ctx.body = fs.createReadStream(temp).on('close', () => cleanup()); +}); + +router.get('/verify-email/:code', async ctx => { + const profile = await UserProfiles.findOneBy({ + emailVerifyCode: ctx.params.code, + }); + + if (profile != null) { + ctx.body = 'Verify succeeded!'; + ctx.status = 200; + + await UserProfiles.update({ userId: profile.userId }, { + emailVerified: true, + emailVerifyCode: null, + }); + + publishMainStream(profile.userId, 'meUpdated', await Users.pack(profile.userId, { id: profile.userId }, { + detail: true, + includeSecrets: true, + })); + } else { + ctx.status = 404; + } +}); + +// Register router +app.use(router.routes()); + +app.use(mount(webServer)); + +function createServer() { + return http.createServer(app.callback()); +} + +// For testing +export const startServer = () => { + const server = createServer(); + + initializeStreamingServer(server); + + server.listen(config.port); + + return server; +}; + +export default () => new Promise(resolve => { + const server = createServer(); + + initializeStreamingServer(server); + + server.on('error', e => { + switch ((e as any).code) { + case 'EACCES': + serverLogger.error(`You do not have permission to listen on port ${config.port}.`); + break; + case 'EADDRINUSE': + serverLogger.error(`Port ${config.port} is already in use by another process.`); + break; + default: + serverLogger.error(e); + break; + } + + if (cluster.isWorker) { + process.send!('listenFailed'); + } else { + // disableClustering + process.exit(1); + } + }); + + server.listen(config.port, resolve); +}); diff --git a/packages/backend/src/server/nodeinfo.ts b/packages/backend/src/server/nodeinfo.ts new file mode 100644 index 0000000000..f139d203d2 --- /dev/null +++ b/packages/backend/src/server/nodeinfo.ts @@ -0,0 +1,104 @@ +import Router from '@koa/router'; +import config from '@/config/index.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Users, Notes } from '@/models/index.js'; +import { IsNull, MoreThan } from 'typeorm'; +import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { Cache } from '@/misc/cache.js'; + +const router = new Router(); + +const nodeinfo2_1path = '/nodeinfo/2.1'; +const nodeinfo2_0path = '/nodeinfo/2.0'; + +export const links = [/* (awaiting release) { + rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', + href: config.url + nodeinfo2_1path +}, */{ + rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', + href: config.url + nodeinfo2_0path, +}]; + +const nodeinfo2 = async () => { + const now = Date.now(); + const [ + meta, + total, + activeHalfyear, + activeMonth, + localPosts, + ] = await Promise.all([ + fetchMeta(true), + Users.count({ where: { host: IsNull() } }), + Users.count({ where: { host: IsNull(), lastActiveDate: MoreThan(new Date(now - 15552000000)) } }), + Users.count({ where: { host: IsNull(), lastActiveDate: MoreThan(new Date(now - 2592000000)) } }), + Notes.count({ where: { userHost: IsNull() } }), + ]); + + const proxyAccount = meta.proxyAccountId ? await Users.pack(meta.proxyAccountId).catch(() => null) : null; + + return { + software: { + name: 'misskey', + version: config.version, + repository: meta.repositoryUrl, + }, + protocols: ['activitypub'], + services: { + inbound: [] as string[], + outbound: ['atom1.0', 'rss2.0'], + }, + openRegistrations: !meta.disableRegistration, + usage: { + users: { total, activeHalfyear, activeMonth }, + localPosts, + localComments: 0, + }, + metadata: { + nodeName: meta.name, + nodeDescription: meta.description, + maintainer: { + name: meta.maintainerName, + email: meta.maintainerEmail, + }, + langs: meta.langs, + tosUrl: meta.ToSUrl, + repositoryUrl: meta.repositoryUrl, + feedbackUrl: meta.feedbackUrl, + disableRegistration: meta.disableRegistration, + disableLocalTimeline: meta.disableLocalTimeline, + disableGlobalTimeline: meta.disableGlobalTimeline, + emailRequiredForSignup: meta.emailRequiredForSignup, + enableHcaptcha: meta.enableHcaptcha, + enableRecaptcha: meta.enableRecaptcha, + maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, + enableTwitterIntegration: meta.enableTwitterIntegration, + enableGithubIntegration: meta.enableGithubIntegration, + enableDiscordIntegration: meta.enableDiscordIntegration, + enableEmail: meta.enableEmail, + enableServiceWorker: meta.enableServiceWorker, + proxyAccountName: proxyAccount ? proxyAccount.username : null, + themeColor: meta.themeColor || '#86b300', + }, + }; +}; + +const cache = new Cache>>(1000 * 60 * 10); + +router.get(nodeinfo2_1path, async ctx => { + const base = await cache.fetch(null, () => nodeinfo2()); + + ctx.body = { version: '2.1', ...base }; + ctx.set('Cache-Control', 'public, max-age=600'); +}); + +router.get(nodeinfo2_0path, async ctx => { + const base = await cache.fetch(null, () => nodeinfo2()); + + delete base.software.repository; + + ctx.body = { version: '2.0', ...base }; + ctx.set('Cache-Control', 'public, max-age=600'); +}); + +export default router; diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts deleted file mode 100644 index 6de9038620..0000000000 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ /dev/null @@ -1,205 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import querystring from 'querystring'; -import { Inject, Injectable } from '@nestjs/common'; -import megalodon, { MegalodonInterface } from 'megalodon'; -import { v4 as uuid } from 'uuid'; -/* import { kinds } from '@/misc/api-permissions.js'; -import type { Config } from '@/config.js'; -import { DI } from '@/di-symbols.js'; */ -import multer from 'fastify-multer'; -import { bindThis } from '@/decorators.js'; -import type { Config } from '@/config.js'; -import { DI } from '@/di-symbols.js'; -import type { FastifyInstance } from 'fastify'; - -const kinds = [ - 'read:account', - 'write:account', - 'read:blocks', - 'write:blocks', - 'read:drive', - 'write:drive', - 'read:favorites', - 'write:favorites', - 'read:following', - 'write:following', - 'read:messaging', - 'write:messaging', - 'read:mutes', - 'write:mutes', - 'write:notes', - 'read:notifications', - 'write:notifications', - 'read:reactions', - 'write:reactions', - 'write:votes', - 'read:pages', - 'write:pages', - 'write:page-likes', - 'read:page-likes', - 'read:user-groups', - 'write:user-groups', - 'read:channels', - 'write:channels', - 'read:gallery', - 'write:gallery', - 'read:gallery-likes', - 'write:gallery-likes', -]; - -function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface { - const accessTokenArr = authorization?.split(' ') ?? [null]; - const accessToken = accessTokenArr[accessTokenArr.length - 1]; - const generator = (megalodon as any).default; - const client = generator('misskey', BASE_URL, accessToken) as MegalodonInterface; - return client; -} - -@Injectable() -export class OAuth2ProviderService { - constructor( - @Inject(DI.config) - private config: Config, - ) { } - - // https://datatracker.ietf.org/doc/html/rfc8414.html - // https://indieauth.spec.indieweb.org/#indieauth-server-metadata - public generateRFC8414() { - return { - issuer: this.config.url, - authorization_endpoint: new URL('/oauth/authorize', this.config.url), - token_endpoint: new URL('/oauth/token', this.config.url), - scopes_supported: kinds, - response_types_supported: ['code'], - grant_types_supported: ['authorization_code'], - service_documentation: 'https://misskey-hub.net', - code_challenge_methods_supported: ['S256'], - authorization_response_iss_parameter_supported: true, - }; - } - - @bindThis - public async createServer(fastify: FastifyInstance): Promise { - // https://datatracker.ietf.org/doc/html/rfc8414.html - // https://indieauth.spec.indieweb.org/#indieauth-server-metadata - /* fastify.get('/.well-known/oauth-authorization-server', async (_request, reply) => { - reply.send({ - issuer: this.config.url, - authorization_endpoint: new URL('/oauth/authorize', this.config.url), - token_endpoint: new URL('/oauth/token', this.config.url), - scopes_supported: kinds, - response_types_supported: ['code'], - grant_types_supported: ['authorization_code'], - service_documentation: 'https://misskey-hub.net', - code_challenge_methods_supported: ['S256'], - authorization_response_iss_parameter_supported: true, - }); - }); */ - - const upload = multer({ - storage: multer.diskStorage({}), - limits: { - fileSize: this.config.maxFileSize || 262144000, - files: 1, - }, - }); - - fastify.addHook('onRequest', (request, reply, done) => { - reply.header('Access-Control-Allow-Origin', '*'); - done(); - }); - - fastify.addContentTypeParser('application/x-www-form-urlencoded', (request, payload, done) => { - let body = ''; - payload.on('data', (data) => { - body += data; - }); - payload.on('end', () => { - try { - const parsed = querystring.parse(body); - done(null, parsed); - } catch (e: any) { - done(e); - } - }); - payload.on('error', done); - }); - - fastify.register(multer.contentParser); - - fastify.get('/authorize', async (request, reply) => { - const query: any = request.query; - let param = "mastodon=true"; - if (query.state) param += `&state=${query.state}`; - if (query.redirect_uri) param += `&redirect_uri=${query.redirect_uri}`; - const client = query.client_id ? query.client_id : ""; - reply.redirect( - `${Buffer.from(client.toString(), 'base64').toString()}?${param}`, - ); - }); - - fastify.get('/authorize/', async (request, reply) => { - const query: any = request.query; - let param = "mastodon=true"; - if (query.state) param += `&state=${query.state}`; - if (query.redirect_uri) param += `&redirect_uri=${query.redirect_uri}`; - const client = query.client_id ? query.client_id : ""; - reply.redirect( - `${Buffer.from(client.toString(), 'base64').toString()}?${param}`, - ); - }); - - fastify.post('/token', { preHandler: upload.none() }, async (request, reply) => { - const body: any = request.body || request.query; - if (body.grant_type === "client_credentials") { - const ret = { - access_token: uuid(), - token_type: "Bearer", - scope: "read", - created_at: Math.floor(new Date().getTime() / 1000), - }; - reply.send(ret); - } - let client_id: any = body.client_id; - const BASE_URL = `${request.protocol}://${request.hostname}`; - const client = getClient(BASE_URL, ''); - let token = null; - if (body.code) { - //m = body.code.match(/^([a-zA-Z0-9]{8})([a-zA-Z0-9]{4})([a-zA-Z0-9]{4})([a-zA-Z0-9]{4})([a-zA-Z0-9]{12})/); - //if (!m.length) { - // ctx.body = { error: "Invalid code" }; - // return; - //} - //token = `${m[1]}-${m[2]}-${m[3]}-${m[4]}-${m[5]}` - //console.log(body.code, token); - token = body.code; - } - if (client_id instanceof Array) { - client_id = client_id.toString(); - } else if (!client_id) { - client_id = null; - } - try { - const atData = await client.fetchAccessToken( - client_id, - body.client_secret, - token ? token : "", - ); - const ret = { - access_token: atData.accessToken, - token_type: "Bearer", - scope: body.scope || "read write follow push", - created_at: Math.floor(new Date().getTime() / 1000), - }; - reply.send(ret); - } catch (err: any) { - /* console.error(err); */ - reply.code(401).send(err.response.data); - } - }); - } -} diff --git a/packages/backend/src/server/proxy/index.ts b/packages/backend/src/server/proxy/index.ts new file mode 100644 index 0000000000..506ba10ef1 --- /dev/null +++ b/packages/backend/src/server/proxy/index.ts @@ -0,0 +1,26 @@ +/** + * Media Proxy + */ + +import Koa from 'koa'; +import cors from '@koa/cors'; +import Router from '@koa/router'; +import { proxyMedia } from './proxy-media.js'; + +// Init app +const app = new Koa(); +app.use(cors()); +app.use(async (ctx, next) => { + ctx.set('Content-Security-Policy', `default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`); + await next(); +}); + +// Init router +const router = new Router(); + +router.get('/:url*', proxyMedia); + +// Register router +app.use(router.routes()); + +export default app; diff --git a/packages/backend/src/server/proxy/proxy-media.ts b/packages/backend/src/server/proxy/proxy-media.ts new file mode 100644 index 0000000000..ca036e8fdf --- /dev/null +++ b/packages/backend/src/server/proxy/proxy-media.ts @@ -0,0 +1,98 @@ +import * as fs from 'node:fs'; +import Koa from 'koa'; +import sharp from 'sharp'; +import { IImage, convertToWebp } from '@/services/drive/image-processor.js'; +import { createTemp } from '@/misc/create-temp.js'; +import { downloadUrl } from '@/misc/download-url.js'; +import { detectType } from '@/misc/get-file-info.js'; +import { StatusError } from '@/misc/fetch.js'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import { serverLogger } from '../index.js'; +import { isMimeImage } from '@/misc/is-mime-image.js'; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export async function proxyMedia(ctx: Koa.Context) { + const url = 'url' in ctx.query ? ctx.query.url : 'https://' + ctx.params.url; + + if (typeof url !== 'string') { + ctx.status = 400; + return; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + try { + await downloadUrl(url, path); + + const { mime, ext } = await detectType(path); + const isConvertibleImage = isMimeImage(mime, 'sharp-convertible-image'); + + let image: IImage; + + if ('static' in ctx.query && isConvertibleImage) { + image = await convertToWebp(path, 498, 280); + } else if ('preview' in ctx.query && isConvertibleImage) { + image = await convertToWebp(path, 200, 200); + } else if ('badge' in ctx.query) { + if (!isConvertibleImage) { + // 画像でないなら404でお茶を濁す + throw new StatusError('Unexpected mime', 404); + } + + const mask = sharp(path) + .resize(96, 96, { + fit: 'inside', + withoutEnlargement: false, + }) + .greyscale() + .normalise() + .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast + .flatten({ background: '#000' }) + .toColorspace('b-w'); + + const stats = await mask.clone().stats(); + + if (stats.entropy < 0.1) { + // エントロピーがあまりない場合は404にする + throw new StatusError('Skip to provide badge', 404); + } + + const data = sharp({ + create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, + }) + .pipelineColorspace('b-w') + .boolean(await mask.png().toBuffer(), 'eor'); + + image = { + data: await data.png().toBuffer(), + ext: 'png', + type: 'image/png', + }; + } else if (mime === 'image/svg+xml') { + image = await convertToWebp(path, 2048, 2048, 1); + } else if (!mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(mime)) { + throw new StatusError('Rejected type', 403, 'Rejected type'); + } else { + image = { + data: fs.readFileSync(path), + ext, + type: mime, + }; + } + + ctx.set('Content-Type', image.type); + ctx.set('Cache-Control', 'max-age=31536000, immutable'); + ctx.body = image.data; + } catch (e) { + serverLogger.error(`${e}`); + + if (e instanceof StatusError && (e.statusCode === 302 || e.isClientError)) { + ctx.status = e.statusCode; + } else { + ctx.status = 500; + } + } finally { + cleanup(); + } +} diff --git a/packages/backend/src/server/web/ClientLoggerService.ts b/packages/backend/src/server/web/ClientLoggerService.ts deleted file mode 100644 index 213266f59c..0000000000 --- a/packages/backend/src/server/web/ClientLoggerService.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import type Logger from '@/logger.js'; -import { LoggerService } from '@/core/LoggerService.js'; - -@Injectable() -export class ClientLoggerService { - public logger: Logger; - - constructor( - private loggerService: LoggerService, - ) { - this.logger = this.loggerService.getLogger('client'); - } -} diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts deleted file mode 100644 index 3488f6694f..0000000000 --- a/packages/backend/src/server/web/ClientServerService.ts +++ /dev/null @@ -1,776 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { randomUUID } from 'node:crypto'; -import { dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { Inject, Injectable } from '@nestjs/common'; -import { createBullBoard } from '@bull-board/api'; -import { BullMQAdapter } from '@bull-board/api/bullMQAdapter.js'; -import { FastifyAdapter } from '@bull-board/fastify'; -import ms from 'ms'; -import sharp from 'sharp'; -import pug from 'pug'; -import { In, IsNull } from 'typeorm'; -import fastifyStatic from '@fastify/static'; -import fastifyView from '@fastify/view'; -import fastifyCookie from '@fastify/cookie'; -import fastifyProxy from '@fastify/http-proxy'; -import vary from 'vary'; -import type { Config } from '@/config.js'; -import { getNoteSummary } from '@/misc/get-note-summary.js'; -import { DI } from '@/di-symbols.js'; -import * as Acct from '@/misc/acct.js'; -import { MetaService } from '@/core/MetaService.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from '@/core/QueueModule.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { PageEntityService } from '@/core/entities/PageEntityService.js'; -import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; -import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; -import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; -import type { ChannelsRepository, ClipsRepository, FlashsRepository, GalleryPostsRepository, MiMeta, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import { deepClone } from '@/misc/clone.js'; -import { bindThis } from '@/decorators.js'; -import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; -import { RoleService } from '@/core/RoleService.js'; -import { FeedService } from './FeedService.js'; -import { UrlPreviewService } from './UrlPreviewService.js'; -import { ClientLoggerService } from './ClientLoggerService.js'; -import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify'; - -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); - -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_/`; - -@Injectable() -export class ClientServerService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.galleryPostsRepository) - private galleryPostsRepository: GalleryPostsRepository, - - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - - @Inject(DI.clipsRepository) - private clipsRepository: ClipsRepository, - - @Inject(DI.pagesRepository) - private pagesRepository: PagesRepository, - - @Inject(DI.flashsRepository) - private flashsRepository: FlashsRepository, - - private flashEntityService: FlashEntityService, - private userEntityService: UserEntityService, - private noteEntityService: NoteEntityService, - private pageEntityService: PageEntityService, - private galleryPostEntityService: GalleryPostEntityService, - private clipEntityService: ClipEntityService, - private channelEntityService: ChannelEntityService, - private metaService: MetaService, - private urlPreviewService: UrlPreviewService, - private feedService: FeedService, - private roleService: RoleService, - private clientLoggerService: ClientLoggerService, - - @Inject('queue:system') public systemQueue: SystemQueue, - @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, - @Inject('queue:deliver') public deliverQueue: DeliverQueue, - @Inject('queue:inbox') public inboxQueue: InboxQueue, - @Inject('queue:db') public dbQueue: DbQueue, - @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, - @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, - ) { - //this.createServer = this.createServer.bind(this); - } - - @bindThis - private async manifestHandler(reply: FastifyReply) { - const instance = await this.metaService.fetch(true); - - let manifest = { - // 空文字列の場合右辺を使いたいため - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'short_name': instance.shortName || instance.name || this.config.host, - // 空文字列の場合右辺を使いたいため - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'name': instance.name || this.config.host, - 'start_url': '/', - 'display': 'standalone', - 'background_color': '#313a42', - // 空文字列の場合右辺を使いたいため - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'theme_color': instance.themeColor || '#86b300', - 'icons': [{ - // 空文字列の場合右辺を使いたいため - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'src': instance.app192IconUrl || '/static-assets/icons/192.png', - 'sizes': '192x192', - 'type': 'image/png', - 'purpose': 'maskable', - }, { - // 空文字列の場合右辺を使いたいため - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'src': instance.app512IconUrl || '/static-assets/icons/512.png', - 'sizes': '512x512', - 'type': 'image/png', - 'purpose': 'maskable', - }, { - // 空文字列の場合右辺を使いたいため - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'src': instance.app512IconUrl || '/static-assets/icons/512.png', - 'sizes': '300x300', - 'type': 'image/png', - 'purpose': 'any', - }], - 'share_target': { - 'action': '/share/', - 'method': 'GET', - 'enctype': 'application/x-www-form-urlencoded', - 'params': { - 'title': 'title', - 'text': 'text', - 'url': 'url', - }, - }, - }; - - manifest = { - ...manifest, - ...JSON.parse(instance.manifestJsonOverride === '' ? '{}' : instance.manifestJsonOverride), - }; - - reply.header('Cache-Control', 'max-age=300'); - return (manifest); - } - - @bindThis - private generateCommonPugData(meta: MiMeta) { - return { - instanceName: meta.name ?? 'Sharkey', - icon: meta.iconUrl, - appleTouchIcon: meta.app512IconUrl, - themeColor: meta.themeColor, - serverErrorImageUrl: meta.serverErrorImageUrl ?? 'https://launcher.moe/error.png', - infoImageUrl: meta.infoImageUrl ?? 'https://launcher.moe/nothinghere.png', - notFoundImageUrl: meta.notFoundImageUrl ?? 'https://launcher.moe/missingpage.webp', - instanceUrl: this.config.url, - randomMOTD: this.config.customMOTD ? this.config.customMOTD[Math.floor(Math.random() * this.config.customMOTD.length)] : undefined, - }; - } - - @bindThis - public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { - fastify.register(fastifyCookie, {}); - - //#region Bull Dashboard - const bullBoardPath = '/queue'; - - // Authenticate - fastify.addHook('onRequest', async (request, reply) => { - // %71ueueとかでリクエストされたら困るため - const url = decodeURI(request.routeOptions.url); - if (url === bullBoardPath || url.startsWith(bullBoardPath + '/')) { - const token = request.cookies.token; - if (token == null) { - reply.code(401).send('Login required'); - return; - } - const user = await this.usersRepository.findOneBy({ token }); - if (user == null) { - reply.code(403).send('No such user'); - return; - } - const isAdministrator = await this.roleService.isAdministrator(user); - if (!isAdministrator) { - reply.code(403).send('Access denied'); - return; - } - } - }); - - const serverAdapter = new FastifyAdapter(); - - createBullBoard({ - queues: [ - this.systemQueue, - this.endedPollNotificationQueue, - this.deliverQueue, - this.inboxQueue, - this.dbQueue, - this.objectStorageQueue, - this.webhookDeliverQueue, - ].map(q => new BullMQAdapter(q)), - serverAdapter, - }); - - serverAdapter.setBasePath(bullBoardPath); - (fastify.register as any)(serverAdapter.registerPlugin(), { prefix: bullBoardPath }); - //#endregion - - fastify.register(fastifyView, { - root: _dirname + '/views', - engine: { - pug: pug, - }, - defaultContext: { - version: this.config.version, - config: this.config, - }, - }); - - fastify.addHook('onRequest', (request, reply, done) => { - // クリックジャッキング防止のためiFrameの中に入れられないようにする - reply.header('X-Frame-Options', 'DENY'); - done(); - }); - - //#region vite assets - if (this.config.clientManifestExists) { - fastify.register(fastifyStatic, { - root: viteOut, - prefix: '/vite/', - maxAge: ms('30 days'), - decorateReply: false, - }); - } else { - const port = (process.env.VITE_PORT ?? '5173'); - fastify.register(fastifyProxy, { - upstream: 'http://localhost:' + port, - prefix: '/vite', - rewritePrefix: '/vite', - }); - } - //#endregion - - //#region static assets - - fastify.register(fastifyStatic, { - root: staticAssets, - prefix: '/static-assets/', - maxAge: ms('7 days'), - decorateReply: false, - }); - - fastify.register(fastifyStatic, { - root: clientAssets, - prefix: '/client-assets/', - maxAge: ms('7 days'), - decorateReply: false, - }); - - fastify.register(fastifyStatic, { - root: assets, - prefix: '/assets/', - maxAge: ms('7 days'), - decorateReply: false, - }); - - fastify.get('/favicon.ico', async (request, reply) => { - return reply.sendFile('/favicon.ico', staticAssets); - }); - - fastify.get('/apple-touch-icon.png', async (request, reply) => { - return reply.sendFile('/apple-touch-icon.png', staticAssets); - }); - - fastify.get<{ Params: { path: string } }>('/fluent-emoji/:path(.*)', async (request, reply) => { - const path = request.params.path; - - if (!path.match(/^[0-9a-f-]+\.png$/)) { - reply.code(404); - return; - } - - reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); - - return await reply.sendFile(path, `${_dirname}/../../../../../fluent-emojis/dist/`, { - maxAge: ms('30 days'), - }); - }); - - fastify.get<{ Params: { path: string } }>('/twemoji/:path(.*)', async (request, reply) => { - const path = request.params.path; - - if (!path.match(/^[0-9a-f-]+\.svg$/)) { - reply.code(404); - return; - } - - reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); - - return await reply.sendFile(path, `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/`, { - maxAge: ms('30 days'), - }); - }); - - fastify.get<{ Params: { path: string } }>('/twemoji-badge/:path(.*)', async (request, reply) => { - const path = request.params.path; - - if (!path.match(/^[0-9a-f-]+\.png$/)) { - reply.code(404); - return; - } - - const mask = await sharp( - `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/${path.replace('.png', '')}.svg`, - { density: 1000 }, - ) - .resize(488, 488) - .greyscale() - .normalise() - .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast - .flatten({ background: '#000' }) - .extend({ - top: 12, - bottom: 12, - left: 12, - right: 12, - background: '#000', - }) - .toColorspace('b-w') - .png() - .toBuffer(); - - const buffer = await sharp({ - create: { width: 512, height: 512, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, - }) - .pipelineColorspace('b-w') - .boolean(mask, 'eor') - .resize(96, 96) - .png() - .toBuffer(); - - reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); - reply.header('Cache-Control', 'max-age=2592000'); - reply.header('Content-Type', 'image/png'); - return buffer; - }); - - // ServiceWorker - fastify.get('/sw.js', async (request, reply) => { - return await reply.sendFile('/sw.js', swAssets, { - maxAge: ms('10 minutes'), - }); - }); - - // Manifest - fastify.get('/manifest.json', async (request, reply) => await this.manifestHandler(reply)); - - fastify.get('/robots.txt', async (request, reply) => { - return await reply.sendFile('/robots.txt', staticAssets); - }); - - // OpenSearch XML - fastify.get('/opensearch.xml', async (request, reply) => { - const meta = await this.metaService.fetch(); - - const name = meta.name ?? 'Sharkey'; - let content = ''; - content += ''; - content += `${name}`; - content += `${name} Search`; - content += 'UTF-8'; - content += `${this.config.url}/favicon.ico`; - content += ``; - content += ''; - - reply.header('Content-Type', 'application/opensearchdescription+xml'); - return await reply.send(content); - }); - - //#endregion - - const renderBase = async (reply: FastifyReply) => { - const meta = await this.metaService.fetch(); - reply.header('Cache-Control', 'public, max-age=30'); - return await reply.view('base', { - img: meta.bannerUrl, - url: this.config.url, - title: meta.name ?? 'Misskey', - desc: meta.description, - ...this.generateCommonPugData(meta), - }); - }; - - // URL preview endpoint - fastify.get<{ Querystring: { url: string; lang: string; } }>('/url', (request, reply) => this.urlPreviewService.handle(request, reply)); - - const getFeed = async (acct: string) => { - const { username, host } = Acct.parse(acct); - const user = await this.usersRepository.findOneBy({ - usernameLower: username.toLowerCase(), - host: host ?? IsNull(), - isSuspended: false, - }); - - return user && await this.feedService.packFeed(user); - }; - - // Atom - fastify.get<{ Params: { user: string; } }>('/@:user.atom', async (request, reply) => { - if (this.config.secureApiMode) return await renderBase(reply); - - const feed = await getFeed(request.params.user); - - if (feed) { - reply.header('Content-Type', 'application/atom+xml; charset=utf-8'); - return feed.atom1(); - } else { - reply.code(404); - return; - } - }); - - // RSS - fastify.get<{ Params: { user: string; } }>('/@:user.rss', async (request, reply) => { - if (this.config.secureApiMode) return await renderBase(reply); - - const feed = await getFeed(request.params.user); - - if (feed) { - reply.header('Content-Type', 'application/rss+xml; charset=utf-8'); - return feed.rss2(); - } else { - reply.code(404); - return; - } - }); - - // JSON - fastify.get<{ Params: { user: string; } }>('/@:user.json', async (request, reply) => { - if (this.config.secureApiMode) return await renderBase(reply); - - const feed = await getFeed(request.params.user); - - if (feed) { - reply.header('Content-Type', 'application/json; charset=utf-8'); - return feed.json1(); - } else { - reply.code(404); - return; - } - }); - - //#region SSR (for crawlers) - // User - fastify.get<{ Params: { user: string; sub?: string; } }>('/@:user/:sub?', async (request, reply) => { - if (this.config.secureApiMode) return await renderBase(reply); - - const { username, host } = Acct.parse(request.params.user); - const user = await this.usersRepository.findOneBy({ - usernameLower: username.toLowerCase(), - host: host ?? IsNull(), - isSuspended: false, - }); - - if (user != null) { - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - const meta = await this.metaService.fetch(); - const me = profile.fields - ? profile.fields - .filter(filed => filed.value != null && filed.value.match(/^https?:/)) - .map(field => field.value) - : []; - - reply.header('Cache-Control', 'public, max-age=15'); - if (profile.preventAiLearning) { - reply.header('X-Robots-Tag', 'noimageai'); - reply.header('X-Robots-Tag', 'noai'); - } - return await reply.view('user', { - user, profile, me, - avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), - sub: request.params.sub, - ...this.generateCommonPugData(meta), - }); - } else { - // リモートユーザーなので - // モデレータがAPI経由で参照可能にするために404にはしない - return await renderBase(reply); - } - }); - - fastify.get<{ Params: { user: string; } }>('/users/:user', async (request, reply) => { - if (this.config.secureApiMode) return await renderBase(reply); - - const user = await this.usersRepository.findOneBy({ - id: request.params.user, - host: IsNull(), - isSuspended: false, - }); - - if (user == null) { - reply.code(404); - return; - } - - reply.redirect(`/@${user.username}${ user.host == null ? '' : '@' + user.host}`); - }); - - // Note - fastify.get<{ Params: { note: string; } }>('/notes/:note', async (request, reply) => { - if (this.config.secureApiMode) return await renderBase(reply); - - vary(reply.raw, 'Accept'); - - const note = await this.notesRepository.findOneBy({ - id: request.params.note, - visibility: In(['public', 'home']), - }); - - if (note) { - const _note = await this.noteEntityService.pack(note); - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId }); - const meta = await this.metaService.fetch(); - reply.header('Cache-Control', 'public, max-age=15'); - if (profile.preventAiLearning) { - reply.header('X-Robots-Tag', 'noimageai'); - reply.header('X-Robots-Tag', 'noai'); - } - return await reply.view('note', { - note: _note, - profile, - avatarUrl: _note.user.avatarUrl, - // TODO: Let locale changeable by instance setting - summary: getNoteSummary(_note), - ...this.generateCommonPugData(meta), - }); - } else { - return await renderBase(reply); - } - }); - - // Page - fastify.get<{ Params: { user: string; page: string; } }>('/@:user/pages/:page', async (request, reply) => { - if (this.config.secureApiMode) return await renderBase(reply); - - const { username, host } = Acct.parse(request.params.user); - const user = await this.usersRepository.findOneBy({ - usernameLower: username.toLowerCase(), - host: host ?? IsNull(), - }); - - if (user == null) return; - - const page = await this.pagesRepository.findOneBy({ - name: request.params.page, - userId: user.id, - }); - - if (page) { - const _page = await this.pageEntityService.pack(page); - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: page.userId }); - const meta = await this.metaService.fetch(); - if (['public'].includes(page.visibility)) { - reply.header('Cache-Control', 'public, max-age=15'); - } else { - reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); - } - if (profile.preventAiLearning) { - reply.header('X-Robots-Tag', 'noimageai'); - reply.header('X-Robots-Tag', 'noai'); - } - return await reply.view('page', { - page: _page, - profile, - avatarUrl: _page.user.avatarUrl, - ...this.generateCommonPugData(meta), - }); - } else { - return await renderBase(reply); - } - }); - - // Flash - fastify.get<{ Params: { id: string; } }>('/play/:id', async (request, reply) => { - if (this.config.secureApiMode) return await renderBase(reply); - - const flash = await this.flashsRepository.findOneBy({ - id: request.params.id, - }); - - if (flash) { - const _flash = await this.flashEntityService.pack(flash); - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: flash.userId }); - const meta = await this.metaService.fetch(); - reply.header('Cache-Control', 'public, max-age=15'); - if (profile.preventAiLearning) { - reply.header('X-Robots-Tag', 'noimageai'); - reply.header('X-Robots-Tag', 'noai'); - } - return await reply.view('flash', { - flash: _flash, - profile, - avatarUrl: _flash.user.avatarUrl, - ...this.generateCommonPugData(meta), - }); - } else { - return await renderBase(reply); - } - }); - - // Clip - fastify.get<{ Params: { clip: string; } }>('/clips/:clip', async (request, reply) => { - if (this.config.secureApiMode) return await renderBase(reply); - - const clip = await this.clipsRepository.findOneBy({ - id: request.params.clip, - }); - - if (clip && clip.isPublic) { - const _clip = await this.clipEntityService.pack(clip); - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: clip.userId }); - const meta = await this.metaService.fetch(); - reply.header('Cache-Control', 'public, max-age=15'); - if (profile.preventAiLearning) { - reply.header('X-Robots-Tag', 'noimageai'); - reply.header('X-Robots-Tag', 'noai'); - } - return await reply.view('clip', { - clip: _clip, - profile, - avatarUrl: _clip.user.avatarUrl, - ...this.generateCommonPugData(meta), - }); - } else { - return await renderBase(reply); - } - }); - - // Gallery post - fastify.get<{ Params: { post: string; } }>('/gallery/:post', async (request, reply) => { - if (this.config.secureApiMode) return await renderBase(reply); - - const post = await this.galleryPostsRepository.findOneBy({ id: request.params.post }); - - if (post) { - const _post = await this.galleryPostEntityService.pack(post); - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: post.userId }); - const meta = await this.metaService.fetch(); - reply.header('Cache-Control', 'public, max-age=15'); - if (profile.preventAiLearning) { - reply.header('X-Robots-Tag', 'noimageai'); - reply.header('X-Robots-Tag', 'noai'); - } - return await reply.view('gallery-post', { - post: _post, - profile, - avatarUrl: _post.user.avatarUrl, - ...this.generateCommonPugData(meta), - }); - } else { - return await renderBase(reply); - } - }); - - // Channel - fastify.get<{ Params: { channel: string; } }>('/channels/:channel', async (request, reply) => { - if (this.config.secureApiMode) return await renderBase(reply); - - const channel = await this.channelsRepository.findOneBy({ - id: request.params.channel, - }); - - if (channel) { - const _channel = await this.channelEntityService.pack(channel); - const meta = await this.metaService.fetch(); - reply.header('Cache-Control', 'public, max-age=15'); - return await reply.view('channel', { - channel: _channel, - ...this.generateCommonPugData(meta), - }); - } else { - return await renderBase(reply); - } - }); - //#endregion - - fastify.get('/_info_card_', async (request, reply) => { - const meta = await this.metaService.fetch(true); - - reply.removeHeader('X-Frame-Options'); - - return await reply.view('info-card', { - version: this.config.version, - host: this.config.host, - meta: meta, - originalUsersCount: await this.usersRepository.countBy({ host: IsNull() }), - originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }), - }); - }); - - fastify.get('/bios', async (request, reply) => { - return await reply.view('bios', { - version: this.config.version, - }); - }); - - fastify.get('/cli', async (request, reply) => { - return await reply.view('cli', { - version: this.config.version, - }); - }); - - const override = (source: string, target: string, depth = 0) => - [, ...target.split('/').filter(x => x), ...source.split('/').filter(x => x).splice(depth)].join('/'); - - fastify.get('/flush', async (request, reply) => { - return await reply.view('flush'); - }); - - // streamingに非WebSocketリクエストが来た場合にbase htmlをキャシュ付きで返すと、Proxy等でそのパスがキャッシュされておかしくなる - fastify.get('/streaming', async (request, reply) => { - reply.code(503); - reply.header('Cache-Control', 'private, max-age=0'); - }); - - // Render base html for all requests - fastify.get('*', async (request, reply) => { - return await renderBase(reply); - }); - - fastify.setErrorHandler(async (error, request, reply) => { - const errId = randomUUID(); - this.clientLoggerService.logger.error(`Internal error occurred in ${request.routeOptions.url}: ${error.message}`, { - path: request.routeOptions.url, - params: request.params, - query: request.query, - code: error.name, - stack: error.stack, - id: errId, - }); - reply.code(500); - reply.header('Cache-Control', 'max-age=10, must-revalidate'); - return await reply.view('error', { - code: error.code, - id: errId, - }); - }); - - done(); - } -} diff --git a/packages/backend/src/server/web/FeedService.ts b/packages/backend/src/server/web/FeedService.ts deleted file mode 100644 index aaa9566a76..0000000000 --- a/packages/backend/src/server/web/FeedService.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { In, IsNull } from 'typeorm'; -import { Feed } from 'feed'; -import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository, NotesRepository, UserProfilesRepository } from '@/models/_.js'; -import type { Config } from '@/config.js'; -import type { MiUser } from '@/models/User.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { bindThis } from '@/decorators.js'; -import { IdService } from '@/core/IdService.js'; - -@Injectable() -export class FeedService { - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private userEntityService: UserEntityService, - private driveFileEntityService: DriveFileEntityService, - private idService: IdService, - ) { - } - - @bindThis - public async packFeed(user: MiUser) { - const author = { - link: `${this.config.url}/@${user.username}`, - name: user.name ?? user.username, - }; - - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - - const notes = await this.notesRepository.find({ - where: { - userId: user.id, - renoteId: IsNull(), - visibility: In(['public', 'home']), - }, - order: { id: -1 }, - take: 20, - }); - - const feed = new Feed({ - id: author.link, - title: `${author.name} (@${user.username}@${this.config.host})`, - updated: notes.length !== 0 ? this.idService.parse(notes[0].id).date : undefined, - generator: 'Sharkey', - description: `${user.notesCount} Notes, ${profile.followingVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.followersVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`, - link: author.link, - image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), - feedLinks: { - json: `${author.link}.json`, - atom: `${author.link}.atom`, - }, - author, - copyright: user.name ?? user.username, - }); - - for (const note of notes) { - const files = note.fileIds.length > 0 ? await this.driveFilesRepository.findBy({ - id: In(note.fileIds), - }) : []; - const file = files.find(file => file.type.startsWith('image/')); - - feed.addItem({ - title: `New note by ${author.name}`, - link: `${this.config.url}/notes/${note.id}`, - date: this.idService.parse(note.id).date, - description: note.cw ?? undefined, - content: note.text ?? undefined, - image: file ? this.driveFileEntityService.getPublicUrl(file) : undefined, - }); - } - - return feed; - } -} diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts deleted file mode 100644 index d590244e34..0000000000 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { summaly } from 'summaly'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { MetaService } from '@/core/MetaService.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; -import type Logger from '@/logger.js'; -import { query } from '@/misc/prelude/url.js'; -import { LoggerService } from '@/core/LoggerService.js'; -import { bindThis } from '@/decorators.js'; -import { ApiError } from '@/server/api/error.js'; -import type { FastifyRequest, FastifyReply } from 'fastify'; - -@Injectable() -export class UrlPreviewService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - private metaService: MetaService, - private httpRequestService: HttpRequestService, - private loggerService: LoggerService, - ) { - this.logger = this.loggerService.getLogger('url-preview'); - } - - @bindThis - private wrap(url?: string | null): string | null { - return url != null - ? url.match(/^https?:\/\//) - ? `${this.config.mediaProxy}/preview.webp?${query({ - url, - preview: '1', - })}` - : url - : null; - } - - @bindThis - public async handle( - request: FastifyRequest<{ Querystring: { url: string; lang?: string; } }>, - reply: FastifyReply, - ): Promise { - const url = request.query.url; - if (typeof url !== 'string') { - reply.code(400); - return; - } - - const lang = request.query.lang; - if (Array.isArray(lang)) { - reply.code(400); - return; - } - - const meta = await this.metaService.fetch(); - - this.logger.info(meta.summalyProxy - ? `(Proxy) Getting preview of ${url}@${lang} ...` - : `Getting preview of ${url}@${lang} ...`); - try { - const summary = meta.summalyProxy ? - await this.httpRequestService.getJson>(`${meta.summalyProxy}?${query({ - url: url, - lang: lang ?? 'ja-JP', - })}`) - : - await summaly(url, { - followRedirects: false, - lang: lang ?? 'ja-JP', - agent: this.config.proxy ? { - http: this.httpRequestService.httpAgent, - https: this.httpRequestService.httpsAgent, - } : undefined, - }); - - this.logger.succ(`Got preview of ${url}: ${summary.title}`); - - if (!(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) { - throw new Error('unsupported schema included'); - } - - if (summary.player.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) { - throw new Error('unsupported schema included'); - } - - summary.icon = this.wrap(summary.icon); - summary.thumbnail = this.wrap(summary.thumbnail); - - // Cache 7days - reply.header('Cache-Control', 'max-age=604800, immutable'); - - return summary; - } catch (err) { - this.logger.warn(`Failed to get preview of ${url}: ${err}`); - reply.code(422); - reply.header('Cache-Control', 'max-age=86400, immutable'); - return { - error: new ApiError({ - message: 'Failed to get preview', - code: 'URL_PREVIEW_FAILED', - id: '09d01cb5-53b9-4856-82e5-38a50c290a3b', - }), - }; - } - } -} diff --git a/packages/backend/src/server/web/bios.css b/packages/backend/src/server/web/bios.css index c934a55fa9..b0da3ee39b 100644 --- a/packages/backend/src/server/web/bios.css +++ b/packages/backend/src/server/web/bios.css @@ -1,9 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - * { font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; } diff --git a/packages/backend/src/server/web/bios.js b/packages/backend/src/server/web/bios.js index 029eb92aad..d06dee801a 100644 --- a/packages/backend/src/server/web/bios.js +++ b/packages/backend/src/server/web/bios.js @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - 'use strict'; window.onload = async () => { @@ -13,16 +8,16 @@ window.onload = async () => { const promise = new Promise((resolve, reject) => { // Append a credential if (i) data.i = i; - + // Send request - window.fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, { + fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, { method: 'POST', body: JSON.stringify(data), credentials: 'omit', cache: 'no-cache' }).then(async (res) => { const body = res.status === 204 ? null : await res.json(); - + if (res.status === 200) { resolve(body); } else if (res.status === 204) { @@ -32,7 +27,7 @@ window.onload = async () => { } }).catch(reject); }); - + return promise; }; diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index aac6689e12..2aef689d3f 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - /** * BOOT LOADER * サーバーからレスポンスされるHTMLに埋め込まれるスクリプトで、以下の役割を持ちます。 @@ -27,13 +22,13 @@ renderError('SOMETHING_HAPPENED_IN_PROMISE', e); }; - let forceError = localStorage.getItem('forceError'); - if (forceError != null) { - renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.') - } + const v = localStorage.getItem('v') || VERSION; //#region Detect language & fetch translations - if (!localStorage.hasOwnProperty('locale')) { + const localeVersion = localStorage.getItem('localeVersion'); + const localeOutdated = (localeVersion == null || localeVersion !== v); + + if (!localStorage.hasOwnProperty('locale') || localeOutdated) { const supportedLangs = LANGS; let lang = localStorage.getItem('lang'); if (lang == null || !supportedLangs.includes(lang)) { @@ -47,38 +42,13 @@ } } - 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) { + const res = await fetch(`/assets/locales/${lang}.${v}.json`); + if (res.status === 200) { localStorage.setItem('lang', lang); - localStorage.setItem('locale', await localRes.text()); + localStorage.setItem('locale', await res.text()); localStorage.setItem('localeVersion', v); } else { + await checkUpdate(); renderError('LOCALE_FETCH'); return; } @@ -87,8 +57,9 @@ //#region Script function importAppScript() { - import(`/vite/${CLIENT_ENTRY}`) + import(`/assets/${CLIENT_ENTRY}`) .catch(async e => { + await checkUpdate(); console.error(e); renderError('APP_IMPORT', e); }); @@ -106,29 +77,8 @@ //#region Theme const theme = localStorage.getItem('theme'); - const themeFontFaceName = 'sharkey-theme-font-face'; if (theme) { - let existingFontFace; - document.fonts.forEach((v,k,s)=>{if (v.family === themeFontFaceName) existingFontFace=v;}); - if (existingFontFace) document.fonts.delete(existingFontFace); - - const themeProps = JSON.parse(theme); - const fontFaceSrc = themeProps.fontFaceSrc; - const fontFaceOpts = themeProps.fontFaceOpts || {}; - if (fontFaceSrc) { - const fontFace = new FontFace( - themeFontFaceName, - fontFaceSrc, fontFaceOpts || {}, - ); - document.fonts.add(fontFace); - fontFace.load().catch( - (failure) => { - console.log(failure) - } - ); - } - for (const [k, v] of Object.entries(themeProps)) { - if (k.startsWith('font')) continue; + for (const [k, v] of Object.entries(JSON.parse(theme))) { document.documentElement.style.setProperty(`--${k}`, v.toString()); // HTMLの theme-color 適用 @@ -142,9 +92,9 @@ } } } - const colorScheme = localStorage.getItem('colorScheme'); - if (colorScheme) { - document.documentElement.style.setProperty('color-scheme', colorScheme); + const colorSchema = localStorage.getItem('colorSchema'); + if (colorSchema) { + document.documentElement.style.setProperty('color-schema', colorSchema); } //#endregion @@ -153,11 +103,6 @@ document.documentElement.classList.add('f-' + fontSize); } - const cornerRadius = localStorage.getItem('cornerRadius'); - if (cornerRadius) { - document.documentElement.classList.add(`radius-${cornerRadius}`); - } - const useSystemFont = localStorage.getItem('useSystemFont'); if (useSystemFont) { document.documentElement.classList.add('useSystemFont'); @@ -191,42 +136,37 @@ -

Failed to load
読み込みに失敗しました

+

An error has occurred!

-

The following actions may solve the problem. / 以下を行うと解決する可能性があります。

-

Clear the browser cache / ブラウザのキャッシュをクリアする

-

Update your os and browser / ブラウザおよびOSを最新バージョンに更新する

-

Disable an adblocker / アドブロッカーを無効にする

-

(Tor Browser) Set dom.webaudio.enabled to true / dom.webaudio.enabledをtrueに設定する

-
- Other options / その他のオプション - - - -
- - - -
- - - -
+

Don't worry, it's (probably) not your fault.

+

If the problem persists after refreshing, please contact your instance's administrator.
You may also try the following options:

+

Update your os and browser.

+

Disable an adblocker.

+ + + +
+ + + +
+ + +
`; errorsElement = document.getElementById('errors'); } const detailsElement = document.createElement('details'); - detailsElement.id = 'errorInfo'; detailsElement.innerHTML = `
@@ -239,7 +179,7 @@ font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; } - #sharkey_app, + #misskey_app, #splash { display: none !important; } @@ -283,7 +223,7 @@ .button-label-big { color: #222; font-weight: bold; - font-size: 1.2em; + font-size: 20px; padding: 12px; } @@ -303,6 +243,11 @@ font-size: 16px; } + .dont-worry, + #msg { + font-size: 18px; + } + .icon-warning { color: #dec340; height: 4rem; @@ -310,15 +255,14 @@ } h1 { - font-size: 1.5em; - margin: 1em; + font-size: 32px; } code { font-family: Fira, FiraCode, monospace; } - #errorInfo { + details { background: #333; margin-bottom: 2rem; padding: 0.5rem 1rem; @@ -328,18 +272,54 @@ margin: auto; } - #errorInfo summary { + summary { cursor: pointer; } - #errorInfo summary > * { + summary > * { display: inline; } @media screen and (max-width: 500px) { - #errorInfo { + details { width: 50%; } `) } + + // eslint-disable-next-line no-inner-declarations + async function checkUpdate() { + try { + const res = await fetch('/api/meta', { + method: 'POST', + cache: 'no-cache' + }); + + const meta = await res.json(); + + if (meta.version != v) { + localStorage.setItem('v', meta.version); + refresh(); + } + } catch (e) { + console.error(e); + renderError('UPDATE_CHECK', e); + throw e; + } + } + + // eslint-disable-next-line no-inner-declarations + function refresh() { + // Clear cache (service worker) + try { + navigator.serviceWorker.controller.postMessage('clear'); + navigator.serviceWorker.getRegistrations().then(registrations => { + registrations.forEach(registration => registration.unregister()); + }); + } catch (e) { + console.error(e); + } + + location.reload(); + } })(); diff --git a/packages/backend/src/server/web/cli.css b/packages/backend/src/server/web/cli.css index b7737c3f21..07cd27830b 100644 --- a/packages/backend/src/server/web/cli.css +++ b/packages/backend/src/server/web/cli.css @@ -1,9 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - * { font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; } diff --git a/packages/backend/src/server/web/cli.js b/packages/backend/src/server/web/cli.js index e63a80327c..3dff1d4860 100644 --- a/packages/backend/src/server/web/cli.js +++ b/packages/backend/src/server/web/cli.js @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - 'use strict'; window.onload = async () => { @@ -13,19 +8,16 @@ window.onload = async () => { const promise = new Promise((resolve, reject) => { // Append a credential if (i) data.i = i; - + // Send request fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, { - headers: { - 'Content-Type': 'application/json' - }, method: 'POST', body: JSON.stringify(data), credentials: 'omit', cache: 'no-cache' }).then(async (res) => { const body = res.status === 204 ? null : await res.json(); - + if (res.status === 200) { resolve(body); } else if (res.status === 204) { @@ -35,7 +27,7 @@ window.onload = async () => { } }).catch(reject); }); - + return promise; }; diff --git a/packages/backend/src/server/web/error.css b/packages/backend/src/server/web/error.css deleted file mode 100644 index caa179e25c..0000000000 --- a/packages/backend/src/server/web/error.css +++ /dev/null @@ -1,116 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -* { - font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; -} - -#sharkey_app, -#splash { - display: none !important; -} - -body, -html { - background-color: #222; - color: #dfddcc; - justify-content: center; - margin: auto; - padding: 10px; - text-align: center; -} - -button { - border-radius: 999px; - padding: 0px 12px 0px 12px; - border: none; - cursor: pointer; - margin-bottom: 12px; -} - -.button-big { - background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0)); - line-height: 50px; -} - -.button-big:hover { - background: rgb(153, 204, 0); -} - -.button-small { - background: #444; - line-height: 40px; -} - -.button-small:hover { - background: #555; -} - -.button-label-big { - color: #222; - font-weight: bold; - font-size: 20px; - padding: 12px; -} - -.button-label-small { - color: rgb(153, 204, 0); - font-size: 16px; - padding: 12px; -} - -a { - color: rgb(134, 179, 0); - text-decoration: none; -} - -p, -li { - font-size: 16px; -} - -.dont-worry, -#msg { - font-size: 18px; -} - -.icon-warning { - color: #dec340; - height: 4rem; - padding-top: 2rem; -} - -h1 { - font-size: 32px; -} - -code { - display: block; - font-family: Fira, FiraCode, monospace; - background: #333; - padding: 0.5rem 1rem; - max-width: 40rem; - border-radius: 10px; - justify-content: center; - margin: auto; - white-space: pre-wrap; - word-break: break-word; -} - -summary { - cursor: pointer; -} - -summary > * { - display: inline; - white-space: pre-wrap; -} - -@media screen and (max-width: 500px) { - details { - width: 50%; - } -} \ No newline at end of file diff --git a/packages/backend/src/server/web/feed.ts b/packages/backend/src/server/web/feed.ts new file mode 100644 index 0000000000..4abe2885cf --- /dev/null +++ b/packages/backend/src/server/web/feed.ts @@ -0,0 +1,58 @@ +import { Feed } from 'feed'; +import { In, IsNull } from 'typeorm'; +import config from '@/config/index.js'; +import { User } from '@/models/entities/user.js'; +import { Notes, DriveFiles, UserProfiles, Users } from '@/models/index.js'; + +export default async function(user: User) { + const author = { + link: `${config.url}/@${user.username}`, + name: user.name || user.username, + }; + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + const notes = await Notes.find({ + where: { + userId: user.id, + renoteId: IsNull(), + visibility: In(['public', 'home']), + }, + order: { createdAt: -1 }, + take: 20, + }); + + const feed = new Feed({ + id: author.link, + title: `${author.name} (@${user.username}@${config.host})`, + updated: notes[0].createdAt, + generator: 'Misskey', + description: `${user.notesCount} Notes, ${profile.ffVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.ffVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`, + link: author.link, + image: await Users.getAvatarUrl(user), + feedLinks: { + json: `${author.link}.json`, + atom: `${author.link}.atom`, + }, + author, + copyright: user.name || user.username, + }); + + for (const note of notes) { + const files = note.fileIds.length > 0 ? await DriveFiles.findBy({ + id: In(note.fileIds), + }) : []; + const file = files.find(file => file.type.startsWith('image/')); + + feed.addItem({ + title: `New note by ${author.name}`, + link: `${config.url}/notes/${note.id}`, + date: note.createdAt, + description: note.cw || undefined, + content: note.text || undefined, + image: file ? DriveFiles.getPublicUrl(file) || undefined : undefined, + }); + } + + return feed; +} diff --git a/packages/backend/src/server/web/index.ts b/packages/backend/src/server/web/index.ts new file mode 100644 index 0000000000..be95becb68 --- /dev/null +++ b/packages/backend/src/server/web/index.ts @@ -0,0 +1,521 @@ +/** + * Web Client Server + */ + +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PathOrFileDescriptor, readFileSync } from 'node:fs'; +import ms from 'ms'; +import Koa from 'koa'; +import Router from '@koa/router'; +import send from 'koa-send'; +import favicon from 'koa-favicon'; +import views from 'koa-views'; +import sharp from 'sharp'; +import { createBullBoard } from '@bull-board/api'; +import { BullAdapter } from '@bull-board/api/bullAdapter.js'; +import { KoaAdapter } from '@bull-board/koa'; + +import { In, IsNull } from 'typeorm'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import config from '@/config/index.js'; +import { Users, Notes, UserProfiles, Pages, Channels, Clips, GalleryPosts } from '@/models/index.js'; +import * as Acct from '@/misc/acct.js'; +import { getNoteSummary } from '@/misc/get-note-summary.js'; +import { queues } from '@/queue/queues.js'; +import { genOpenapiSpec } from '../api/openapi/gen-spec.js'; +import { urlPreviewHandler } from './url-preview.js'; +import { manifestHandler } from './manifest.js'; +import packFeed from './feed.js'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +const staticAssets = `${_dirname}/../../../assets/`; +const clientAssets = `${_dirname}/../../../../client/assets/`; +const assets = `${_dirname}/../../../../../built/_client_dist_/`; +const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`; + +// Init app +const app = new Koa(); + +//#region Bull Dashboard +const bullBoardPath = '/queue'; + +// Authenticate +app.use(async (ctx, next) => { + if (ctx.path === bullBoardPath || ctx.path.startsWith(bullBoardPath + '/')) { + const token = ctx.cookies.get('token'); + if (token == null) { + ctx.status = 401; + return; + } + const user = await Users.findOneBy({ token }); + if (user == null || !(user.isAdmin || user.isModerator)) { + ctx.status = 403; + return; + } + } + await next(); +}); + +const serverAdapter = new KoaAdapter(); + +createBullBoard({ + queues: queues.map(q => new BullAdapter(q)), + serverAdapter, +}); + +serverAdapter.setBasePath(bullBoardPath); +app.use(serverAdapter.registerPlugin()); +//#endregion + +// Init renderer +app.use(views(_dirname + '/views', { + extension: 'pug', + options: { + version: config.version, + getClientEntry: () => process.env.NODE_ENV === 'production' ? + config.clientEntry : + JSON.parse(readFileSync(`${_dirname}/../../../../../built/_client_dist_/manifest.json`, 'utf-8'))['src/init.ts'], + config, + }, +})); + +// Serve favicon +app.use(favicon(`${_dirname}/../../../assets/favicon.ico`)); + +// Common request handler +app.use(async (ctx, next) => { + // IFrameの中に入れられないようにする + ctx.set('X-Frame-Options', 'DENY'); + await next(); +}); + +// Init router +const router = new Router(); + +//#region static assets + +router.get('/static-assets/(.*)', async ctx => { + await send(ctx as any, ctx.path.replace('/static-assets/', ''), { + root: staticAssets, + maxage: ms('7 days'), + }); +}); + +router.get('/client-assets/(.*)', async ctx => { + await send(ctx as any, ctx.path.replace('/client-assets/', ''), { + root: clientAssets, + maxage: ms('7 days'), + }); +}); + +router.get('/assets/(.*)', async ctx => { + await send(ctx as any, ctx.path.replace('/assets/', ''), { + root: assets, + maxage: ms('7 days'), + }); +}); + +// Apple touch icon +router.get('/apple-touch-icon.png', async ctx => { + await send(ctx as any, '/apple-touch-icon.png', { + root: staticAssets, + }); +}); + +router.get('/twemoji/(.*)', async ctx => { + const path = ctx.path.replace('/twemoji/', ''); + + if (!path.match(/^[0-9a-f-]+\.svg$/)) { + ctx.status = 404; + return; + } + + ctx.set('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); + + await send(ctx as any, path, { + root: `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/`, + maxage: ms('30 days'), + }); +}); + +router.get('/twemoji-badge/(.*)', async ctx => { + const path = ctx.path.replace('/twemoji-badge/', ''); + + if (!path.match(/^[0-9a-f-]+\.png$/)) { + ctx.status = 404; + return; + } + + const mask = await sharp( + `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/${path.replace('.png', '')}.svg`, + { density: 1000 }, + ) + .resize(488, 488) + .greyscale() + .normalise() + .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast + .flatten({ background: '#000' }) + .extend({ + top: 12, + bottom: 12, + left: 12, + right: 12, + background: '#000', + }) + .toColorspace('b-w') + .png() + .toBuffer(); + + const buffer = await sharp({ + create: { width: 512, height: 512, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, + }) + .pipelineColorspace('b-w') + .boolean(mask, 'eor') + .resize(96, 96) + .png() + .toBuffer(); + + ctx.set('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); + ctx.set('Cache-Control', 'max-age=2592000'); + ctx.set('Content-Type', 'image/png'); + ctx.body = buffer; +}); + +// ServiceWorker +router.get(`/sw.js`, async ctx => { + await send(ctx as any, `/sw.js`, { + root: swAssets, + maxage: ms('10 minutes'), + }); +}); + +// Manifest +router.get('/manifest.json', manifestHandler); + +router.get('/robots.txt', async ctx => { + await send(ctx as any, '/robots.txt', { + root: staticAssets, + }); +}); + +//#endregion + +// Docs +router.get('/api-doc', async ctx => { + await send(ctx as any, '/redoc.html', { + root: staticAssets, + }); +}); + +// URL preview endpoint +router.get('/url', urlPreviewHandler); + +router.get('/api.json', async ctx => { + ctx.body = genOpenapiSpec(); +}); + +const getFeed = async (acct: string) => { + const { username, host } = Acct.parse(acct); + const user = await Users.findOneBy({ + usernameLower: username.toLowerCase(), + host: host ?? IsNull(), + isSuspended: false, + }); + + return user && await packFeed(user); +}; + +// Atom +router.get('/@:user.atom', async ctx => { + const feed = await getFeed(ctx.params.user); + + if (feed) { + ctx.set('Content-Type', 'application/atom+xml; charset=utf-8'); + ctx.body = feed.atom1(); + } else { + ctx.status = 404; + } +}); + +// RSS +router.get('/@:user.rss', async ctx => { + const feed = await getFeed(ctx.params.user); + + if (feed) { + ctx.set('Content-Type', 'application/rss+xml; charset=utf-8'); + ctx.body = feed.rss2(); + } else { + ctx.status = 404; + } +}); + +// JSON +router.get('/@:user.json', async ctx => { + const feed = await getFeed(ctx.params.user); + + if (feed) { + ctx.set('Content-Type', 'application/json; charset=utf-8'); + ctx.body = feed.json1(); + } else { + ctx.status = 404; + } +}); + +//#region SSR (for crawlers) +// User +router.get(['/@:user', '/@:user/:sub'], async (ctx, next) => { + const { username, host } = Acct.parse(ctx.params.user); + const user = await Users.findOneBy({ + usernameLower: username.toLowerCase(), + host: host ?? IsNull(), + isSuspended: false, + }); + + if (user != null) { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + const meta = await fetchMeta(); + const me = profile.fields + ? profile.fields + .filter(filed => filed.value != null && filed.value.match(/^https?:/)) + .map(field => field.value) + : []; + + await ctx.render('user', { + user, profile, me, + avatarUrl: await Users.getAvatarUrl(user), + sub: ctx.params.sub, + instanceName: meta.name || 'Misskey', + icon: meta.iconUrl, + themeColor: meta.themeColor, + }); + ctx.set('Cache-Control', 'public, max-age=15'); + } else { + // リモートユーザーなので + // モデレータがAPI経由で参照可能にするために404にはしない + await next(); + } +}); + +router.get('/users/:user', async ctx => { + const user = await Users.findOneBy({ + id: ctx.params.user, + host: IsNull(), + isSuspended: false, + }); + + if (user == null) { + ctx.status = 404; + return; + } + + ctx.redirect(`/@${user.username}${ user.host == null ? '' : '@' + user.host}`); +}); + +// Note +router.get('/notes/:note', async (ctx, next) => { + const note = await Notes.findOneBy({ + id: ctx.params.note, + visibility: In(['public', 'home']), + }); + + if (note) { + const _note = await Notes.pack(note); + const profile = await UserProfiles.findOneByOrFail({ userId: note.userId }); + const meta = await fetchMeta(); + await ctx.render('note', { + note: _note, + profile, + avatarUrl: await Users.getAvatarUrl(await Users.findOneByOrFail({ id: note.userId })), + // TODO: Let locale changeable by instance setting + summary: getNoteSummary(_note), + instanceName: meta.name || 'Misskey', + icon: meta.iconUrl, + themeColor: meta.themeColor, + }); + + ctx.set('Cache-Control', 'public, max-age=15'); + + return; + } + + await next(); +}); + +// Page +router.get('/@:user/pages/:page', async (ctx, next) => { + const { username, host } = Acct.parse(ctx.params.user); + const user = await Users.findOneBy({ + usernameLower: username.toLowerCase(), + host: host ?? IsNull(), + }); + + if (user == null) return; + + const page = await Pages.findOneBy({ + name: ctx.params.page, + userId: user.id, + }); + + if (page) { + const _page = await Pages.pack(page); + const profile = await UserProfiles.findOneByOrFail({ userId: page.userId }); + const meta = await fetchMeta(); + await ctx.render('page', { + page: _page, + profile, + avatarUrl: await Users.getAvatarUrl(await Users.findOneByOrFail({ id: page.userId })), + instanceName: meta.name || 'Misskey', + icon: meta.iconUrl, + themeColor: meta.themeColor, + }); + + if (['public'].includes(page.visibility)) { + ctx.set('Cache-Control', 'public, max-age=15'); + } else { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } + + return; + } + + await next(); +}); + +// Clip +// TODO: 非publicなclipのハンドリング +router.get('/clips/:clip', async (ctx, next) => { + const clip = await Clips.findOneBy({ + id: ctx.params.clip, + }); + + if (clip) { + const _clip = await Clips.pack(clip); + const profile = await UserProfiles.findOneByOrFail({ userId: clip.userId }); + const meta = await fetchMeta(); + await ctx.render('clip', { + clip: _clip, + profile, + avatarUrl: await Users.getAvatarUrl(await Users.findOneByOrFail({ id: clip.userId })), + instanceName: meta.name || 'Misskey', + icon: meta.iconUrl, + themeColor: meta.themeColor, + }); + + ctx.set('Cache-Control', 'public, max-age=15'); + + return; + } + + await next(); +}); + +// Gallery post +router.get('/gallery/:post', async (ctx, next) => { + const post = await GalleryPosts.findOneBy({ id: ctx.params.post }); + + if (post) { + const _post = await GalleryPosts.pack(post); + const profile = await UserProfiles.findOneByOrFail({ userId: post.userId }); + const meta = await fetchMeta(); + await ctx.render('gallery-post', { + post: _post, + profile, + avatarUrl: await Users.getAvatarUrl(await Users.findOneByOrFail({ id: post.userId })), + instanceName: meta.name || 'Misskey', + icon: meta.iconUrl, + themeColor: meta.themeColor, + }); + + ctx.set('Cache-Control', 'public, max-age=15'); + + return; + } + + await next(); +}); + +// Channel +router.get('/channels/:channel', async (ctx, next) => { + const channel = await Channels.findOneBy({ + id: ctx.params.channel, + }); + + if (channel) { + const _channel = await Channels.pack(channel); + const meta = await fetchMeta(); + await ctx.render('channel', { + channel: _channel, + instanceName: meta.name || 'Misskey', + icon: meta.iconUrl, + themeColor: meta.themeColor, + }); + + ctx.set('Cache-Control', 'public, max-age=15'); + + return; + } + + await next(); +}); +//#endregion + +router.get('/_info_card_', async ctx => { + const meta = await fetchMeta(true); + + ctx.remove('X-Frame-Options'); + + await ctx.render('info-card', { + version: config.version, + host: config.host, + meta: meta, + originalUsersCount: await Users.countBy({ host: IsNull() }), + originalNotesCount: await Notes.countBy({ userHost: IsNull() }), + }); +}); + +router.get('/bios', async ctx => { + await ctx.render('bios', { + version: config.version, + }); +}); + +router.get('/cli', async ctx => { + await ctx.render('cli', { + version: config.version, + }); +}); + +const override = (source: string, target: string, depth = 0) => + [, ...target.split('/').filter(x => x), ...source.split('/').filter(x => x).splice(depth)].join('/'); + +router.get('/flush', async ctx => { + await ctx.render('flush'); +}); + +// streamingに非WebSocketリクエストが来た場合にbase htmlをキャシュ付きで返すと、Proxy等でそのパスがキャッシュされておかしくなる +router.get('/streaming', async ctx => { + ctx.status = 503; + ctx.set('Cache-Control', 'private, max-age=0'); +}); + +// Render base html for all requests +router.get('(.*)', async ctx => { + const meta = await fetchMeta(); + await ctx.render('base', { + img: meta.bannerUrl, + title: meta.name || 'Misskey', + instanceName: meta.name || 'Misskey', + desc: meta.description, + icon: meta.iconUrl, + themeColor: meta.themeColor, + }); + ctx.set('Cache-Control', 'public, max-age=15'); +}); + +// Register router +app.use(router.routes()); + +export default app; diff --git a/packages/backend/src/server/web/manifest.json b/packages/backend/src/server/web/manifest.json index 990843bef8..48030a2980 100644 --- a/packages/backend/src/server/web/manifest.json +++ b/packages/backend/src/server/web/manifest.json @@ -1,6 +1,6 @@ { - "short_name": "Sharkey", - "name": "Sharkey", + "short_name": "Misskey", + "name": "Misskey", "start_url": "/", "display": "standalone", "background_color": "#313a42", @@ -9,26 +9,16 @@ { "src": "/static-assets/icons/192.png", "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" + "type": "image/png" }, { "src": "/static-assets/icons/512.png", "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/static-assets/splash.png", - "sizes": "300x300", - "type": "image/png", - "purpose": "any" + "type": "image/png" } ], "share_target": { "action": "/share/", - "method": "GET", - "enctype": "application/x-www-form-urlencoded", "params": { "title": "title", "text": "text", diff --git a/packages/backend/src/server/web/manifest.ts b/packages/backend/src/server/web/manifest.ts new file mode 100644 index 0000000000..ee568b8077 --- /dev/null +++ b/packages/backend/src/server/web/manifest.ts @@ -0,0 +1,18 @@ +import Koa from 'koa'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import manifest from './manifest.json' assert { type: 'json' }; + +export const manifestHandler = async (ctx: Koa.Context) => { + // TODO + //const res = structuredClone(manifest); + const res = JSON.parse(JSON.stringify(manifest)); + + const instance = await fetchMeta(true); + + res.short_name = instance.name || 'Misskey'; + res.name = instance.name || 'Misskey'; + if (instance.themeColor) res.theme_color = instance.themeColor; + + ctx.set('Cache-Control', 'max-age=300'); + ctx.body = res; +}; diff --git a/packages/backend/src/server/web/style.css b/packages/backend/src/server/web/style.css index 171827a523..d59f00fe16 100644 --- a/packages/backend/src/server/web/style.css +++ b/packages/backend/src/server/web/style.css @@ -1,9 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - html { background-color: var(--bg); color: var(--fg); @@ -44,7 +38,7 @@ html { display: inline-block; width: 28px; height: 28px; - transform: translateY(80px); + transform: translateY(70px); color: var(--accent); } #splashSpinner > .spinner { @@ -74,17 +68,3 @@ html { transform: rotate(360deg); } } - -#splashText { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - margin: auto; - display: inline-block; - width: 70%; - height: 0; - text-align: center; - transform: translateY(40px); -} diff --git a/packages/backend/src/server/web/url-preview.ts b/packages/backend/src/server/web/url-preview.ts new file mode 100644 index 0000000000..1e259649f9 --- /dev/null +++ b/packages/backend/src/server/web/url-preview.ts @@ -0,0 +1,65 @@ +import Koa from 'koa'; +import summaly from 'summaly'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import Logger from '@/services/logger.js'; +import config from '@/config/index.js'; +import { query } from '@/prelude/url.js'; +import { getJson } from '@/misc/fetch.js'; + +const logger = new Logger('url-preview'); + +export const urlPreviewHandler = async (ctx: Koa.Context) => { + const url = ctx.query.url; + if (typeof url !== 'string') { + ctx.status = 400; + return; + } + + const lang = ctx.query.lang; + if (Array.isArray(lang)) { + ctx.status = 400; + return; + } + + const meta = await fetchMeta(); + + logger.info(meta.summalyProxy + ? `(Proxy) Getting preview of ${url}@${lang} ...` + : `Getting preview of ${url}@${lang} ...`); + + try { + const summary = meta.summalyProxy ? await getJson(`${meta.summalyProxy}?${query({ + url: url, + lang: lang ?? 'ja-JP', + })}`) : await summaly.default(url, { + followRedirects: false, + lang: lang ?? 'ja-JP', + }); + + logger.succ(`Got preview of ${url}: ${summary.title}`); + + summary.icon = wrap(summary.icon); + summary.thumbnail = wrap(summary.thumbnail); + + // Cache 7days + ctx.set('Cache-Control', 'max-age=604800, immutable'); + + ctx.body = summary; + } catch (err) { + logger.warn(`Failed to get preview of ${url}: ${err}`); + ctx.status = 200; + ctx.set('Cache-Control', 'max-age=86400, immutable'); + ctx.body = '{}'; + } +}; + +function wrap(url?: string): string | null { + return url != null + ? url.match(/^https?:\/\//) + ? `${config.url}/proxy/preview.webp?${query({ + url, + preview: '1', + })}` + : url + : null; +} diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index c15e123a15..5bb156f0f4 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -1,60 +1,51 @@ block vars block loadClientEntry - - const clientEntry = config.clientEntry; + - const clientEntry = getClientEntry(); doctype html // - - _____ _ _ - / ___| | | | - \ `--.| |__ __ _ _ __| | _____ _ _ - `--. \ '_ \ / _` | '__| |/ / _ \ | | | - /\__/ / | | | (_| | | | < __/ |_| | - \____/|_| |_|\__,_|_| |_|\_\___|\__, | - __/ | - |___/ - - Thank you for using Sharkey! + _____ _ _ + | |_|___ ___| |_ ___ _ _ + | | | | |_ -|_ -| '_| -_| | | + |_|_|_|_|___|___|_,_|___|_ | + |___| + Thank you for using Misskey! If you are reading this message... how about joining the development? - https://git.joinsharkey.org/Sharkey/Sharkey - + https://github.com/misskey-dev/misskey + html head meta(charset='utf-8') - meta(name='application-name' content='Sharkey') + 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(property='og:site_name' content= instanceName || 'Sharkey') - meta(property='instance_url' content= instanceUrl) + meta(property='twitter:card' content='summary') + meta(property='og:site_name' content= instanceName || 'Misskey') meta(name='viewport' content='width=device-width, initial-scale=1') link(rel='icon' href= icon || '/favicon.ico') - link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png') + link(rel='apple-touch-icon' href= icon || '/apple-touch-icon.png') link(rel='manifest' href='/manifest.json') - link(rel='search' type='application/opensearchdescription+xml' title=(title || "Sharkey") href=`${url}/opensearch.xml`) - 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/phosphor-icons/bold/style.css') - link(rel='stylesheet' href='/static-assets/fonts/sharkey-icons/style.css') - link(rel='modulepreload' href=`/vite/${clientEntry.file}`) - script(src='/client-assets/libopenmpt.js') + link(rel='prefetch' href='https://xn--931a.moe/assets/info.jpg') + link(rel='prefetch' href='https://xn--931a.moe/assets/not-found.jpg') + link(rel='prefetch' href='https://xn--931a.moe/assets/error.jpg') + link(rel='stylesheet' href='/assets/fontawesome/css/all.css') + link(rel='modulepreload' href=`/assets/${clientEntry.file}`) - if !config.clientManifestExists - script(type="module" src="/vite/@vite/client") + each href in clientEntry.css + link(rel='preload' href=`/assets/${href}` as='style') - if Array.isArray(clientEntry.css) - each href in clientEntry.css - link(rel='stylesheet' href=`/vite/${href}`) + each href in clientEntry.css + link(rel='preload' href=`/assets/${href}` as='style') title block title - = title || 'Sharkey' + = title || 'Misskey' block desc meta(name='description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨') @@ -62,10 +53,9 @@ html block meta block og - meta(property='og:title' content= title || 'Sharkey') - meta(property='og:description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨') + meta(property='og:title' content= title || 'Misskey') + meta(property='og:description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨') meta(property='og:image' content= img) - meta(property='twitter:card' content='summary') style include ../style.css @@ -73,7 +63,6 @@ html script. var VERSION = "#{version}"; var CLIENT_ENTRY = "#{clientEntry.file}"; - window.libopenmpt = window.Module; script include ../boot.js @@ -85,9 +74,6 @@ html | Please turn on your JavaScript div#splash img#splashIcon(src= icon || '/static-assets/splash.png') - span#splashText - block randomMOTD - = randomMOTD div#splashSpinner diff --git a/packages/backend/src/server/web/views/bios.pug b/packages/backend/src/server/web/views/bios.pug index 53e4e223b9..39a151a29b 100644 --- a/packages/backend/src/server/web/views/bios.pug +++ b/packages/backend/src/server/web/views/bios.pug @@ -4,8 +4,8 @@ html head meta(charset='utf-8') - meta(name='application-name' content='Sharkey') - title Sharkey Repair Tool + meta(name='application-name' content='Misskey') + title Misskey Repair Tool style include ../bios.css script @@ -13,7 +13,7 @@ html body header - h1 Sharkey Repair Tool #{version} + h1 Misskey Repair Tool #{version} main div.tabs button#ls edit local storage diff --git a/packages/backend/src/server/web/views/channel.pug b/packages/backend/src/server/web/views/channel.pug index c514025e0b..486f0ecc47 100644 --- a/packages/backend/src/server/web/views/channel.pug +++ b/packages/backend/src/server/web/views/channel.pug @@ -16,4 +16,3 @@ block og meta(property='og:description' content= channel.description) meta(property='og:url' content= url) meta(property='og:image' content= channel.bannerUrl) - meta(property='twitter:card' content='summary') diff --git a/packages/backend/src/server/web/views/cli.pug b/packages/backend/src/server/web/views/cli.pug index b2dcf3025a..d2cf7c4335 100644 --- a/packages/backend/src/server/web/views/cli.pug +++ b/packages/backend/src/server/web/views/cli.pug @@ -4,8 +4,8 @@ html head meta(charset='utf-8') - meta(name='application-name' content='Sharkey') - title Sharkey Cli + meta(name='application-name' content='Misskey') + title Misskey Cli style include ../cli.css script @@ -13,7 +13,7 @@ html body header - h1 Sharkey Cli #{version} + h1 Misskey Cli #{version} main div#form textarea#text diff --git a/packages/backend/src/server/web/views/clip.pug b/packages/backend/src/server/web/views/clip.pug index 5a0018803a..4c692bf59b 100644 --- a/packages/backend/src/server/web/views/clip.pug +++ b/packages/backend/src/server/web/views/clip.pug @@ -17,14 +17,10 @@ block og meta(property='og:description' content= clip.description) meta(property='og:url' content= url) meta(property='og:image' content= avatarUrl) - meta(property='twitter:card' content='summary') block meta if profile.noCrawle meta(name='robots' content='noindex') - if profile.preventAiLearning - meta(name='robots' content='noimageai') - meta(name='robots' content='noai') meta(name='misskey:user-username' content=user.username) meta(name='misskey:user-id' content=user.id) diff --git a/packages/backend/src/server/web/views/error.pug b/packages/backend/src/server/web/views/error.pug deleted file mode 100644 index 00a2a72d7a..0000000000 --- a/packages/backend/src/server/web/views/error.pug +++ /dev/null @@ -1,69 +0,0 @@ -doctype html - -// - - - _____ _ _ - / ___| | | | - \ `--.| |__ __ _ _ __| | _____ _ _ - `--. \ '_ \ / _` | '__| |/ / _ \ | | | - /\__/ / | | | (_| | | | < __/ |_| | - \____/|_| |_|\__,_|_| |_|\_\___|\__, | - __/ | - |___/ - - Thank you for using Sharkey! - If you are reading this message... how about joining the development? - https://git.joinsharkey.org/Sharkey/Sharkey - - -html - - head - meta(charset='utf-8') - meta(name='viewport' content='width=device-width, initial-scale=1') - meta(name='application-name' content='Sharkey') - meta(name='referrer' content='origin') - - title - block title - = 'An error has occurred... | Sharkey' - - style - include ../error.css - -body - svg.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(d="M12 9v2m0 4v.01") - 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") - - h1 An error has occurred! - - button.button-big(onclick="location.reload();") - span.button-label-big Refresh - - p.dont-worry Don't worry, it's (probably) not your fault. - - p If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID. - - div#errors - code. - ERROR CODE: #{code} - ERROR ID: #{id} - - p You may also try the following options: - - p Update your os and browser. - p Disable an adblocker. - - a(href="/flush") - button.button-small - span.button-label-small Clear preferences and cache - br - a(href="/cli") - button.button-small - span.button-label-small Start the simple client - br - a(href="/bios") - button.button-small - span.button-label-small Start the repair tool diff --git a/packages/backend/src/server/web/views/flash.pug b/packages/backend/src/server/web/views/flash.pug deleted file mode 100644 index 1549aa7906..0000000000 --- a/packages/backend/src/server/web/views/flash.pug +++ /dev/null @@ -1,35 +0,0 @@ -extends ./base - -block vars - - const user = flash.user; - - const title = flash.title; - - const url = `${config.url}/play/${flash.id}`; - -block title - = `${title} | ${instanceName}` - -block desc - meta(name='description' content= flash.summary) - -block og - meta(property='og:type' content='article') - meta(property='og:title' content= title) - meta(property='og:description' content= flash.summary) - meta(property='og:url' content= url) - meta(property='og:image' content= avatarUrl) - meta(property='twitter:card' content='summary') - -block meta - if profile.noCrawle - meta(name='robots' content='noindex') - if profile.preventAiLearning - meta(name='robots' content='noimageai') - meta(name='robots' content='noai') - - meta(name='misskey:user-username' content=user.username) - meta(name='misskey:user-id' content=user.id) - meta(name='misskey:flash-id' content=flash.id) - - // todo - if user.twitter - meta(name='twitter:creator' content=`@${user.twitter.screenName}`) diff --git a/packages/backend/src/server/web/views/flush.pug b/packages/backend/src/server/web/views/flush.pug index 648dd23c55..a73a45212f 100644 --- a/packages/backend/src/server/web/views/flush.pug +++ b/packages/backend/src/server/web/views/flush.pug @@ -4,7 +4,7 @@ html #msg script. const msg = document.getElementById('msg'); - const successText = `\nSuccess Flush! Back to Sharkey\n成功しました。Sharkeyを開き直してください。`; + const successText = `\nSuccess Flush! Back to Misskey\n成功しました。Misskeyを開き直してください。`; message('Start flushing.'); diff --git a/packages/backend/src/server/web/views/gallery-post.pug b/packages/backend/src/server/web/views/gallery-post.pug index 9ae25d9ac8..ca0663a481 100644 --- a/packages/backend/src/server/web/views/gallery-post.pug +++ b/packages/backend/src/server/web/views/gallery-post.pug @@ -16,19 +16,11 @@ block og meta(property='og:title' content= title) meta(property='og:description' content= post.description) meta(property='og:url' content= url) - if post.isSensitive - meta(property='og:image' content= avatarUrl) - meta(property='twitter:card' content='summary') - else - meta(property='og:image' content= post.files[0].thumbnailUrl) - meta(property='twitter:card' content='summary_large_image') + meta(property='og:image' content= post.files[0].thumbnailUrl) block meta if user.host || profile.noCrawle meta(name='robots' content='noindex') - if profile.preventAiLearning - meta(name='robots' content='noimageai') - meta(name='robots' content='noai') meta(name='misskey:user-username' content=user.username) meta(name='misskey:user-id' content=user.id) diff --git a/packages/backend/src/server/web/views/info-card.pug b/packages/backend/src/server/web/views/info-card.pug index 4a9d00a596..1d62778ce1 100644 --- a/packages/backend/src/server/web/views/info-card.pug +++ b/packages/backend/src/server/web/views/info-card.pug @@ -4,7 +4,7 @@ html head meta(charset='utf-8') - meta(name='application-name' content='Sharkey') + meta(name='application-name' content='Misskey') title= meta.name || host style. html, body { @@ -47,4 +47,4 @@ html header#banner(style=`background-image: url(${meta.bannerUrl})`) div#title= meta.name || host div#content - div#description!= meta.description + div#description= meta.description diff --git a/packages/backend/src/server/web/views/note.pug b/packages/backend/src/server/web/views/note.pug index 9bc652b6a1..65696ea138 100644 --- a/packages/backend/src/server/web/views/note.pug +++ b/packages/backend/src/server/web/views/note.pug @@ -5,8 +5,6 @@ block vars - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`; - const url = `${config.url}/notes/${note.id}`; - const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null; - - const images = (note.files || []).filter(file => file.type.startsWith('image/') && !file.isSensitive) - - const videos = (note.files || []).filter(file => file.type.startsWith('video/') && !file.isSensitive) block title = `${title} | ${instanceName}` @@ -19,33 +17,16 @@ block og meta(property='og:title' content= title) meta(property='og:description' content= summary) meta(property='og:url' content= url) - if videos.length - each video in videos - meta(property='og:video:url' content= video.url) - meta(property='og:video:secure_url' content= video.url) - meta(property='og:video:type' content= video.type) - // FIXME: add width and height - // FIXME: add embed player for Twitter - if images.length - meta(property='twitter:card' content='summary_large_image') - each image in images - meta(property='og:image' content= image.url) - else - meta(property='twitter:card' content='summary') - meta(property='og:image' content= avatarUrl) - + meta(property='og:image' content= avatarUrl) block meta if user.host || isRenote || profile.noCrawle meta(name='robots' content='noindex') - if profile.preventAiLearning - meta(name='robots' content='noimageai') - meta(name='robots' content='noai') meta(name='misskey:user-username' content=user.username) meta(name='misskey:user-id' content=user.id) meta(name='misskey:note-id' content=note.id) - + // todo if user.twitter meta(name='twitter:creator' content=`@${user.twitter.screenName}`) diff --git a/packages/backend/src/server/web/views/oauth.pug b/packages/backend/src/server/web/views/oauth.pug deleted file mode 100644 index 1470dbfbdf..0000000000 --- a/packages/backend/src/server/web/views/oauth.pug +++ /dev/null @@ -1,9 +0,0 @@ -extends ./base - -block meta - //- Should be removed by the page when it loads, so that it won't needlessly - //- stay when user navigates away via the navigation bar - //- XXX: Remove navigation bar in auth page? - meta(name='misskey:oauth:transaction-id' content=transactionId) - meta(name='misskey:oauth:client-name' content=clientName) - meta(name='misskey:oauth:scope' content=scope) diff --git a/packages/backend/src/server/web/views/page.pug b/packages/backend/src/server/web/views/page.pug index 08bb08ffe7..4219e76a52 100644 --- a/packages/backend/src/server/web/views/page.pug +++ b/packages/backend/src/server/web/views/page.pug @@ -17,14 +17,10 @@ block og meta(property='og:description' content= page.summary) meta(property='og:url' content= url) meta(property='og:image' content= page.eyeCatchingImage ? page.eyeCatchingImage.thumbnailUrl : avatarUrl) - meta(property='twitter:card' content= page.eyeCatchingImage ? 'summary_large_image' : 'summary') block meta if profile.noCrawle meta(name='robots' content='noindex') - if profile.preventAiLearning - meta(name='robots' content='noimageai') - meta(name='robots' content='noai') meta(name='misskey:user-username' content=user.username) meta(name='misskey:user-id' content=user.id) diff --git a/packages/backend/src/server/web/views/user.pug b/packages/backend/src/server/web/views/user.pug index 83d57349a6..119993fdb5 100644 --- a/packages/backend/src/server/web/views/user.pug +++ b/packages/backend/src/server/web/views/user.pug @@ -16,14 +16,10 @@ block og meta(property='og:description' content= profile.description) meta(property='og:url' content= url) meta(property='og:image' content= avatarUrl) - meta(property='twitter:card' content='summary') block meta if user.host || profile.noCrawle meta(name='robots' content='noindex') - if profile.preventAiLearning - meta(name='robots' content='noimageai') - meta(name='robots' content='noai') meta(name='misskey:user-username' content=user.username) meta(name='misskey:user-id' content=user.id) diff --git a/packages/backend/src/server/well-known.ts b/packages/backend/src/server/well-known.ts new file mode 100644 index 0000000000..1d094f2edd --- /dev/null +++ b/packages/backend/src/server/well-known.ts @@ -0,0 +1,151 @@ +import Router from '@koa/router'; + +import config from '@/config/index.js'; +import * as Acct from '@/misc/acct.js'; +import { links } from './nodeinfo.js'; +import { escapeAttribute, escapeValue } from '@/prelude/xml.js'; +import { Users } from '@/models/index.js'; +import { User } from '@/models/entities/user.js'; +import { FindOptionsWhere, IsNull } from 'typeorm'; + +// Init router +const router = new Router(); + +const XRD = (...x: { element: string, value?: string, attributes?: Record }[]) => + `${x.map(({ element, value, attributes }) => + `<${ + Object.entries(typeof attributes === 'object' && attributes || {}).reduce((a, [k, v]) => `${a} ${k}="${escapeAttribute(v)}"`, element) + }${ + typeof value === 'string' ? `>${escapeValue(value)}`).reduce((a, c) => a + c, '')}`; + +const allPath = '/.well-known/(.*)'; +const webFingerPath = '/.well-known/webfinger'; +const jrd = 'application/jrd+json'; +const xrd = 'application/xrd+xml'; + +router.use(allPath, async (ctx, next) => { + ctx.set({ + 'Access-Control-Allow-Headers': 'Accept', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Expose-Headers': 'Vary', + }); + await next(); +}); + +router.options(allPath, async ctx => { + ctx.status = 204; +}); + +router.get('/.well-known/host-meta', async ctx => { + ctx.set('Content-Type', xrd); + ctx.body = XRD({ element: 'Link', attributes: { + rel: 'lrdd', + type: xrd, + template: `${config.url}${webFingerPath}?resource={uri}`, + } }); +}); + +router.get('/.well-known/host-meta.json', async ctx => { + ctx.set('Content-Type', jrd); + ctx.body = { + links: [{ + rel: 'lrdd', + type: jrd, + template: `${config.url}${webFingerPath}?resource={uri}`, + }], + }; +}); + +router.get('/.well-known/nodeinfo', async ctx => { + ctx.body = { links }; +}); + +/* TODO +router.get('/.well-known/change-password', async ctx => { +}); +*/ + +router.get(webFingerPath, async ctx => { + const fromId = (id: User['id']): FindOptionsWhere => ({ + id, + host: IsNull(), + isSuspended: false, + }); + + const generateQuery = (resource: string): FindOptionsWhere | number => + resource.startsWith(`${config.url.toLowerCase()}/users/`) ? + fromId(resource.split('/').pop()!) : + fromAcct(Acct.parse( + resource.startsWith(`${config.url.toLowerCase()}/@`) ? resource.split('/').pop()! : + resource.startsWith('acct:') ? resource.slice('acct:'.length) : + resource)); + + const fromAcct = (acct: Acct.Acct): FindOptionsWhere | number => + !acct.host || acct.host === config.host.toLowerCase() ? { + usernameLower: acct.username, + host: IsNull(), + isSuspended: false, + } : 422; + + if (typeof ctx.query.resource !== 'string') { + ctx.status = 400; + return; + } + + const query = generateQuery(ctx.query.resource.toLowerCase()); + + if (typeof query === 'number') { + ctx.status = query; + return; + } + + const user = await Users.findOneBy(query); + + if (user == null) { + ctx.status = 404; + return; + } + + const subject = `acct:${user.username}@${config.host}`; + const self = { + rel: 'self', + type: 'application/activity+json', + href: `${config.url}/users/${user.id}`, + }; + const profilePage = { + rel: 'http://webfinger.net/rel/profile-page', + type: 'text/html', + href: `${config.url}/@${user.username}`, + }; + const subscribe = { + rel: 'http://ostatus.org/schema/1.0/subscribe', + template: `${config.url}/authorize-follow?acct={uri}`, + }; + + if (ctx.accepts(jrd, xrd) === xrd) { + ctx.body = XRD( + { element: 'Subject', value: subject }, + { element: 'Link', attributes: self }, + { element: 'Link', attributes: profilePage }, + { element: 'Link', attributes: subscribe }); + ctx.type = xrd; + } else { + ctx.body = { + subject, + links: [self, profilePage, subscribe], + }; + ctx.type = jrd; + } + + ctx.vary('Accept'); + ctx.set('Cache-Control', 'public, max-age=180'); +}); + +// Return 404 for other .well-known +router.all(allPath, async ctx => { + ctx.status = 404; +}); + +export default router; diff --git a/packages/backend/src/services/add-note-to-antenna.ts b/packages/backend/src/services/add-note-to-antenna.ts new file mode 100644 index 0000000000..1f344222e1 --- /dev/null +++ b/packages/backend/src/services/add-note-to-antenna.ts @@ -0,0 +1,54 @@ +import { Antenna } from '@/models/entities/antenna.js'; +import { Note } from '@/models/entities/note.js'; +import { AntennaNotes, Mutings, Notes } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; +import { publishAntennaStream, publishMainStream } from '@/services/stream.js'; +import { User } from '@/models/entities/user.js'; + +export async function addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }) { + // 通知しない設定になっているか、自分自身の投稿なら既読にする + const read = !antenna.notify || (antenna.userId === noteUser.id); + + AntennaNotes.insert({ + id: genId(), + antennaId: antenna.id, + noteId: note.id, + read: read, + }); + + publishAntennaStream(antenna.id, 'note', note); + + if (!read) { + const mutings = await Mutings.find({ + where: { + muterId: antenna.userId, + }, + select: ['muteeId'], + }); + + // Copy + const _note: Note = { + ...note, + }; + + if (note.replyId != null) { + _note.reply = await Notes.findOneByOrFail({ id: note.replyId }); + } + if (note.renoteId != null) { + _note.renote = await Notes.findOneByOrFail({ id: note.renoteId }); + } + + if (isUserRelated(_note, new Set(mutings.map(x => x.muteeId)))) { + return; + } + + // 2秒経っても既読にならなかったら通知 + setTimeout(async () => { + const unread = await AntennaNotes.findOneBy({ antennaId: antenna.id, read: false }); + if (unread) { + publishMainStream(antenna.userId, 'unreadAntenna', antenna); + } + }, 2000); + } +} diff --git a/packages/backend/src/services/blocking/create.ts b/packages/backend/src/services/blocking/create.ts new file mode 100644 index 0000000000..a2c61cca22 --- /dev/null +++ b/packages/backend/src/services/blocking/create.ts @@ -0,0 +1,145 @@ +import { publishMainStream, publishUserEvent } from '@/services/stream.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderFollow from '@/remote/activitypub/renderer/follow.js'; +import renderUndo from '@/remote/activitypub/renderer/undo.js'; +import { renderBlock } from '@/remote/activitypub/renderer/block.js'; +import { deliver } from '@/queue/index.js'; +import renderReject from '@/remote/activitypub/renderer/reject.js'; +import { Blocking } from '@/models/entities/blocking.js'; +import { User } from '@/models/entities/user.js'; +import { Blockings, Users, FollowRequests, Followings, UserListJoinings, UserLists } from '@/models/index.js'; +import { perUserFollowingChart } from '@/services/chart/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { getActiveWebhooks } from '@/misc/webhook-cache.js'; +import { webhookDeliver } from '@/queue/index.js'; + +export default async function(blocker: User, blockee: User) { + await Promise.all([ + cancelRequest(blocker, blockee), + cancelRequest(blockee, blocker), + unFollow(blocker, blockee), + unFollow(blockee, blocker), + removeFromList(blockee, blocker), + ]); + + const blocking = { + id: genId(), + createdAt: new Date(), + blocker, + blockerId: blocker.id, + blockee, + blockeeId: blockee.id, + } as Blocking; + + await Blockings.insert(blocking); + + if (Users.isLocalUser(blocker) && Users.isRemoteUser(blockee)) { + const content = renderActivity(renderBlock(blocking)); + deliver(blocker, content, blockee.inbox); + } +} + +async function cancelRequest(follower: User, followee: User) { + const request = await FollowRequests.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (request == null) { + return; + } + + await FollowRequests.delete({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (Users.isLocalUser(followee)) { + Users.pack(followee, followee, { + detail: true, + }).then(packed => publishMainStream(followee.id, 'meUpdated', packed)); + } + + if (Users.isLocalUser(follower)) { + Users.pack(followee, follower, { + detail: true, + }).then(async packed => { + publishUserEvent(follower.id, 'unfollow', packed); + publishMainStream(follower.id, 'unfollow', packed); + + const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); + for (const webhook of webhooks) { + webhookDeliver(webhook, 'unfollow', { + user: packed, + }); + } + }); + } + + // リモートにフォローリクエストをしていたらUndoFollow送信 + if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) { + const content = renderActivity(renderUndo(renderFollow(follower, followee), follower)); + deliver(follower, content, followee.inbox); + } + + // リモートからフォローリクエストを受けていたらReject送信 + if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { + const content = renderActivity(renderReject(renderFollow(follower, followee, request.requestId!), followee)); + deliver(followee, content, follower.inbox); + } +} + +async function unFollow(follower: User, followee: User) { + const following = await Followings.findOneBy({ + followerId: follower.id, + followeeId: followee.id, + }); + + if (following == null) { + return; + } + + await Promise.all([ + Followings.delete(following.id), + Users.decrement({ id: follower.id }, 'followingCount', 1), + Users.decrement({ id: followee.id }, 'followersCount', 1), + perUserFollowingChart.update(follower, followee, false), + ]); + + // Publish unfollow event + if (Users.isLocalUser(follower)) { + Users.pack(followee, follower, { + detail: true, + }).then(async packed => { + publishUserEvent(follower.id, 'unfollow', packed); + publishMainStream(follower.id, 'unfollow', packed); + + const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); + for (const webhook of webhooks) { + webhookDeliver(webhook, 'unfollow', { + user: packed, + }); + } + }); + } + + // リモートにフォローをしていたらUndoFollow送信 + if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) { + const content = renderActivity(renderUndo(renderFollow(follower, followee), follower)); + deliver(follower, content, followee.inbox); + } +} + +async function removeFromList(listOwner: User, user: User) { + const userLists = await UserLists.findBy({ + userId: listOwner.id, + }); + + for (const userList of userLists) { + await UserListJoinings.delete({ + userListId: userList.id, + userId: user.id, + }); + } +} diff --git a/packages/backend/src/services/blocking/delete.ts b/packages/backend/src/services/blocking/delete.ts new file mode 100644 index 0000000000..cb16651bc0 --- /dev/null +++ b/packages/backend/src/services/blocking/delete.ts @@ -0,0 +1,34 @@ +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import { renderBlock } from '@/remote/activitypub/renderer/block.js'; +import renderUndo from '@/remote/activitypub/renderer/undo.js'; +import { deliver } from '@/queue/index.js'; +import Logger from '../logger.js'; +import { CacheableUser, User } from '@/models/entities/user.js'; +import { Blockings, Users } from '@/models/index.js'; + +const logger = new Logger('blocking/delete'); + +export default async function(blocker: CacheableUser, blockee: CacheableUser) { + const blocking = await Blockings.findOneBy({ + blockerId: blocker.id, + blockeeId: blockee.id, + }); + + if (blocking == null) { + logger.warn('ブロック解除がリクエストされましたがブロックしていませんでした'); + return; + } + + // Since we already have the blocker and blockee, we do not need to fetch + // them in the query above and can just manually insert them here. + blocking.blocker = blocker; + blocking.blockee = blockee; + + Blockings.delete(blocking.id); + + // deliver if remote bloking + if (Users.isLocalUser(blocker) && Users.isRemoteUser(blockee)) { + const content = renderActivity(renderUndo(renderBlock(blocking), blocker)); + deliver(blocker, content, blockee.inbox); + } +} diff --git a/packages/backend/src/services/chart/charts/active-users.ts b/packages/backend/src/services/chart/charts/active-users.ts new file mode 100644 index 0000000000..d952ea53bd --- /dev/null +++ b/packages/backend/src/services/chart/charts/active-users.ts @@ -0,0 +1,44 @@ +import Chart, { KVs } from '../core.js'; +import { User } from '@/models/entities/user.js'; +import { Users } from '@/models/index.js'; +import { name, schema } from './entities/active-users.js'; + +const week = 1000 * 60 * 60 * 24 * 7; +const month = 1000 * 60 * 60 * 24 * 30; +const year = 1000 * 60 * 60 * 24 * 365; + +/** + * アクティブユーザーに関するチャート + */ +// eslint-disable-next-line import/no-default-export +export default class ActiveUsersChart extends Chart { + constructor() { + super(name, schema); + } + + protected async tickMajor(): Promise>> { + return {}; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async read(user: { id: User['id'], host: null, createdAt: User['createdAt'] }): Promise { + await this.commit({ + 'read': [user.id], + 'registeredWithinWeek': (Date.now() - user.createdAt.getTime() < week) ? [user.id] : [], + 'registeredWithinMonth': (Date.now() - user.createdAt.getTime() < month) ? [user.id] : [], + 'registeredWithinYear': (Date.now() - user.createdAt.getTime() < year) ? [user.id] : [], + 'registeredOutsideWeek': (Date.now() - user.createdAt.getTime() > week) ? [user.id] : [], + 'registeredOutsideMonth': (Date.now() - user.createdAt.getTime() > month) ? [user.id] : [], + 'registeredOutsideYear': (Date.now() - user.createdAt.getTime() > year) ? [user.id] : [], + }); + } + + public async write(user: { id: User['id'], host: null, createdAt: User['createdAt'] }): Promise { + await this.commit({ + 'write': [user.id], + }); + } +} diff --git a/packages/backend/src/services/chart/charts/ap-request.ts b/packages/backend/src/services/chart/charts/ap-request.ts new file mode 100644 index 0000000000..e9e42ade7f --- /dev/null +++ b/packages/backend/src/services/chart/charts/ap-request.ts @@ -0,0 +1,38 @@ +import Chart, { KVs } from '../core.js'; +import { name, schema } from './entities/ap-request.js'; + +/** + * Chart about ActivityPub requests + */ +// eslint-disable-next-line import/no-default-export +export default class ApRequestChart extends Chart { + constructor() { + super(name, schema); + } + + protected async tickMajor(): Promise>> { + return {}; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async deliverSucc(): Promise { + await this.commit({ + 'deliverSucceeded': 1, + }); + } + + public async deliverFail(): Promise { + await this.commit({ + 'deliverFailed': 1, + }); + } + + public async inbox(): Promise { + await this.commit({ + 'inboxReceived': 1, + }); + } +} diff --git a/packages/backend/src/services/chart/charts/drive.ts b/packages/backend/src/services/chart/charts/drive.ts new file mode 100644 index 0000000000..0eeba90dd3 --- /dev/null +++ b/packages/backend/src/services/chart/charts/drive.ts @@ -0,0 +1,38 @@ +import Chart, { KVs } from '../core.js'; +import { DriveFiles } from '@/models/index.js'; +import { Not, IsNull } from 'typeorm'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { name, schema } from './entities/drive.js'; + +/** + * ドライブに関するチャート + */ +// eslint-disable-next-line import/no-default-export +export default class DriveChart extends Chart { + constructor() { + super(name, schema); + } + + protected async tickMajor(): Promise>> { + return {}; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async update(file: DriveFile, isAdditional: boolean): Promise { + const fileSizeKb = file.size / 1000; + await this.commit(file.userHost === null ? { + 'local.incCount': isAdditional ? 1 : 0, + 'local.incSize': isAdditional ? fileSizeKb : 0, + 'local.decCount': isAdditional ? 0 : 1, + 'local.decSize': isAdditional ? 0 : fileSizeKb, + } : { + 'remote.incCount': isAdditional ? 1 : 0, + 'remote.incSize': isAdditional ? fileSizeKb : 0, + 'remote.decCount': isAdditional ? 0 : 1, + 'remote.decSize': isAdditional ? 0 : fileSizeKb, + }); + } +} diff --git a/packages/backend/src/services/chart/charts/entities/active-users.ts b/packages/backend/src/services/chart/charts/entities/active-users.ts new file mode 100644 index 0000000000..5767b76f8e --- /dev/null +++ b/packages/backend/src/services/chart/charts/entities/active-users.ts @@ -0,0 +1,17 @@ +import Chart from '../../core.js'; + +export const name = 'activeUsers'; + +export const schema = { + 'readWrite': { intersection: ['read', 'write'], range: 'small' }, + 'read': { uniqueIncrement: true, range: 'small' }, + 'write': { uniqueIncrement: true, range: 'small' }, + 'registeredWithinWeek': { uniqueIncrement: true, range: 'small' }, + 'registeredWithinMonth': { uniqueIncrement: true, range: 'small' }, + 'registeredWithinYear': { uniqueIncrement: true, range: 'small' }, + 'registeredOutsideWeek': { uniqueIncrement: true, range: 'small' }, + 'registeredOutsideMonth': { uniqueIncrement: true, range: 'small' }, + 'registeredOutsideYear': { uniqueIncrement: true, range: 'small' }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/services/chart/charts/entities/ap-request.ts b/packages/backend/src/services/chart/charts/entities/ap-request.ts new file mode 100644 index 0000000000..3a9f3dacfd --- /dev/null +++ b/packages/backend/src/services/chart/charts/entities/ap-request.ts @@ -0,0 +1,11 @@ +import Chart from '../../core.js'; + +export const name = 'apRequest'; + +export const schema = { + 'deliverFailed': { }, + 'deliverSucceeded': { }, + 'inboxReceived': { }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/services/chart/charts/entities/drive.ts b/packages/backend/src/services/chart/charts/entities/drive.ts new file mode 100644 index 0000000000..4bf5bb729e --- /dev/null +++ b/packages/backend/src/services/chart/charts/entities/drive.ts @@ -0,0 +1,16 @@ +import Chart from '../../core.js'; + +export const name = 'drive'; + +export const schema = { + 'local.incCount': {}, + 'local.incSize': {}, // in kilobyte + 'local.decCount': {}, + 'local.decSize': {}, // in kilobyte + 'remote.incCount': {}, + 'remote.incSize': {}, // in kilobyte + 'remote.decCount': {}, + 'remote.decSize': {}, // in kilobyte +} as const; + +export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/services/chart/charts/entities/federation.ts b/packages/backend/src/services/chart/charts/entities/federation.ts new file mode 100644 index 0000000000..a8466b0b4c --- /dev/null +++ b/packages/backend/src/services/chart/charts/entities/federation.ts @@ -0,0 +1,16 @@ +import Chart from '../../core.js'; + +export const name = 'federation'; + +export const schema = { + 'deliveredInstances': { uniqueIncrement: true, range: 'small' }, + 'inboxInstances': { uniqueIncrement: true, range: 'small' }, + 'stalled': { uniqueIncrement: true, range: 'small' }, + 'sub': { accumulate: true, range: 'small' }, + 'pub': { accumulate: true, range: 'small' }, + 'pubsub': { accumulate: true, range: 'small' }, + 'subActive': { accumulate: true, range: 'small' }, + 'pubActive': { accumulate: true, range: 'small' }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/services/chart/charts/entities/hashtag.ts b/packages/backend/src/services/chart/charts/entities/hashtag.ts new file mode 100644 index 0000000000..4d04039047 --- /dev/null +++ b/packages/backend/src/services/chart/charts/entities/hashtag.ts @@ -0,0 +1,10 @@ +import Chart from '../../core.js'; + +export const name = 'hashtag'; + +export const schema = { + 'local.users': { uniqueIncrement: true }, + 'remote.users': { uniqueIncrement: true }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/services/chart/charts/entities/instance.ts b/packages/backend/src/services/chart/charts/entities/instance.ts new file mode 100644 index 0000000000..06962120e2 --- /dev/null +++ b/packages/backend/src/services/chart/charts/entities/instance.ts @@ -0,0 +1,32 @@ +import Chart from '../../core.js'; + +export const name = 'instance'; + +export const schema = { + 'requests.failed': { range: 'small' }, + 'requests.succeeded': { range: 'small' }, + 'requests.received': { range: 'small' }, + 'notes.total': { accumulate: true }, + 'notes.inc': {}, + 'notes.dec': {}, + 'notes.diffs.normal': {}, + 'notes.diffs.reply': {}, + 'notes.diffs.renote': {}, + 'notes.diffs.withFile': {}, + 'users.total': { accumulate: true }, + 'users.inc': { range: 'small' }, + 'users.dec': { range: 'small' }, + 'following.total': { accumulate: true }, + 'following.inc': { range: 'small' }, + 'following.dec': { range: 'small' }, + 'followers.total': { accumulate: true }, + 'followers.inc': { range: 'small' }, + 'followers.dec': { range: 'small' }, + 'drive.totalFiles': { accumulate: true }, + 'drive.incFiles': {}, + 'drive.decFiles': {}, + 'drive.incUsage': {}, // in kilobyte + 'drive.decUsage': {}, // in kilobyte +} as const; + +export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/services/chart/charts/entities/notes.ts b/packages/backend/src/services/chart/charts/entities/notes.ts new file mode 100644 index 0000000000..9387dbfb2c --- /dev/null +++ b/packages/backend/src/services/chart/charts/entities/notes.ts @@ -0,0 +1,22 @@ +import Chart from '../../core.js'; + +export const name = 'notes'; + +export const schema = { + 'local.total': { accumulate: true }, + 'local.inc': {}, + 'local.dec': {}, + 'local.diffs.normal': {}, + 'local.diffs.reply': {}, + 'local.diffs.renote': {}, + 'local.diffs.withFile': {}, + 'remote.total': { accumulate: true }, + 'remote.inc': {}, + 'remote.dec': {}, + 'remote.diffs.normal': {}, + 'remote.diffs.reply': {}, + 'remote.diffs.renote': {}, + 'remote.diffs.withFile': {}, +} as const; + +export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/services/chart/charts/entities/per-user-drive.ts b/packages/backend/src/services/chart/charts/entities/per-user-drive.ts new file mode 100644 index 0000000000..6111640ea0 --- /dev/null +++ b/packages/backend/src/services/chart/charts/entities/per-user-drive.ts @@ -0,0 +1,14 @@ +import Chart from '../../core.js'; + +export const name = 'perUserDrive'; + +export const schema = { + 'totalCount': { accumulate: true }, + 'totalSize': { accumulate: true }, // in kilobyte + 'incCount': { range: 'small' }, + 'incSize': {}, // in kilobyte + 'decCount': { range: 'small' }, + 'decSize': {}, // in kilobyte +} as const; + +export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/services/chart/charts/entities/per-user-following.ts b/packages/backend/src/services/chart/charts/entities/per-user-following.ts new file mode 100644 index 0000000000..4118daa474 --- /dev/null +++ b/packages/backend/src/services/chart/charts/entities/per-user-following.ts @@ -0,0 +1,20 @@ +import Chart from '../../core.js'; + +export const name = 'perUserFollowing'; + +export const schema = { + 'local.followings.total': { accumulate: true }, + 'local.followings.inc': { range: 'small' }, + 'local.followings.dec': { range: 'small' }, + 'local.followers.total': { accumulate: true }, + 'local.followers.inc': { range: 'small' }, + 'local.followers.dec': { range: 'small' }, + 'remote.followings.total': { accumulate: true }, + 'remote.followings.inc': { range: 'small' }, + 'remote.followings.dec': { range: 'small' }, + 'remote.followers.total': { accumulate: true }, + 'remote.followers.inc': { range: 'small' }, + 'remote.followers.dec': { range: 'small' }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/services/chart/charts/entities/per-user-notes.ts b/packages/backend/src/services/chart/charts/entities/per-user-notes.ts new file mode 100644 index 0000000000..c1fa174452 --- /dev/null +++ b/packages/backend/src/services/chart/charts/entities/per-user-notes.ts @@ -0,0 +1,15 @@ +import Chart from '../../core.js'; + +export const name = 'perUserNotes'; + +export const schema = { + 'total': { accumulate: true }, + 'inc': { range: 'small' }, + 'dec': { range: 'small' }, + 'diffs.normal': { range: 'small' }, + 'diffs.reply': { range: 'small' }, + 'diffs.renote': { range: 'small' }, + 'diffs.withFile': { range: 'small' }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/services/chart/charts/entities/per-user-reactions.ts b/packages/backend/src/services/chart/charts/entities/per-user-reactions.ts new file mode 100644 index 0000000000..5e1a6c7b30 --- /dev/null +++ b/packages/backend/src/services/chart/charts/entities/per-user-reactions.ts @@ -0,0 +1,10 @@ +import Chart from '../../core.js'; + +export const name = 'perUserReaction'; + +export const schema = { + 'local.count': { range: 'small' }, + 'remote.count': { range: 'small' }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/services/chart/charts/entities/test-grouped.ts b/packages/backend/src/services/chart/charts/entities/test-grouped.ts new file mode 100644 index 0000000000..66b6e8e864 --- /dev/null +++ b/packages/backend/src/services/chart/charts/entities/test-grouped.ts @@ -0,0 +1,11 @@ +import Chart from '../../core.js'; + +export const name = 'testGrouped'; + +export const schema = { + 'foo.total': { accumulate: true }, + 'foo.inc': {}, + 'foo.dec': {}, +} as const; + +export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/services/chart/charts/entities/test-intersection.ts b/packages/backend/src/services/chart/charts/entities/test-intersection.ts new file mode 100644 index 0000000000..a3bdcb367f --- /dev/null +++ b/packages/backend/src/services/chart/charts/entities/test-intersection.ts @@ -0,0 +1,11 @@ +import Chart from '../../core.js'; + +export const name = 'testIntersection'; + +export const schema = { + 'a': { uniqueIncrement: true }, + 'b': { uniqueIncrement: true }, + 'aAndB': { intersection: ['a', 'b'] }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/services/chart/charts/entities/test-unique.ts b/packages/backend/src/services/chart/charts/entities/test-unique.ts new file mode 100644 index 0000000000..b2cfb71b05 --- /dev/null +++ b/packages/backend/src/services/chart/charts/entities/test-unique.ts @@ -0,0 +1,9 @@ +import Chart from '../../core.js'; + +export const name = 'testUnique'; + +export const schema = { + 'foo': { uniqueIncrement: true }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/services/chart/charts/entities/test.ts b/packages/backend/src/services/chart/charts/entities/test.ts new file mode 100644 index 0000000000..7cba21e16a --- /dev/null +++ b/packages/backend/src/services/chart/charts/entities/test.ts @@ -0,0 +1,11 @@ +import Chart from '../../core.js'; + +export const name = 'test'; + +export const schema = { + 'foo.total': { accumulate: true }, + 'foo.inc': {}, + 'foo.dec': {}, +} as const; + +export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/services/chart/charts/entities/users.ts b/packages/backend/src/services/chart/charts/entities/users.ts new file mode 100644 index 0000000000..c0b83094ae --- /dev/null +++ b/packages/backend/src/services/chart/charts/entities/users.ts @@ -0,0 +1,14 @@ +import Chart from '../../core.js'; + +export const name = 'users'; + +export const schema = { + 'local.total': { accumulate: true }, + 'local.inc': { range: 'small' }, + 'local.dec': { range: 'small' }, + 'remote.total': { accumulate: true }, + 'remote.inc': { range: 'small' }, + 'remote.dec': { range: 'small' }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/services/chart/charts/federation.ts b/packages/backend/src/services/chart/charts/federation.ts new file mode 100644 index 0000000000..10221ee1e3 --- /dev/null +++ b/packages/backend/src/services/chart/charts/federation.ts @@ -0,0 +1,103 @@ +import Chart, { KVs } from '../core.js'; +import { Followings, Instances } from '@/models/index.js'; +import { name, schema } from './entities/federation.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; + +/** + * フェデレーションに関するチャート + */ +// eslint-disable-next-line import/no-default-export +export default class FederationChart extends Chart { + constructor() { + super(name, schema); + } + + protected async tickMajor(): Promise>> { + return { + }; + } + + protected async tickMinor(): Promise>> { + const meta = await fetchMeta(); + + const suspendedInstancesQuery = Instances.createQueryBuilder('instance') + .select('instance.host') + .where('instance.isSuspended = true'); + + const pubsubSubQuery = Followings.createQueryBuilder('f') + .select('f.followerHost') + .where('f.followerHost IS NOT NULL'); + + const subInstancesQuery = Followings.createQueryBuilder('f') + .select('f.followeeHost') + .where('f.followeeHost IS NOT NULL'); + + const pubInstancesQuery = Followings.createQueryBuilder('f') + .select('f.followerHost') + .where('f.followerHost IS NOT NULL'); + + const [sub, pub, pubsub, subActive, pubActive] = await Promise.all([ + Followings.createQueryBuilder('following') + .select('COUNT(DISTINCT following.followeeHost)') + .where('following.followeeHost IS NOT NULL') + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : `following.followeeHost NOT IN (:...blocked)`, { blocked: meta.blockedHosts }) + .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) + .getRawOne() + .then(x => parseInt(x.count, 10)), + Followings.createQueryBuilder('following') + .select('COUNT(DISTINCT following.followerHost)') + .where('following.followerHost IS NOT NULL') + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : `following.followerHost NOT IN (:...blocked)`, { blocked: meta.blockedHosts }) + .andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) + .getRawOne() + .then(x => parseInt(x.count, 10)), + Followings.createQueryBuilder('following') + .select('COUNT(DISTINCT following.followeeHost)') + .where('following.followeeHost IS NOT NULL') + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : `following.followeeHost NOT IN (:...blocked)`, { blocked: meta.blockedHosts }) + .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) + .andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`) + .setParameters(pubsubSubQuery.getParameters()) + .getRawOne() + .then(x => parseInt(x.count, 10)), + Instances.createQueryBuilder('instance') + .select('COUNT(instance.id)') + .where(`instance.host IN (${ subInstancesQuery.getQuery() })`) + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : `instance.host NOT IN (:...blocked)`, { blocked: meta.blockedHosts }) + .andWhere(`instance.isSuspended = false`) + .andWhere(`instance.lastCommunicatedAt > :gt`, { gt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) + .getRawOne() + .then(x => parseInt(x.count, 10)), + Instances.createQueryBuilder('instance') + .select('COUNT(instance.id)') + .where(`instance.host IN (${ pubInstancesQuery.getQuery() })`) + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : `instance.host NOT IN (:...blocked)`, { blocked: meta.blockedHosts }) + .andWhere(`instance.isSuspended = false`) + .andWhere(`instance.lastCommunicatedAt > :gt`, { gt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) + .getRawOne() + .then(x => parseInt(x.count, 10)), + ]); + + return { + 'sub': sub, + 'pub': pub, + 'pubsub': pubsub, + 'subActive': subActive, + 'pubActive': pubActive, + }; + } + + public async deliverd(host: string, succeeded: boolean): Promise { + await this.commit(succeeded ? { + 'deliveredInstances': [host], + } : { + 'stalled': [host], + }); + } + + public async inbox(host: string): Promise { + await this.commit({ + 'inboxInstances': [host], + }); + } +} diff --git a/packages/backend/src/services/chart/charts/hashtag.ts b/packages/backend/src/services/chart/charts/hashtag.ts new file mode 100644 index 0000000000..31f7fa95dc --- /dev/null +++ b/packages/backend/src/services/chart/charts/hashtag.ts @@ -0,0 +1,29 @@ +import Chart, { KVs } from '../core.js'; +import { User } from '@/models/entities/user.js'; +import { Users } from '@/models/index.js'; +import { name, schema } from './entities/hashtag.js'; + +/** + * ハッシュタグに関するチャート + */ +// eslint-disable-next-line import/no-default-export +export default class HashtagChart extends Chart { + constructor() { + super(name, schema, true); + } + + protected async tickMajor(): Promise>> { + return {}; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async update(hashtag: string, user: { id: User['id'], host: User['host'] }): Promise { + await this.commit({ + 'local.users': Users.isLocalUser(user) ? [user.id] : [], + 'remote.users': Users.isLocalUser(user) ? [] : [user.id], + }, hashtag); + } +} diff --git a/packages/backend/src/services/chart/charts/instance.ts b/packages/backend/src/services/chart/charts/instance.ts new file mode 100644 index 0000000000..fe29ba5228 --- /dev/null +++ b/packages/backend/src/services/chart/charts/instance.ts @@ -0,0 +1,103 @@ +import Chart, { KVs } from '../core.js'; +import { DriveFiles, Followings, Users, Notes } from '@/models/index.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { Note } from '@/models/entities/note.js'; +import { toPuny } from '@/misc/convert-host.js'; +import { name, schema } from './entities/instance.js'; + +/** + * インスタンスごとのチャート + */ +// eslint-disable-next-line import/no-default-export +export default class InstanceChart extends Chart { + constructor() { + super(name, schema, true); + } + + protected async tickMajor(group: string): Promise>> { + const [ + notesCount, + usersCount, + followingCount, + followersCount, + driveFiles, + ] = await Promise.all([ + Notes.countBy({ userHost: group }), + Users.countBy({ host: group }), + Followings.countBy({ followerHost: group }), + Followings.countBy({ followeeHost: group }), + DriveFiles.countBy({ userHost: group }), + ]); + + return { + 'notes.total': notesCount, + 'users.total': usersCount, + 'following.total': followingCount, + 'followers.total': followersCount, + 'drive.totalFiles': driveFiles, + }; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async requestReceived(host: string): Promise { + await this.commit({ + 'requests.received': 1, + }, toPuny(host)); + } + + public async requestSent(host: string, isSucceeded: boolean): Promise { + await this.commit({ + 'requests.succeeded': isSucceeded ? 1 : 0, + 'requests.failed': isSucceeded ? 0 : 1, + }, toPuny(host)); + } + + public async newUser(host: string): Promise { + await this.commit({ + 'users.total': 1, + 'users.inc': 1, + }, toPuny(host)); + } + + public async updateNote(host: string, note: Note, isAdditional: boolean): Promise { + await this.commit({ + 'notes.total': isAdditional ? 1 : -1, + 'notes.inc': isAdditional ? 1 : 0, + 'notes.dec': isAdditional ? 0 : 1, + 'notes.diffs.normal': note.replyId == null && note.renoteId == null ? (isAdditional ? 1 : -1) : 0, + 'notes.diffs.renote': note.renoteId != null ? (isAdditional ? 1 : -1) : 0, + 'notes.diffs.reply': note.replyId != null ? (isAdditional ? 1 : -1) : 0, + 'notes.diffs.withFile': note.fileIds.length > 0 ? (isAdditional ? 1 : -1) : 0, + }, toPuny(host)); + } + + public async updateFollowing(host: string, isAdditional: boolean): Promise { + await this.commit({ + 'following.total': isAdditional ? 1 : -1, + 'following.inc': isAdditional ? 1 : 0, + 'following.dec': isAdditional ? 0 : 1, + }, toPuny(host)); + } + + public async updateFollowers(host: string, isAdditional: boolean): Promise { + await this.commit({ + 'followers.total': isAdditional ? 1 : -1, + 'followers.inc': isAdditional ? 1 : 0, + 'followers.dec': isAdditional ? 0 : 1, + }, toPuny(host)); + } + + public async updateDrive(file: DriveFile, isAdditional: boolean): Promise { + const fileSizeKb = file.size / 1000; + await this.commit({ + 'drive.totalFiles': isAdditional ? 1 : -1, + 'drive.incFiles': isAdditional ? 1 : 0, + 'drive.incUsage': isAdditional ? fileSizeKb : 0, + 'drive.decFiles': isAdditional ? 1 : 0, + 'drive.decUsage': isAdditional ? fileSizeKb : 0, + }, file.userHost); + } +} diff --git a/packages/backend/src/services/chart/charts/notes.ts b/packages/backend/src/services/chart/charts/notes.ts new file mode 100644 index 0000000000..bb14b62f3c --- /dev/null +++ b/packages/backend/src/services/chart/charts/notes.ts @@ -0,0 +1,45 @@ +import Chart, { KVs } from '../core.js'; +import { Notes } from '@/models/index.js'; +import { Not, IsNull } from 'typeorm'; +import { Note } from '@/models/entities/note.js'; +import { name, schema } from './entities/notes.js'; + +/** + * ノートに関するチャート + */ +// eslint-disable-next-line import/no-default-export +export default class NotesChart extends Chart { + constructor() { + super(name, schema); + } + + protected async tickMajor(): Promise>> { + const [localCount, remoteCount] = await Promise.all([ + Notes.countBy({ userHost: IsNull() }), + Notes.countBy({ userHost: Not(IsNull()) }), + ]); + + return { + 'local.total': localCount, + 'remote.total': remoteCount, + }; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async update(note: Note, isAdditional: boolean): Promise { + const prefix = note.userHost === null ? 'local' : 'remote'; + + await this.commit({ + [`${prefix}.total`]: isAdditional ? 1 : -1, + [`${prefix}.inc`]: isAdditional ? 1 : 0, + [`${prefix}.dec`]: isAdditional ? 0 : 1, + [`${prefix}.diffs.normal`]: note.replyId == null && note.renoteId == null ? (isAdditional ? 1 : -1) : 0, + [`${prefix}.diffs.renote`]: note.renoteId != null ? (isAdditional ? 1 : -1) : 0, + [`${prefix}.diffs.reply`]: note.replyId != null ? (isAdditional ? 1 : -1) : 0, + [`${prefix}.diffs.withFile`]: note.fileIds.length > 0 ? (isAdditional ? 1 : -1) : 0, + }); + } +} diff --git a/packages/backend/src/services/chart/charts/per-user-drive.ts b/packages/backend/src/services/chart/charts/per-user-drive.ts new file mode 100644 index 0000000000..5f75dc6887 --- /dev/null +++ b/packages/backend/src/services/chart/charts/per-user-drive.ts @@ -0,0 +1,42 @@ +import Chart, { KVs } from '../core.js'; +import { DriveFiles } from '@/models/index.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { name, schema } from './entities/per-user-drive.js'; + +/** + * ユーザーごとのドライブに関するチャート + */ +// eslint-disable-next-line import/no-default-export +export default class PerUserDriveChart extends Chart { + constructor() { + super(name, schema, true); + } + + protected async tickMajor(group: string): Promise>> { + const [count, size] = await Promise.all([ + DriveFiles.countBy({ userId: group }), + DriveFiles.calcDriveUsageOf(group), + ]); + + return { + 'totalCount': count, + 'totalSize': size, + }; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async update(file: DriveFile, isAdditional: boolean): Promise { + const fileSizeKb = file.size / 1000; + await this.commit({ + 'totalCount': isAdditional ? 1 : -1, + 'totalSize': isAdditional ? fileSizeKb : -fileSizeKb, + 'incCount': isAdditional ? 1 : 0, + 'incSize': isAdditional ? fileSizeKb : 0, + 'decCount': isAdditional ? 0 : 1, + 'decSize': isAdditional ? 0 : fileSizeKb, + }, file.userId); + } +} diff --git a/packages/backend/src/services/chart/charts/per-user-following.ts b/packages/backend/src/services/chart/charts/per-user-following.ts new file mode 100644 index 0000000000..02b149f52a --- /dev/null +++ b/packages/backend/src/services/chart/charts/per-user-following.ts @@ -0,0 +1,56 @@ +import Chart, { KVs } from '../core.js'; +import { Followings, Users } from '@/models/index.js'; +import { Not, IsNull } from 'typeorm'; +import { User } from '@/models/entities/user.js'; +import { name, schema } from './entities/per-user-following.js'; + +/** + * ユーザーごとのフォローに関するチャート + */ +// eslint-disable-next-line import/no-default-export +export default class PerUserFollowingChart extends Chart { + constructor() { + super(name, schema, true); + } + + protected async tickMajor(group: string): Promise>> { + const [ + localFollowingsCount, + localFollowersCount, + remoteFollowingsCount, + remoteFollowersCount, + ] = await Promise.all([ + Followings.countBy({ followerId: group, followeeHost: IsNull() }), + Followings.countBy({ followeeId: group, followerHost: IsNull() }), + Followings.countBy({ followerId: group, followeeHost: Not(IsNull()) }), + Followings.countBy({ followeeId: group, followerHost: Not(IsNull()) }), + ]); + + return { + 'local.followings.total': localFollowingsCount, + 'local.followers.total': localFollowersCount, + 'remote.followings.total': remoteFollowingsCount, + 'remote.followers.total': remoteFollowersCount, + }; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async update(follower: { id: User['id']; host: User['host']; }, followee: { id: User['id']; host: User['host']; }, isFollow: boolean): Promise { + const prefixFollower = Users.isLocalUser(follower) ? 'local' : 'remote'; + const prefixFollowee = Users.isLocalUser(followee) ? 'local' : 'remote'; + + this.commit({ + [`${prefixFollower}.followings.total`]: isFollow ? 1 : -1, + [`${prefixFollower}.followings.inc`]: isFollow ? 1 : 0, + [`${prefixFollower}.followings.dec`]: isFollow ? 0 : 1, + }, follower.id); + this.commit({ + [`${prefixFollowee}.followers.total`]: isFollow ? 1 : -1, + [`${prefixFollowee}.followers.inc`]: isFollow ? 1 : 0, + [`${prefixFollowee}.followers.dec`]: isFollow ? 0 : 1, + }, followee.id); + } +} diff --git a/packages/backend/src/services/chart/charts/per-user-notes.ts b/packages/backend/src/services/chart/charts/per-user-notes.ts new file mode 100644 index 0000000000..b9191dd088 --- /dev/null +++ b/packages/backend/src/services/chart/charts/per-user-notes.ts @@ -0,0 +1,41 @@ +import Chart, { KVs } from '../core.js'; +import { User } from '@/models/entities/user.js'; +import { Notes } from '@/models/index.js'; +import { Note } from '@/models/entities/note.js'; +import { name, schema } from './entities/per-user-notes.js'; + +/** + * ユーザーごとのノートに関するチャート + */ +// eslint-disable-next-line import/no-default-export +export default class PerUserNotesChart extends Chart { + constructor() { + super(name, schema, true); + } + + protected async tickMajor(group: string): Promise>> { + const [count] = await Promise.all([ + Notes.countBy({ userId: group }), + ]); + + return { + total: count, + }; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async update(user: { id: User['id'] }, note: Note, isAdditional: boolean): Promise { + await this.commit({ + 'total': isAdditional ? 1 : -1, + 'inc': isAdditional ? 1 : 0, + 'dec': isAdditional ? 0 : 1, + 'diffs.normal': note.replyId == null && note.renoteId == null ? (isAdditional ? 1 : -1) : 0, + 'diffs.renote': note.renoteId != null ? (isAdditional ? 1 : -1) : 0, + 'diffs.reply': note.replyId != null ? (isAdditional ? 1 : -1) : 0, + 'diffs.withFile': note.fileIds.length > 0 ? (isAdditional ? 1 : -1) : 0, + }, user.id); + } +} diff --git a/packages/backend/src/services/chart/charts/per-user-reactions.ts b/packages/backend/src/services/chart/charts/per-user-reactions.ts new file mode 100644 index 0000000000..3a830e118c --- /dev/null +++ b/packages/backend/src/services/chart/charts/per-user-reactions.ts @@ -0,0 +1,30 @@ +import Chart, { KVs } from '../core.js'; +import { User } from '@/models/entities/user.js'; +import { Note } from '@/models/entities/note.js'; +import { Users } from '@/models/index.js'; +import { name, schema } from './entities/per-user-reactions.js'; + +/** + * ユーザーごとのリアクションに関するチャート + */ +// eslint-disable-next-line import/no-default-export +export default class PerUserReactionsChart extends Chart { + constructor() { + super(name, schema, true); + } + + protected async tickMajor(group: string): Promise>> { + return {}; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async update(user: { id: User['id'], host: User['host'] }, note: Note): Promise { + const prefix = Users.isLocalUser(user) ? 'local' : 'remote'; + this.commit({ + [`${prefix}.count`]: 1, + }, note.userId); + } +} diff --git a/packages/backend/src/services/chart/charts/test-grouped.ts b/packages/backend/src/services/chart/charts/test-grouped.ts new file mode 100644 index 0000000000..d01c9fcbd6 --- /dev/null +++ b/packages/backend/src/services/chart/charts/test-grouped.ts @@ -0,0 +1,35 @@ +import Chart, { KVs } from '../core.js'; +import { name, schema } from './entities/test-grouped.js'; + +/** + * For testing + */ +// eslint-disable-next-line import/no-default-export +export default class TestGroupedChart extends Chart { + private total = {} as Record; + + constructor() { + super(name, schema, true); + } + + protected async tickMajor(group: string): Promise>> { + return { + 'foo.total': this.total[group], + }; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async increment(group: string): Promise { + if (this.total[group] == null) this.total[group] = 0; + + this.total[group]++; + + await this.commit({ + 'foo.total': 1, + 'foo.inc': 1, + }, group); + } +} diff --git a/packages/backend/src/services/chart/charts/test-intersection.ts b/packages/backend/src/services/chart/charts/test-intersection.ts new file mode 100644 index 0000000000..88b5a715c1 --- /dev/null +++ b/packages/backend/src/services/chart/charts/test-intersection.ts @@ -0,0 +1,32 @@ +import Chart, { KVs } from '../core.js'; +import { name, schema } from './entities/test-intersection.js'; + +/** + * For testing + */ +// eslint-disable-next-line import/no-default-export +export default class TestIntersectionChart extends Chart { + constructor() { + super(name, schema); + } + + protected async tickMajor(): Promise>> { + return {}; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async addA(key: string): Promise { + await this.commit({ + a: [key], + }); + } + + public async addB(key: string): Promise { + await this.commit({ + b: [key], + }); + } +} diff --git a/packages/backend/src/services/chart/charts/test-unique.ts b/packages/backend/src/services/chart/charts/test-unique.ts new file mode 100644 index 0000000000..d714f1d40c --- /dev/null +++ b/packages/backend/src/services/chart/charts/test-unique.ts @@ -0,0 +1,26 @@ +import Chart, { KVs } from '../core.js'; +import { name, schema } from './entities/test-unique.js'; + +/** + * For testing + */ +// eslint-disable-next-line import/no-default-export +export default class TestUniqueChart extends Chart { + constructor() { + super(name, schema); + } + + protected async tickMajor(): Promise>> { + return {}; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async uniqueIncrement(key: string): Promise { + await this.commit({ + foo: [key], + }); + } +} diff --git a/packages/backend/src/services/chart/charts/test.ts b/packages/backend/src/services/chart/charts/test.ts new file mode 100644 index 0000000000..adb2b18c87 --- /dev/null +++ b/packages/backend/src/services/chart/charts/test.ts @@ -0,0 +1,42 @@ +import Chart, { KVs } from '../core.js'; +import { name, schema } from './entities/test.js'; + +/** + * For testing + */ +// eslint-disable-next-line import/no-default-export +export default class TestChart extends Chart { + public total = 0; // publicにするのはテストのため + + constructor() { + super(name, schema); + } + + protected async tickMajor(): Promise>> { + return { + 'foo.total': this.total, + }; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async increment(): Promise { + this.total++; + + await this.commit({ + 'foo.total': 1, + 'foo.inc': 1, + }); + } + + public async decrement(): Promise { + this.total--; + + await this.commit({ + 'foo.total': -1, + 'foo.dec': 1, + }); + } +} diff --git a/packages/backend/src/services/chart/charts/users.ts b/packages/backend/src/services/chart/charts/users.ts new file mode 100644 index 0000000000..acb16ead87 --- /dev/null +++ b/packages/backend/src/services/chart/charts/users.ts @@ -0,0 +1,41 @@ +import Chart, { KVs } from '../core.js'; +import { Users } from '@/models/index.js'; +import { Not, IsNull } from 'typeorm'; +import { User } from '@/models/entities/user.js'; +import { name, schema } from './entities/users.js'; + +/** + * ユーザー数に関するチャート + */ +// eslint-disable-next-line import/no-default-export +export default class UsersChart extends Chart { + constructor() { + super(name, schema); + } + + protected async tickMajor(): Promise>> { + const [localCount, remoteCount] = await Promise.all([ + Users.countBy({ host: IsNull() }), + Users.countBy({ host: Not(IsNull()) }), + ]); + + return { + 'local.total': localCount, + 'remote.total': remoteCount, + }; + } + + protected async tickMinor(): Promise>> { + return {}; + } + + public async update(user: { id: User['id'], host: User['host'] }, isAdditional: boolean): Promise { + const prefix = Users.isLocalUser(user) ? 'local' : 'remote'; + + await this.commit({ + [`${prefix}.total`]: isAdditional ? 1 : -1, + [`${prefix}.inc`]: isAdditional ? 1 : 0, + [`${prefix}.dec`]: isAdditional ? 0 : 1, + }); + } +} diff --git a/packages/backend/src/core/chart/core.ts b/packages/backend/src/services/chart/core.ts similarity index 83% rename from packages/backend/src/core/chart/core.ts rename to packages/backend/src/services/chart/core.ts index 8d0a89f2d6..2960bac8f7 100644 --- a/packages/backend/src/core/chart/core.ts +++ b/packages/backend/src/services/chart/core.ts @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - /** * チャートエンジン * @@ -10,15 +5,17 @@ */ import * as nestedProperty from 'nested-property'; -import { EntitySchema, LessThan, Between } from 'typeorm'; -import { dateUTC, isTimeSame, isTimeBefore, subtractTime, addTime } from '@/misc/prelude/time.js'; -import type Logger from '@/logger.js'; -import { bindThis } from '@/decorators.js'; -import type { Repository, DataSource } from 'typeorm'; +import Logger from '../logger.js'; +import { EntitySchema, Repository, LessThan, Between } from 'typeorm'; +import { dateUTC, isTimeSame, isTimeBefore, subtractTime, addTime } from '@/prelude/time.js'; +import { getChartInsertLock } from '@/misc/app-lock.js'; +import { db } from '@/db/postgre.js'; -const COLUMN_PREFIX = '___' as const; -const UNIQUE_TEMP_COLUMN_PREFIX = 'unique_temp___' as const; -const COLUMN_DELIMITER = '_' as const; +const logger = new Logger('chart', 'white', process.env.NODE_ENV !== 'test'); + +const columnPrefix = '___' as const; +const uniqueTempColumnPrefix = 'unique_temp___' as const; +const columnDot = '_' as const; type Schema = Record; -type KeyToColumnName = T extends `${infer R1}.${infer R2}` ? `${R1}${typeof COLUMN_DELIMITER}${KeyToColumnName}` : T; +type KeyToColumnName = T extends `${infer R1}.${infer R2}` ? `${R1}${typeof columnDot}${KeyToColumnName}` : T; type Columns = { - [K in keyof S as `${typeof COLUMN_PREFIX}${KeyToColumnName}`]: number; + [K in keyof S as `${typeof columnPrefix}${KeyToColumnName}`]: number; }; type TempColumnsForUnique = { - [K in keyof S as `${typeof UNIQUE_TEMP_COLUMN_PREFIX}${KeyToColumnName}`]: S[K]['uniqueIncrement'] extends true ? string[] : never; + [K in keyof S as `${typeof uniqueTempColumnPrefix}${KeyToColumnName}`]: S[K]['uniqueIncrement'] extends true ? string[] : never; }; type RawRecord = { @@ -115,8 +112,6 @@ export function getJsonSchema(schema: S): ToJsonSchema { - private logger: Logger; - public schema: T; private name: string; @@ -143,20 +138,20 @@ export default abstract class Chart { private static convertSchemaToColumnDefinitions(schema: Schema): Record { const columns = {} as Record; for (const [k, v] of Object.entries(schema)) { - const name = k.replaceAll('.', COLUMN_DELIMITER); + const name = k.replaceAll('.', columnDot); const type = v.range === 'big' ? 'bigint' : v.range === 'small' ? 'smallint' : 'integer'; if (v.uniqueIncrement) { - columns[UNIQUE_TEMP_COLUMN_PREFIX + name] = { + columns[uniqueTempColumnPrefix + name] = { type: 'varchar', array: true, default: '{}', }; - columns[COLUMN_PREFIX + name] = { + columns[columnPrefix + name] = { type, default: 0, }; } else { - columns[COLUMN_PREFIX + name] = { + columns[columnPrefix + name] = { type, default: 0, }; @@ -235,36 +230,23 @@ export default abstract class Chart { }; } - private lock: (key: string) => Promise<() => void>; - - constructor( - db: DataSource, - lock: (key: string) => Promise<() => void>, - logger: Logger, - name: string, - schema: T, - grouped = false, - ) { + constructor(name: string, schema: T, grouped = false) { this.name = name; this.schema = schema; - this.lock = lock; - this.logger = logger; const { hour, day } = Chart.schemaToEntity(name, schema, grouped); this.repositoryForHour = db.getRepository<{ id: number; group?: string | null; date: number; }>(hour); this.repositoryForDay = db.getRepository<{ id: number; group?: string | null; date: number; }>(day); } - @bindThis private convertRawRecord(x: RawRecord): KVs { const kvs = {} as Record; - for (const k of Object.keys(x).filter((k) => k.startsWith(COLUMN_PREFIX)) as (keyof Columns)[]) { - kvs[(k as string).substring(COLUMN_PREFIX.length).split(COLUMN_DELIMITER).join('.')] = x[k] as unknown as number; + for (const k of Object.keys(x).filter((k) => k.startsWith(columnPrefix)) as (keyof Columns)[]) { + kvs[(k as string).substr(columnPrefix.length).split(columnDot).join('.')] = x[k]; } return kvs as KVs; } - @bindThis private getNewLog(latest: KVs | null): KVs { const log = {} as Record; for (const [k, v] of Object.entries(this.schema) as ([keyof typeof this['schema'], this['schema'][string]])[]) { @@ -277,7 +259,6 @@ export default abstract class Chart { return log as KVs; } - @bindThis private getLatestLog(group: string | null, span: 'hour' | 'day'): Promise | null> { const repository = span === 'hour' ? this.repositoryForHour : @@ -297,7 +278,6 @@ export default abstract class Chart { /** * 現在(=今のHour or Day)のログをデータベースから探して、あればそれを返し、なければ作成して返します。 */ - @bindThis private async claimCurrentLog(group: string | null, span: 'hour' | 'day'): Promise> { const [y, m, d, h] = Chart.getCurrentDate(); @@ -343,13 +323,13 @@ export default abstract class Chart { // 初期ログデータを作成 data = this.getNewLog(null); - this.logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): Initial commit created`); + logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): Initial commit created`); } const date = Chart.dateToTimestamp(current); const lockKey = group ? `${this.name}:${date}:${span}:${group}` : `${this.name}:${date}:${span}`; - const unlock = await this.lock(lockKey); + const unlock = await getChartInsertLock(lockKey); try { // ロック内でもう1回チェックする const currentLog = await repository.findOneBy({ @@ -362,8 +342,8 @@ export default abstract class Chart { const columns = {} as Record; for (const [k, v] of Object.entries(data)) { - const name = k.replaceAll('.', COLUMN_DELIMITER); - columns[COLUMN_PREFIX + name] = v; + const name = k.replaceAll('.', columnDot); + columns[columnPrefix + name] = v; } // 新規ログ挿入 @@ -373,7 +353,7 @@ export default abstract class Chart { ...columns, }).then(x => repository.findOneByOrFail(x.identifiers[0])) as RawRecord; - this.logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): New commit created`); + logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): New commit created`); return log; } finally { @@ -390,10 +370,9 @@ export default abstract class Chart { }); } - @bindThis public async save(): Promise { if (this.buffer.length === 0) { - this.logger.info(`${this.name}: Write skipped`); + logger.info(`${this.name}: Write skipped`); return; } @@ -424,16 +403,16 @@ export default abstract class Chart { const queryForDay: Record, number | (() => string)> = {} as any; for (const [k, v] of Object.entries(finalDiffs)) { if (typeof v === 'number') { - const name = COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as string & keyof Columns; + const name = columnPrefix + k.replaceAll('.', columnDot) as keyof Columns; if (v > 0) queryForHour[name] = () => `"${name}" + ${v}`; if (v < 0) queryForHour[name] = () => `"${name}" - ${Math.abs(v)}`; if (v > 0) queryForDay[name] = () => `"${name}" + ${v}`; if (v < 0) queryForDay[name] = () => `"${name}" - ${Math.abs(v)}`; } else if (Array.isArray(v) && v.length > 0) { // ユニークインクリメント - const tempColumnName = UNIQUE_TEMP_COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as string & keyof TempColumnsForUnique; + const tempColumnName = uniqueTempColumnPrefix + k.replaceAll('.', columnDot) as keyof TempColumnsForUnique; // TODO: item をSQLエスケープ - const itemsForHour = v.filter(item => !(logHour[tempColumnName] as unknown as string[]).includes(item)).map(item => `"${item}"`); - const itemsForDay = v.filter(item => !(logDay[tempColumnName] as unknown as string[]).includes(item)).map(item => `"${item}"`); + const itemsForHour = v.filter(item => !logHour[tempColumnName].includes(item)).map(item => `"${item}"`); + const itemsForDay = v.filter(item => !logDay[tempColumnName].includes(item)).map(item => `"${item}"`); if (itemsForHour.length > 0) queryForHour[tempColumnName] = () => `array_cat("${tempColumnName}", '{${itemsForHour.join(',')}}'::varchar[])`; if (itemsForDay.length > 0) queryForDay[tempColumnName] = () => `array_cat("${tempColumnName}", '{${itemsForDay.join(',')}}'::varchar[])`; } @@ -442,10 +421,10 @@ export default abstract class Chart { // bake unique count for (const [k, v] of Object.entries(finalDiffs)) { if (this.schema[k].uniqueIncrement) { - const name = COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as keyof Columns; - const tempColumnName = UNIQUE_TEMP_COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as keyof TempColumnsForUnique; - queryForHour[name] = new Set([...(v as string[]), ...(logHour[tempColumnName] as unknown as string[])]).size; - queryForDay[name] = new Set([...(v as string[]), ...(logDay[tempColumnName] as unknown as string[])]).size; + const name = columnPrefix + k.replaceAll('.', columnDot) as keyof Columns; + const tempColumnName = uniqueTempColumnPrefix + k.replaceAll('.', columnDot) as keyof TempColumnsForUnique; + queryForHour[name] = new Set([...(v as string[]), ...logHour[tempColumnName]]).size; + queryForDay[name] = new Set([...(v as string[]), ...logDay[tempColumnName]]).size; } } @@ -454,18 +433,18 @@ export default abstract class Chart { for (const [k, v] of Object.entries(this.schema)) { const intersection = v.intersection; if (intersection) { - const name = COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as keyof Columns; + const name = columnPrefix + k.replaceAll('.', columnDot) as keyof Columns; const firstKey = intersection[0]; - const firstTempColumnName = UNIQUE_TEMP_COLUMN_PREFIX + firstKey.replaceAll('.', COLUMN_DELIMITER) as keyof TempColumnsForUnique; + const firstTempColumnName = uniqueTempColumnPrefix + firstKey.replaceAll('.', columnDot) as keyof TempColumnsForUnique; const firstValues = finalDiffs[firstKey] as string[] | undefined; - const currentValuesForHour = new Set([...(firstValues ?? []), ...(logHour[firstTempColumnName] as unknown as string[])]); - const currentValuesForDay = new Set([...(firstValues ?? []), ...(logDay[firstTempColumnName] as unknown as string[])]); + const currentValuesForHour = new Set([...(firstValues ?? []), ...logHour[firstTempColumnName]]); + const currentValuesForDay = new Set([...(firstValues ?? []), ...logDay[firstTempColumnName]]); for (let i = 1; i < intersection.length; i++) { const targetKey = intersection[i]; - const targetTempColumnName = UNIQUE_TEMP_COLUMN_PREFIX + targetKey.replaceAll('.', COLUMN_DELIMITER) as keyof TempColumnsForUnique; + const targetTempColumnName = uniqueTempColumnPrefix + targetKey.replaceAll('.', columnDot) as keyof TempColumnsForUnique; const targetValues = finalDiffs[targetKey] as string[] | undefined; - const targetValuesForHour = new Set([...(targetValues ?? []), ...(logHour[targetTempColumnName] as unknown as string[])]); - const targetValuesForDay = new Set([...(targetValues ?? []), ...(logDay[targetTempColumnName] as unknown as string[])]); + const targetValuesForHour = new Set([...(targetValues ?? []), ...logHour[targetTempColumnName]]); + const targetValuesForDay = new Set([...(targetValues ?? []), ...logDay[targetTempColumnName]]); currentValuesForHour.forEach(v => { if (!targetValuesForHour.has(v)) currentValuesForHour.delete(v); }); @@ -492,7 +471,7 @@ export default abstract class Chart { .execute(), ]); - this.logger.info(`${this.name + (logHour.group ? `:${logHour.group}` : '')}: Updated`); + logger.info(`${this.name + (logHour.group ? `:${logHour.group}` : '')}: Updated`); // TODO: この一連の処理が始まった後に新たにbufferに入ったものは消さないようにする this.buffer = this.buffer.filter(q => q.group != null && (q.group !== logHour.group)); @@ -509,13 +488,12 @@ export default abstract class Chart { update(logHour, logDay)))); } - @bindThis public async tick(major: boolean, group: string | null = null): Promise { const data = major ? await this.tickMajor(group) : await this.tickMinor(group); const columns = {} as Record, number>; for (const [k, v] of Object.entries(data) as ([keyof typeof data, number])[]) { - const name = COLUMN_PREFIX + (k as string).replaceAll('.', COLUMN_DELIMITER) as keyof Columns; + const name = columnPrefix + (k as string).replaceAll('.', columnDot) as keyof Columns; columns[name] = v; } @@ -545,12 +523,10 @@ export default abstract class Chart { update(logHour, logDay)); } - @bindThis public resync(group: string | null = null): Promise { return this.tick(true, group); } - @bindThis public async clean(): Promise { const current = dateUTC(Chart.getCurrentDate()); @@ -561,7 +537,7 @@ export default abstract class Chart { const columns = {} as Record, []>; for (const [k, v] of Object.entries(this.schema)) { if (v.uniqueIncrement) { - const name = UNIQUE_TEMP_COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as keyof TempColumnsForUnique; + const name = uniqueTempColumnPrefix + k.replaceAll('.', columnDot) as keyof TempColumnsForUnique; columns[name] = []; } } @@ -586,7 +562,6 @@ export default abstract class Chart { ]); } - @bindThis public async getChartRaw(span: 'hour' | 'day', amount: number, cursor: Date | null, group: string | null = null): Promise> { const [y, m, d, h, _m, _s, _ms] = cursor ? Chart.parseDate(subtractTime(addTime(cursor, 1, span), 1)) : Chart.getCurrentDate(); const [y2, m2, d2, h2] = cursor ? Chart.parseDate(addTime(cursor, 1, span)) : [] as never; @@ -632,7 +607,7 @@ export default abstract class Chart { } // 要求された範囲の最も古い箇所に位置するログが存在しなかったら - } else if (!isTimeSame(new Date(logs.at(-1)!.date * 1000), gt)) { + } else if (!isTimeSame(new Date(logs[logs.length - 1].date * 1000), gt)) { // 要求された範囲の最も古い箇所時点での最も新しいログを持ってきて末尾に追加する // (隙間埋めできないため) const outdatedLog = await repository.findOne({ @@ -691,7 +666,6 @@ export default abstract class Chart { return res; } - @bindThis public async getChart(span: 'hour' | 'day', amount: number, cursor: Date | null, group: string | null = null): Promise>> { const result = await this.getChartRaw(span, amount, cursor, group); const object = {}; diff --git a/packages/backend/src/services/chart/entities.ts b/packages/backend/src/services/chart/entities.ts new file mode 100644 index 0000000000..a9eeabd639 --- /dev/null +++ b/packages/backend/src/services/chart/entities.ts @@ -0,0 +1,39 @@ +import { entity as FederationChart } from './charts/entities/federation.js'; +import { entity as NotesChart } from './charts/entities/notes.js'; +import { entity as UsersChart } from './charts/entities/users.js'; +import { entity as ActiveUsersChart } from './charts/entities/active-users.js'; +import { entity as InstanceChart } from './charts/entities/instance.js'; +import { entity as PerUserNotesChart } from './charts/entities/per-user-notes.js'; +import { entity as DriveChart } from './charts/entities/drive.js'; +import { entity as PerUserReactionsChart } from './charts/entities/per-user-reactions.js'; +import { entity as HashtagChart } from './charts/entities/hashtag.js'; +import { entity as PerUserFollowingChart } from './charts/entities/per-user-following.js'; +import { entity as PerUserDriveChart } from './charts/entities/per-user-drive.js'; +import { entity as ApRequestChart } from './charts/entities/ap-request.js'; + +import { entity as TestChart } from './charts/entities/test.js'; +import { entity as TestGroupedChart } from './charts/entities/test-grouped.js'; +import { entity as TestUniqueChart } from './charts/entities/test-unique.js'; +import { entity as TestIntersectionChart } from './charts/entities/test-intersection.js'; + +export const entities = [ + FederationChart.hour, FederationChart.day, + NotesChart.hour, NotesChart.day, + UsersChart.hour, UsersChart.day, + ActiveUsersChart.hour, ActiveUsersChart.day, + InstanceChart.hour, InstanceChart.day, + PerUserNotesChart.hour, PerUserNotesChart.day, + DriveChart.hour, DriveChart.day, + PerUserReactionsChart.hour, PerUserReactionsChart.day, + HashtagChart.hour, HashtagChart.day, + PerUserFollowingChart.hour, PerUserFollowingChart.day, + PerUserDriveChart.hour, PerUserDriveChart.day, + ApRequestChart.hour, ApRequestChart.day, + + ...(process.env.NODE_ENV === 'test' ? [ + TestChart.hour, TestChart.day, + TestGroupedChart.hour, TestGroupedChart.day, + TestUniqueChart.hour, TestUniqueChart.day, + TestIntersectionChart.hour, TestIntersectionChart.day, + ] : []), +]; diff --git a/packages/backend/src/services/chart/index.ts b/packages/backend/src/services/chart/index.ts new file mode 100644 index 0000000000..8bf2d8f65f --- /dev/null +++ b/packages/backend/src/services/chart/index.ts @@ -0,0 +1,51 @@ +import { beforeShutdown } from '@/misc/before-shutdown.js'; + +import FederationChart from './charts/federation.js'; +import NotesChart from './charts/notes.js'; +import UsersChart from './charts/users.js'; +import ActiveUsersChart from './charts/active-users.js'; +import InstanceChart from './charts/instance.js'; +import PerUserNotesChart from './charts/per-user-notes.js'; +import DriveChart from './charts/drive.js'; +import PerUserReactionsChart from './charts/per-user-reactions.js'; +import HashtagChart from './charts/hashtag.js'; +import PerUserFollowingChart from './charts/per-user-following.js'; +import PerUserDriveChart from './charts/per-user-drive.js'; +import ApRequestChart from './charts/ap-request.js'; + +export const federationChart = new FederationChart(); +export const notesChart = new NotesChart(); +export const usersChart = new UsersChart(); +export const activeUsersChart = new ActiveUsersChart(); +export const instanceChart = new InstanceChart(); +export const perUserNotesChart = new PerUserNotesChart(); +export const driveChart = new DriveChart(); +export const perUserReactionsChart = new PerUserReactionsChart(); +export const hashtagChart = new HashtagChart(); +export const perUserFollowingChart = new PerUserFollowingChart(); +export const perUserDriveChart = new PerUserDriveChart(); +export const apRequestChart = new ApRequestChart(); + +const charts = [ + federationChart, + notesChart, + usersChart, + activeUsersChart, + instanceChart, + perUserNotesChart, + driveChart, + perUserReactionsChart, + hashtagChart, + perUserFollowingChart, + perUserDriveChart, + apRequestChart, +]; + +// 20分おきにメモリ情報をDBに書き込み +setInterval(() => { + for (const chart of charts) { + chart.save(); + } +}, 1000 * 60 * 20); + +beforeShutdown(() => Promise.all(charts.map(chart => chart.save()))); diff --git a/packages/backend/src/services/create-notification.ts b/packages/backend/src/services/create-notification.ts new file mode 100644 index 0000000000..d53a4235b8 --- /dev/null +++ b/packages/backend/src/services/create-notification.ts @@ -0,0 +1,62 @@ +import { publishMainStream } from '@/services/stream.js'; +import { pushNotification } from '@/services/push-notification.js'; +import { Notifications, Mutings, UserProfiles, Users } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { User } from '@/models/entities/user.js'; +import { Notification } from '@/models/entities/notification.js'; +import { sendEmailNotification } from './send-email-notification.js'; + +export async function createNotification( + notifieeId: User['id'], + type: Notification['type'], + data: Partial +) { + if (data.notifierId && (notifieeId === data.notifierId)) { + return null; + } + + const profile = await UserProfiles.findOneBy({ userId: notifieeId }); + + const isMuted = profile?.mutingNotificationTypes.includes(type); + + // Create notification + const notification = await Notifications.insert({ + id: genId(), + createdAt: new Date(), + notifieeId: notifieeId, + type: type, + // 相手がこの通知をミュートしているようなら、既読を予めつけておく + isRead: isMuted, + ...data, + } as Partial) + .then(x => Notifications.findOneByOrFail(x.identifiers[0])); + + const packed = await Notifications.pack(notification, {}); + + // Publish notification event + publishMainStream(notifieeId, 'notification', packed); + + // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する + setTimeout(async () => { + const fresh = await Notifications.findOneBy({ id: notification.id }); + if (fresh == null) return; // 既に削除されているかもしれない + if (fresh.isRead) return; + + //#region ただしミュートしているユーザーからの通知なら無視 + const mutings = await Mutings.findBy({ + muterId: notifieeId, + }); + if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) { + return; + } + //#endregion + + publishMainStream(notifieeId, 'unreadNotification', packed); + pushNotification(notifieeId, 'notification', packed); + + if (type === 'follow') sendEmailNotification.follow(notifieeId, await Users.findOneByOrFail({ id: data.notifierId! })); + if (type === 'receiveFollowRequest') sendEmailNotification.receiveFollowRequest(notifieeId, await Users.findOneByOrFail({ id: data.notifierId! })); + }, 2000); + + return notification; +} diff --git a/packages/backend/src/services/create-system-user.ts b/packages/backend/src/services/create-system-user.ts new file mode 100644 index 0000000000..bae91ec4c3 --- /dev/null +++ b/packages/backend/src/services/create-system-user.ts @@ -0,0 +1,68 @@ +import bcrypt from 'bcryptjs'; +import { v4 as uuid } from 'uuid'; +import generateNativeUserToken from '../server/api/common/generate-native-user-token.js'; +import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; +import { User } from '@/models/entities/user.js'; +import { UserProfile } from '@/models/entities/user-profile.js'; +import { IsNull } from 'typeorm'; +import { genId } from '@/misc/gen-id.js'; +import { UserKeypair } from '@/models/entities/user-keypair.js'; +import { UsedUsername } from '@/models/entities/used-username.js'; +import { db } from '@/db/postgre.js'; + +export async function createSystemUser(username: string) { + const password = uuid(); + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(password, salt); + + // Generate secret + const secret = generateNativeUserToken(); + + const keyPair = await genRsaKeyPair(4096); + + let account!: User; + + // Start transaction + await db.transaction(async transactionalEntityManager => { + const exist = await transactionalEntityManager.findOneBy(User, { + usernameLower: username.toLowerCase(), + host: IsNull(), + }); + + if (exist) throw new Error('the user is already exists'); + + account = await transactionalEntityManager.insert(User, { + id: genId(), + createdAt: new Date(), + username: username, + usernameLower: username.toLowerCase(), + host: null, + token: secret, + isAdmin: false, + isLocked: true, + isExplorable: false, + isBot: true, + }).then(x => transactionalEntityManager.findOneByOrFail(User, x.identifiers[0])); + + await transactionalEntityManager.insert(UserKeypair, { + publicKey: keyPair.publicKey, + privateKey: keyPair.privateKey, + userId: account.id, + }); + + await transactionalEntityManager.insert(UserProfile, { + userId: account.id, + autoAcceptFollowed: false, + password: hash, + }); + + await transactionalEntityManager.insert(UsedUsername, { + createdAt: new Date(), + username: username.toLowerCase(), + }); + }); + + return account; +} diff --git a/packages/backend/src/services/delete-account.ts b/packages/backend/src/services/delete-account.ts new file mode 100644 index 0000000000..0fdceb671b --- /dev/null +++ b/packages/backend/src/services/delete-account.ts @@ -0,0 +1,23 @@ +import { Users } from '@/models/index.js'; +import { createDeleteAccountJob } from '@/queue/index.js'; +import { publishUserEvent } from './stream.js'; +import { doPostSuspend } from './suspend-user.js'; + +export async function deleteAccount(user: { + id: string; + host: string | null; +}): Promise { + // 物理削除する前にDelete activityを送信する + await doPostSuspend(user).catch(e => {}); + + createDeleteAccountJob(user, { + soft: false, + }); + + await Users.update(user.id, { + isDeleted: true, + }); + + // Terminate streaming + publishUserEvent(user.id, 'terminate', {}); +} diff --git a/packages/backend/src/services/detect-sensitive.ts b/packages/backend/src/services/detect-sensitive.ts new file mode 100644 index 0000000000..2ade39d524 --- /dev/null +++ b/packages/backend/src/services/detect-sensitive.ts @@ -0,0 +1,48 @@ +import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import * as nsfw from 'nsfwjs'; +import si from 'systeminformation'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +const REQUIRED_CPU_FLAGS = ['avx2', 'fma']; +let isSupportedCpu: undefined | boolean = undefined; + +let model: nsfw.NSFWJS; + +export async function detectSensitive(path: string): Promise { + try { + if (isSupportedCpu === undefined) { + const cpuFlags = await getCpuFlags(); + isSupportedCpu = REQUIRED_CPU_FLAGS.every(required => cpuFlags.includes(required)); + } + + if (!isSupportedCpu) { + console.error('This CPU cannot use TensorFlow.'); + return null; + } + + const tf = await import('@tensorflow/tfjs-node'); + + if (model == null) model = await nsfw.load(`file://${_dirname}/../../nsfw-model/`, { size: 299 }); + + const buffer = await fs.promises.readFile(path); + const image = await tf.node.decodeImage(buffer, 3) as any; + try { + const predictions = await model.classify(image); + return predictions; + } finally { + image.dispose(); + } + } catch (err) { + console.error(err); + return null; + } +} + +async function getCpuFlags(): Promise { + const str = await si.cpuFlags(); + return str.split(/\s+/); +} diff --git a/packages/backend/src/services/drive/add-file.ts b/packages/backend/src/services/drive/add-file.ts new file mode 100644 index 0000000000..709db88f2f --- /dev/null +++ b/packages/backend/src/services/drive/add-file.ts @@ -0,0 +1,540 @@ +import * as fs from 'node:fs'; + +import { v4 as uuid } from 'uuid'; + +import S3 from 'aws-sdk/clients/s3.js'; +import sharp from 'sharp'; +import { IsNull } from 'typeorm'; +import { publishMainStream, publishDriveStream } from '@/services/stream.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import { getFileInfo } from '@/misc/get-file-info.js'; +import { DriveFiles, DriveFolders, Users, Instances, UserProfiles } from '@/models/index.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { IRemoteUser, User } from '@/models/entities/user.js'; +import { driveChart, perUserDriveChart, instanceChart } from '@/services/chart/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { getS3 } from './s3.js'; +import { InternalStorage } from './internal-storage.js'; +import { IImage, convertSharpToJpeg, convertSharpToWebp, convertSharpToPng } from './image-processor.js'; +import { driveLogger } from './logger.js'; +import { GenerateVideoThumbnail } from './generate-video-thumbnail.js'; +import { deleteFile } from './delete-file.js'; + +const logger = driveLogger.createSubLogger('register', 'yellow'); + +/*** + * Save file + * @param path Path for original + * @param name Name for original + * @param type Content-Type for original + * @param hash Hash for original + * @param size Size for original + */ +async function save(file: DriveFile, path: string, name: string, type: string, hash: string, size: number): Promise { + // thunbnail, webpublic を必要なら生成 + const alts = await generateAlts(path, type, !file.uri); + + const meta = await fetchMeta(); + + if (meta.useObjectStorage) { + //#region ObjectStorage params + let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) || ['']); + + if (ext === '') { + if (type === 'image/jpeg') ext = '.jpg'; + if (type === 'image/png') ext = '.png'; + if (type === 'image/webp') ext = '.webp'; + if (type === 'image/apng') ext = '.apng'; + if (type === 'image/vnd.mozilla.apng') ext = '.apng'; + } + + // 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、 + // 許可されているファイル形式でしか拡張子をつけない + if (!FILE_TYPE_BROWSERSAFE.includes(type)) { + ext = ''; + } + + const baseUrl = meta.objectStorageBaseUrl + || `${ meta.objectStorageUseSSL ? 'https' : 'http' }://${ meta.objectStorageEndpoint }${ meta.objectStoragePort ? `:${meta.objectStoragePort}` : '' }/${ meta.objectStorageBucket }`; + + // for original + const key = `${meta.objectStoragePrefix}/${uuid()}${ext}`; + const url = `${ baseUrl }/${ key }`; + + // for alts + let webpublicKey: string | null = null; + let webpublicUrl: string | null = null; + let thumbnailKey: string | null = null; + let thumbnailUrl: string | null = null; + //#endregion + + //#region Uploads + logger.info(`uploading original: ${key}`); + const uploads = [ + upload(key, fs.createReadStream(path), type, name), + ]; + + if (alts.webpublic) { + webpublicKey = `${meta.objectStoragePrefix}/webpublic-${uuid()}.${alts.webpublic.ext}`; + webpublicUrl = `${ baseUrl }/${ webpublicKey }`; + + logger.info(`uploading webpublic: ${webpublicKey}`); + uploads.push(upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name)); + } + + if (alts.thumbnail) { + thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${uuid()}.${alts.thumbnail.ext}`; + thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; + + logger.info(`uploading thumbnail: ${thumbnailKey}`); + uploads.push(upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type)); + } + + await Promise.all(uploads); + //#endregion + + file.url = url; + file.thumbnailUrl = thumbnailUrl; + file.webpublicUrl = webpublicUrl; + file.accessKey = key; + file.thumbnailAccessKey = thumbnailKey; + file.webpublicAccessKey = webpublicKey; + file.webpublicType = alts.webpublic?.type ?? null; + file.name = name; + file.type = type; + file.md5 = hash; + file.size = size; + file.storedInternal = false; + + return await DriveFiles.insert(file).then(x => DriveFiles.findOneByOrFail(x.identifiers[0])); + } else { // use internal storage + const accessKey = uuid(); + const thumbnailAccessKey = 'thumbnail-' + uuid(); + const webpublicAccessKey = 'webpublic-' + uuid(); + + const url = InternalStorage.saveFromPath(accessKey, path); + + let thumbnailUrl: string | null = null; + let webpublicUrl: string | null = null; + + if (alts.thumbnail) { + thumbnailUrl = InternalStorage.saveFromBuffer(thumbnailAccessKey, alts.thumbnail.data); + logger.info(`thumbnail stored: ${thumbnailAccessKey}`); + } + + if (alts.webpublic) { + webpublicUrl = InternalStorage.saveFromBuffer(webpublicAccessKey, alts.webpublic.data); + logger.info(`web stored: ${webpublicAccessKey}`); + } + + file.storedInternal = true; + file.url = url; + file.thumbnailUrl = thumbnailUrl; + file.webpublicUrl = webpublicUrl; + file.accessKey = accessKey; + file.thumbnailAccessKey = thumbnailAccessKey; + file.webpublicAccessKey = webpublicAccessKey; + file.webpublicType = alts.webpublic?.type ?? null; + file.name = name; + file.type = type; + file.md5 = hash; + file.size = size; + + return await DriveFiles.insert(file).then(x => DriveFiles.findOneByOrFail(x.identifiers[0])); + } +} + +/** + * Generate webpublic, thumbnail, etc + * @param path Path for original + * @param type Content-Type for original + * @param generateWeb Generate webpublic or not + */ +export async function generateAlts(path: string, type: string, generateWeb: boolean) { + if (type.startsWith('video/')) { + try { + const thumbnail = await GenerateVideoThumbnail(path); + return { + webpublic: null, + thumbnail, + }; + } catch (err) { + logger.warn(`GenerateVideoThumbnail failed: ${err}`); + return { + webpublic: null, + thumbnail: null, + }; + } + } + + if (!['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml'].includes(type)) { + logger.debug('web image and thumbnail not created (not an required file)'); + return { + webpublic: null, + thumbnail: null, + }; + } + + let img: sharp.Sharp | null = null; + let satisfyWebpublic: boolean; + + try { + img = sharp(path); + const metadata = await img.metadata(); + const isAnimated = metadata.pages && metadata.pages > 1; + + // skip animated + if (isAnimated) { + return { + webpublic: null, + thumbnail: null, + }; + } + + satisfyWebpublic = !!( + type !== 'image/svg+xml' && type !== 'image/webp' && + !(metadata.exif || metadata.iptc || metadata.xmp || metadata.tifftagPhotoshop) && + metadata.width && metadata.width <= 2048 && + metadata.height && metadata.height <= 2048 + ); + } catch (err) { + logger.warn(`sharp failed: ${err}`); + return { + webpublic: null, + thumbnail: null, + }; + } + + // #region webpublic + let webpublic: IImage | null = null; + + if (generateWeb && !satisfyWebpublic) { + logger.info('creating web image'); + + try { + if (['image/jpeg', 'image/webp'].includes(type)) { + webpublic = await convertSharpToJpeg(img, 2048, 2048); + } else if (['image/png'].includes(type)) { + webpublic = await convertSharpToPng(img, 2048, 2048); + } else if (['image/svg+xml'].includes(type)) { + webpublic = await convertSharpToPng(img, 2048, 2048); + } else { + logger.debug('web image not created (not an required image)'); + } + } catch (err) { + logger.warn('web image not created (an error occured)', err as Error); + } + } else { + if (satisfyWebpublic) logger.info('web image not created (original satisfies webpublic)'); + else logger.info('web image not created (from remote)'); + } + // #endregion webpublic + + // #region thumbnail + let thumbnail: IImage | null = null; + + try { + if (['image/jpeg', 'image/webp', 'image/png', 'image/svg+xml'].includes(type)) { + thumbnail = await convertSharpToWebp(img, 498, 280); + } else { + logger.debug('thumbnail not created (not an required file)'); + } + } catch (err) { + logger.warn('thumbnail not created (an error occured)', err as Error); + } + // #endregion thumbnail + + return { + webpublic, + thumbnail, + }; +} + +/** + * Upload to ObjectStorage + */ +async function upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) { + if (type === 'image/apng') type = 'image/png'; + if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream'; + + const meta = await fetchMeta(); + + const params = { + Bucket: meta.objectStorageBucket, + Key: key, + Body: stream, + ContentType: type, + CacheControl: 'max-age=31536000, immutable', + } as S3.PutObjectRequest; + + if (filename) params.ContentDisposition = contentDisposition('inline', filename); + if (meta.objectStorageSetPublicRead) params.ACL = 'public-read'; + + const s3 = getS3(meta); + + const upload = s3.upload(params, { + partSize: s3.endpoint.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024, + }); + + const result = await upload.promise(); + if (result) logger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`); +} + +async function deleteOldFile(user: IRemoteUser) { + const q = DriveFiles.createQueryBuilder('file') + .where('file.userId = :userId', { userId: user.id }) + .andWhere('file.isLink = FALSE'); + + if (user.avatarId) { + q.andWhere('file.id != :avatarId', { avatarId: user.avatarId }); + } + + if (user.bannerId) { + q.andWhere('file.id != :bannerId', { bannerId: user.bannerId }); + } + + q.orderBy('file.id', 'ASC'); + + const oldFile = await q.getOne(); + + if (oldFile) { + deleteFile(oldFile, true); + } +} + +type AddFileArgs = { + /** User who wish to add file */ + user: { id: User['id']; host: User['host']; driveCapacityOverrideMb: User['driveCapacityOverrideMb'] } | null; + /** File path */ + path: string; + /** Name */ + name?: string | null; + /** Comment */ + comment?: string | null; + /** Folder ID */ + folderId?: any; + /** If set to true, forcibly upload the file even if there is a file with the same hash. */ + force?: boolean; + /** Do not save file to local */ + isLink?: boolean; + /** URL of source (URLからアップロードされた場合(ローカル/リモート)の元URL) */ + url?: string | null; + /** URL of source (リモートインスタンスのURLからアップロードされた場合の元URL) */ + uri?: string | null; + /** Mark file as sensitive */ + sensitive?: boolean | null; + + requestIp?: string | null; + requestHeaders?: Record | null; +}; + +/** + * Add file to drive + * + */ +export async function addFile({ + user, + path, + name = null, + comment = null, + folderId = null, + force = false, + isLink = false, + url = null, + uri = null, + sensitive = null, + requestIp = null, + requestHeaders = null, +}: AddFileArgs): Promise { + let skipNsfwCheck = false; + const instance = await fetchMeta(); + if (user == null) skipNsfwCheck = true; + if (instance.sensitiveMediaDetection === 'none') skipNsfwCheck = true; + if (user && instance.sensitiveMediaDetection === 'local' && Users.isRemoteUser(user)) skipNsfwCheck = true; + if (user && instance.sensitiveMediaDetection === 'remote' && Users.isLocalUser(user)) skipNsfwCheck = true; + + const info = await getFileInfo(path, { + skipSensitiveDetection: skipNsfwCheck, + sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる + instance.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 : + instance.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 : + instance.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 : + instance.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 : + 0.5, + sensitiveThresholdForPorn: 0.75, + enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, + }); + logger.info(`${JSON.stringify(info)}`); + + // 現状 false positive が多すぎて実用に耐えない + //if (info.porn && instance.disallowUploadWhenPredictedAsPorn) { + // throw new IdentifiableError('282f77bf-5816-4f72-9264-aa14d8261a21', 'Detected as porn.'); + //} + + // detect name + const detectedName = name || (info.type.ext ? `untitled.${info.type.ext}` : 'untitled'); + + if (user && !force) { + // Check if there is a file with the same hash + const much = await DriveFiles.findOneBy({ + md5: info.md5, + userId: user.id, + }); + + if (much) { + logger.info(`file with same hash is found: ${much.id}`); + return much; + } + } + + //#region Check drive usage + if (user && !isLink) { + const usage = await DriveFiles.calcDriveUsageOf(user); + const u = await Users.findOneBy({ id: user.id }); + + const instance = await fetchMeta(); + let driveCapacity = 1024 * 1024 * (Users.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb); + + if (Users.isLocalUser(user) && u?.driveCapacityOverrideMb != null) { + driveCapacity = 1024 * 1024 * u.driveCapacityOverrideMb; + logger.debug('drive capacity override applied'); + logger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`); + } + + logger.debug(`drive usage is ${usage} (max: ${driveCapacity})`); + + // If usage limit exceeded + if (usage + info.size > driveCapacity) { + if (Users.isLocalUser(user)) { + throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.'); + } else { + // (アバターまたはバナーを含まず)最も古いファイルを削除する + deleteOldFile(await Users.findOneByOrFail({ id: user.id }) as IRemoteUser); + } + } + } + //#endregion + + const fetchFolder = async () => { + if (!folderId) { + return null; + } + + const driveFolder = await DriveFolders.findOneBy({ + id: folderId, + userId: user ? user.id : IsNull(), + }); + + if (driveFolder == null) throw new Error('folder-not-found'); + + return driveFolder; + }; + + const properties: { + width?: number; + height?: number; + orientation?: number; + } = {}; + + if (info.width) { + properties['width'] = info.width; + properties['height'] = info.height; + } + if (info.orientation != null) { + properties['orientation'] = info.orientation; + } + + const profile = user ? await UserProfiles.findOneBy({ userId: user.id }) : null; + + const folder = await fetchFolder(); + + let file = new DriveFile(); + file.id = genId(); + file.createdAt = new Date(); + file.userId = user ? user.id : null; + file.userHost = user ? user.host : null; + file.folderId = folder !== null ? folder.id : null; + file.comment = comment; + file.properties = properties; + file.blurhash = info.blurhash || null; + file.isLink = isLink; + file.requestIp = requestIp; + file.requestHeaders = requestHeaders; + file.maybeSensitive = info.sensitive; + file.maybePorn = info.porn; + file.isSensitive = user + ? Users.isLocalUser(user) && profile!.alwaysMarkNsfw ? true : + (sensitive !== null && sensitive !== undefined) + ? sensitive + : false + : false; + + if (info.sensitive && profile!.autoSensitive) file.isSensitive = true; + if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true; + + if (url !== null) { + file.src = url; + + if (isLink) { + file.url = url; + // ローカルプロキシ用 + file.accessKey = uuid(); + file.thumbnailAccessKey = 'thumbnail-' + uuid(); + file.webpublicAccessKey = 'webpublic-' + uuid(); + } + } + + if (uri !== null) { + file.uri = uri; + } + + if (isLink) { + try { + file.size = 0; + file.md5 = info.md5; + file.name = detectedName; + file.type = info.type.mime; + file.storedInternal = false; + + file = await DriveFiles.insert(file).then(x => DriveFiles.findOneByOrFail(x.identifiers[0])); + } catch (err) { + // duplicate key error (when already registered) + if (isDuplicateKeyValueError(err)) { + logger.info(`already registered ${file.uri}`); + + file = await DriveFiles.findOneBy({ + uri: file.uri!, + userId: user ? user.id : IsNull(), + }) as DriveFile; + } else { + logger.error(err as Error); + throw err; + } + } + } else { + file = await (save(file, path, detectedName, info.type.mime, info.md5, info.size)); + } + + logger.succ(`drive file has been created ${file.id}`); + + if (user) { + DriveFiles.pack(file, { self: true }).then(packedFile => { + // Publish driveFileCreated event + publishMainStream(user.id, 'driveFileCreated', packedFile); + publishDriveStream(user.id, 'fileCreated', packedFile); + }); + } + + // 統計を更新 + driveChart.update(file, true); + perUserDriveChart.update(file, true); + if (file.userHost !== null) { + instanceChart.updateDrive(file, true); + } + + return file; +} diff --git a/packages/backend/src/services/drive/delete-file.ts b/packages/backend/src/services/drive/delete-file.ts new file mode 100644 index 0000000000..4816a3a31b --- /dev/null +++ b/packages/backend/src/services/drive/delete-file.ts @@ -0,0 +1,101 @@ +import { DriveFile } from '@/models/entities/drive-file.js'; +import { InternalStorage } from './internal-storage.js'; +import { DriveFiles, Instances } from '@/models/index.js'; +import { driveChart, perUserDriveChart, instanceChart } from '@/services/chart/index.js'; +import { createDeleteObjectStorageFileJob } from '@/queue/index.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { getS3 } from './s3.js'; +import { v4 as uuid } from 'uuid'; + +export async function deleteFile(file: DriveFile, isExpired = false) { + if (file.storedInternal) { + InternalStorage.del(file.accessKey!); + + if (file.thumbnailUrl) { + InternalStorage.del(file.thumbnailAccessKey!); + } + + if (file.webpublicUrl) { + InternalStorage.del(file.webpublicAccessKey!); + } + } else if (!file.isLink) { + createDeleteObjectStorageFileJob(file.accessKey!); + + if (file.thumbnailUrl) { + createDeleteObjectStorageFileJob(file.thumbnailAccessKey!); + } + + if (file.webpublicUrl) { + createDeleteObjectStorageFileJob(file.webpublicAccessKey!); + } + } + + postProcess(file, isExpired); +} + +export async function deleteFileSync(file: DriveFile, isExpired = false) { + if (file.storedInternal) { + InternalStorage.del(file.accessKey!); + + if (file.thumbnailUrl) { + InternalStorage.del(file.thumbnailAccessKey!); + } + + if (file.webpublicUrl) { + InternalStorage.del(file.webpublicAccessKey!); + } + } else if (!file.isLink) { + const promises = []; + + promises.push(deleteObjectStorageFile(file.accessKey!)); + + if (file.thumbnailUrl) { + promises.push(deleteObjectStorageFile(file.thumbnailAccessKey!)); + } + + if (file.webpublicUrl) { + promises.push(deleteObjectStorageFile(file.webpublicAccessKey!)); + } + + await Promise.all(promises); + } + + postProcess(file, isExpired); +} + +async function postProcess(file: DriveFile, isExpired = false) { + // リモートファイル期限切れ削除後は直リンクにする + if (isExpired && file.userHost !== null && file.uri != null) { + DriveFiles.update(file.id, { + isLink: true, + url: file.uri, + thumbnailUrl: null, + webpublicUrl: null, + storedInternal: false, + // ローカルプロキシ用 + accessKey: uuid(), + thumbnailAccessKey: 'thumbnail-' + uuid(), + webpublicAccessKey: 'webpublic-' + uuid(), + }); + } else { + DriveFiles.delete(file.id); + } + + // 統計を更新 + driveChart.update(file, false); + perUserDriveChart.update(file, false); + if (file.userHost !== null) { + instanceChart.updateDrive(file, false); + } +} + +export async function deleteObjectStorageFile(key: string) { + const meta = await fetchMeta(); + + const s3 = getS3(meta); + + await s3.deleteObject({ + Bucket: meta.objectStorageBucket!, + Key: key, + }).promise(); +} diff --git a/packages/backend/src/services/drive/generate-video-thumbnail.ts b/packages/backend/src/services/drive/generate-video-thumbnail.ts new file mode 100644 index 0000000000..6e6666481d --- /dev/null +++ b/packages/backend/src/services/drive/generate-video-thumbnail.ts @@ -0,0 +1,29 @@ +import * as fs from 'node:fs'; +import { createTempDir } from '@/misc/create-temp.js'; +import { IImage, convertToJpeg } from './image-processor.js'; +import FFmpeg from 'fluent-ffmpeg'; + +export async function GenerateVideoThumbnail(source: string): Promise { + const [dir, cleanup] = await createTempDir(); + + try { + await new Promise((res, rej) => { + FFmpeg({ + source, + }) + .on('end', res) + .on('error', rej) + .screenshot({ + folder: dir, + filename: 'out.png', // must have .png extension + count: 1, + timestamps: ['5%'], + }); + }); + + // JPEGに変換 (Webpでもいいが、MastodonはWebpをサポートせず表示できなくなる) + return await convertToJpeg(`${dir}/out.png`, 498, 280); + } finally { + cleanup(); + } +} diff --git a/packages/backend/src/services/drive/image-processor.ts b/packages/backend/src/services/drive/image-processor.ts new file mode 100644 index 0000000000..2c564ea595 --- /dev/null +++ b/packages/backend/src/services/drive/image-processor.ts @@ -0,0 +1,87 @@ +import sharp from 'sharp'; + +export type IImage = { + data: Buffer; + ext: string | null; + type: string; +}; + +/** + * Convert to JPEG + * with resize, remove metadata, resolve orientation, stop animation + */ +export async function convertToJpeg(path: string, width: number, height: number): Promise { + return convertSharpToJpeg(await sharp(path), width, height); +} + +export async function convertSharpToJpeg(sharp: sharp.Sharp, width: number, height: number): Promise { + const data = await sharp + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true, + }) + .rotate() + .jpeg({ + quality: 85, + progressive: true, + }) + .toBuffer(); + + return { + data, + ext: 'jpg', + type: 'image/jpeg', + }; +} + +/** + * Convert to WebP + * with resize, remove metadata, resolve orientation, stop animation + */ +export async function convertToWebp(path: string, width: number, height: number, quality: number = 85): Promise { + return convertSharpToWebp(await sharp(path), width, height, quality); +} + +export async function convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number, quality: number = 85): Promise { + const data = await sharp + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true, + }) + .rotate() + .webp({ + quality, + }) + .toBuffer(); + + return { + data, + ext: 'webp', + type: 'image/webp', + }; +} + +/** + * Convert to PNG + * with resize, remove metadata, resolve orientation, stop animation + */ +export async function convertToPng(path: string, width: number, height: number): Promise { + return convertSharpToPng(await sharp(path), width, height); +} + +export async function convertSharpToPng(sharp: sharp.Sharp, width: number, height: number): Promise { + const data = await sharp + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true, + }) + .rotate() + .png() + .toBuffer(); + + return { + data, + ext: 'png', + type: 'image/png', + }; +} diff --git a/packages/backend/src/services/drive/internal-storage.ts b/packages/backend/src/services/drive/internal-storage.ts new file mode 100644 index 0000000000..8f76c81ca3 --- /dev/null +++ b/packages/backend/src/services/drive/internal-storage.ts @@ -0,0 +1,34 @@ +import * as fs from 'node:fs'; +import * as Path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import config from '@/config/index.js'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +export class InternalStorage { + private static readonly path = Path.resolve(_dirname, '../../../../../files'); + + public static resolvePath = (key: string) => Path.resolve(InternalStorage.path, key); + + public static read(key: string) { + return fs.createReadStream(InternalStorage.resolvePath(key)); + } + + public static saveFromPath(key: string, srcPath: string) { + fs.mkdirSync(InternalStorage.path, { recursive: true }); + fs.copyFileSync(srcPath, InternalStorage.resolvePath(key)); + return `${config.url}/files/${key}`; + } + + public static saveFromBuffer(key: string, data: Buffer) { + fs.mkdirSync(InternalStorage.path, { recursive: true }); + fs.writeFileSync(InternalStorage.resolvePath(key), data); + return `${config.url}/files/${key}`; + } + + public static del(key: string) { + fs.unlink(InternalStorage.resolvePath(key), () => {}); + } +} diff --git a/packages/backend/src/services/drive/logger.ts b/packages/backend/src/services/drive/logger.ts new file mode 100644 index 0000000000..917a8317e2 --- /dev/null +++ b/packages/backend/src/services/drive/logger.ts @@ -0,0 +1,3 @@ +import Logger from '../logger.js'; + +export const driveLogger = new Logger('drive', 'blue'); diff --git a/packages/backend/src/services/drive/s3.ts b/packages/backend/src/services/drive/s3.ts new file mode 100644 index 0000000000..80e34be956 --- /dev/null +++ b/packages/backend/src/services/drive/s3.ts @@ -0,0 +1,24 @@ +import { URL } from 'node:url'; +import S3 from 'aws-sdk/clients/s3.js'; +import { Meta } from '@/models/entities/meta.js'; +import { getAgentByUrl } from '@/misc/fetch.js'; + +export function getS3(meta: Meta) { + const u = meta.objectStorageEndpoint != null + ? `${meta.objectStorageUseSSL ? 'https://' : 'http://'}${meta.objectStorageEndpoint}` + : `${meta.objectStorageUseSSL ? 'https://' : 'http://'}example.net`; + + return new S3({ + endpoint: meta.objectStorageEndpoint || undefined, + accessKeyId: meta.objectStorageAccessKey!, + secretAccessKey: meta.objectStorageSecretKey!, + region: meta.objectStorageRegion || undefined, + sslEnabled: meta.objectStorageUseSSL, + s3ForcePathStyle: !meta.objectStorageEndpoint // AWS with endPoint omitted + ? false + : meta.objectStorageS3ForcePathStyle, + httpOptions: { + agent: getAgentByUrl(new URL(u), !meta.objectStorageUseProxy), + }, + }); +} diff --git a/packages/backend/src/services/drive/upload-from-url.ts b/packages/backend/src/services/drive/upload-from-url.ts new file mode 100644 index 0000000000..3c5e1aa5c1 --- /dev/null +++ b/packages/backend/src/services/drive/upload-from-url.ts @@ -0,0 +1,68 @@ +import { URL } from 'node:url'; +import { User } from '@/models/entities/user.js'; +import { createTemp } from '@/misc/create-temp.js'; +import { downloadUrl } from '@/misc/download-url.js'; +import { DriveFolder } from '@/models/entities/drive-folder.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { DriveFiles } from '@/models/index.js'; +import { driveLogger } from './logger.js'; +import { addFile } from './add-file.js'; + +const logger = driveLogger.createSubLogger('downloader'); + +type Args = { + url: string; + user: { id: User['id']; host: User['host'] } | null; + folderId?: DriveFolder['id'] | null; + uri?: string | null; + sensitive?: boolean; + force?: boolean; + isLink?: boolean; + comment?: string | null; + requestIp?: string | null; + requestHeaders?: Record | null; +}; + +export async function uploadFromUrl({ + url, + user, + folderId = null, + uri = null, + sensitive = false, + force = false, + isLink = false, + comment = null, + requestIp = null, + requestHeaders = null, +}: Args): Promise { + let name = new URL(url).pathname.split('/').pop() || null; + if (name == null || !DriveFiles.validateFileName(name)) { + name = null; + } + + // If the comment is same as the name, skip comment + // (image.name is passed in when receiving attachment) + if (comment !== null && name === comment) { + comment = null; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + try { + // write content at URL to temp file + await downloadUrl(url, path); + + const driveFile = await addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders }); + logger.succ(`Got: ${driveFile.id}`); + return driveFile!; + } catch (e) { + logger.error(`Failed to create drive file: ${e}`, { + url: url, + e: e, + }); + throw e; + } finally { + cleanup(); + } +} diff --git a/packages/backend/src/services/fetch-instance-metadata.ts b/packages/backend/src/services/fetch-instance-metadata.ts new file mode 100644 index 0000000000..ee1245132a --- /dev/null +++ b/packages/backend/src/services/fetch-instance-metadata.ts @@ -0,0 +1,268 @@ +import { DOMWindow, JSDOM } from 'jsdom'; +import fetch from 'node-fetch'; +import tinycolor from 'tinycolor2'; +import { getJson, getHtml, getAgentByUrl } from '@/misc/fetch.js'; +import { Instance } from '@/models/entities/instance.js'; +import { Instances } from '@/models/index.js'; +import { getFetchInstanceMetadataLock } from '@/misc/app-lock.js'; +import Logger from './logger.js'; +import { URL } from 'node:url'; + +const logger = new Logger('metadata', 'cyan'); + +export async function fetchInstanceMetadata(instance: Instance, force = false): Promise { + const unlock = await getFetchInstanceMetadataLock(instance.host); + + if (!force) { + const _instance = await Instances.findOneBy({ host: instance.host }); + const now = Date.now(); + if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) { + unlock(); + return; + } + } + + logger.info(`Fetching metadata of ${instance.host} ...`); + + try { + const [info, dom, manifest] = await Promise.all([ + fetchNodeinfo(instance).catch(() => null), + fetchDom(instance).catch(() => null), + fetchManifest(instance).catch(() => null), + ]); + + const [favicon, icon, themeColor, name, description] = await Promise.all([ + fetchFaviconUrl(instance, dom).catch(() => null), + fetchIconUrl(instance, dom, manifest).catch(() => null), + getThemeColor(info, dom, manifest).catch(() => null), + getSiteName(info, dom, manifest).catch(() => null), + getDescription(info, dom, manifest).catch(() => null), + ]); + + logger.succ(`Successfuly fetched metadata of ${instance.host}`); + + const updates = { + infoUpdatedAt: new Date(), + } as Record; + + if (info) { + updates.softwareName = info.software?.name.toLowerCase(); + updates.softwareVersion = info.software?.version; + updates.openRegistrations = info.openRegistrations; + updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name || null) : null : null; + updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email || null) : null : null; + } + + if (name) updates.name = name; + if (description) updates.description = description; + if (icon || favicon) updates.iconUrl = icon || favicon; + if (favicon) updates.faviconUrl = favicon; + if (themeColor) updates.themeColor = themeColor; + + await Instances.update(instance.id, updates); + + logger.succ(`Successfuly updated metadata of ${instance.host}`); + } catch (e) { + logger.error(`Failed to update metadata of ${instance.host}: ${e}`); + } finally { + unlock(); + } +} + +type NodeInfo = { + openRegistrations?: any; + software?: { + name?: any; + version?: any; + }; + metadata?: { + name?: any; + nodeName?: any; + nodeDescription?: any; + description?: any; + maintainer?: { + name?: any; + email?: any; + }; + }; +}; + +async function fetchNodeinfo(instance: Instance): Promise { + logger.info(`Fetching nodeinfo of ${instance.host} ...`); + + try { + const wellknown = await getJson('https://' + instance.host + '/.well-known/nodeinfo') + .catch(e => { + if (e.statusCode === 404) { + throw 'No nodeinfo provided'; + } else { + throw e.statusCode || e.message; + } + }) as Record; + + if (wellknown.links == null || !Array.isArray(wellknown.links)) { + throw 'No wellknown links'; + } + + const links = wellknown.links as any[]; + + const lnik1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0'); + const lnik2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0'); + const lnik2_1 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.1'); + const link = lnik2_1 || lnik2_0 || lnik1_0; + + if (link == null) { + throw 'No nodeinfo link provided'; + } + + const info = await getJson(link.href) + .catch(e => { + throw e.statusCode || e.message; + }); + + logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`); + + return info as NodeInfo; + } catch (e) { + logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${e}`); + + throw e; + } +} + +async function fetchDom(instance: Instance): Promise { + logger.info(`Fetching HTML of ${instance.host} ...`); + + const url = 'https://' + instance.host; + + const html = await getHtml(url); + + const { window } = new JSDOM(html); + const doc = window.document; + + return doc; +} + +async function fetchManifest(instance: Instance): Promise | null> { + const url = 'https://' + instance.host; + + const manifestUrl = url + '/manifest.json'; + + const manifest = await getJson(manifestUrl) as Record; + + return manifest; +} + +async function fetchFaviconUrl(instance: Instance, doc: DOMWindow['document'] | null): Promise { + const url = 'https://' + instance.host; + + if (doc) { + // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 + const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.relList.contains('icon'))?.href; + + if (href) { + return (new URL(href, url)).href; + } + } + + const faviconUrl = url + '/favicon.ico'; + + const favicon = await fetch(faviconUrl, { + // TODO + //timeout: 10000, + agent: getAgentByUrl, + }); + + if (favicon.ok) { + return faviconUrl; + } + + return null; +} + +async function fetchIconUrl(instance: Instance, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { + if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) { + const url = 'https://' + instance.host; + return (new URL(manifest.icons[0].src, url)).href; + } + + if (doc) { + const url = 'https://' + instance.host; + + // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 + const links = Array.from(doc.getElementsByTagName('link')).reverse(); + // https://github.com/misskey-dev/misskey/pull/8220/files/0ec4eba22a914e31b86874f12448f88b3e58dd5a#r796487559 + const href = + [ + links.find(link => link.relList.contains('apple-touch-icon-precomposed'))?.href, + links.find(link => link.relList.contains('apple-touch-icon'))?.href, + links.find(link => link.relList.contains('icon'))?.href, + ] + .find(href => href); + + if (href) { + return (new URL(href, url)).href; + } + } + + return null; +} + +async function getThemeColor(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { + const themeColor = info?.metadata?.themeColor || doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') || manifest?.theme_color; + + if (themeColor) { + const color = new tinycolor(themeColor); + if (color.isValid()) return color.toHexString(); + } + + return null; +} + +async function getSiteName(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { + if (info && info.metadata) { + if (info.metadata.nodeName || info.metadata.name) { + return info.metadata.nodeName || info.metadata.name; + } + } + + if (doc) { + const og = doc.querySelector('meta[property="og:title"]')?.getAttribute('content'); + + if (og) { + return og; + } + } + + if (manifest) { + return manifest?.name || manifest?.short_name; + } + + return null; +} + +async function getDescription(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { + if (info && info.metadata) { + if (info.metadata.nodeDescription || info.metadata.description) { + return info.metadata.nodeDescription || info.metadata.description; + } + } + + if (doc) { + const meta = doc.querySelector('meta[name="description"]')?.getAttribute('content'); + if (meta) { + return meta; + } + + const og = doc.querySelector('meta[property="og:description"]')?.getAttribute('content'); + if (og) { + return og; + } + } + + if (manifest) { + return manifest?.name || manifest?.short_name; + } + + return null; +} diff --git a/packages/backend/src/services/following/create.ts b/packages/backend/src/services/following/create.ts new file mode 100644 index 0000000000..72c24676bb --- /dev/null +++ b/packages/backend/src/services/following/create.ts @@ -0,0 +1,201 @@ +import { publishMainStream, publishUserEvent } from '@/services/stream.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderFollow from '@/remote/activitypub/renderer/follow.js'; +import renderAccept from '@/remote/activitypub/renderer/accept.js'; +import renderReject from '@/remote/activitypub/renderer/reject.js'; +import { deliver } from '@/queue/index.js'; +import createFollowRequest from './requests/create.js'; +import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js'; +import Logger from '../logger.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { User } from '@/models/entities/user.js'; +import { Followings, Users, FollowRequests, Blockings, Instances, UserProfiles } from '@/models/index.js'; +import { instanceChart, perUserFollowingChart } from '@/services/chart/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { createNotification } from '../create-notification.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +import { Packed } from '@/misc/schema.js'; +import { getActiveWebhooks } from '@/misc/webhook-cache.js'; +import { webhookDeliver } from '@/queue/index.js'; + +const logger = new Logger('following/create'); + +export async function insertFollowingDoc(followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'] }, follower: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'] }) { + if (follower.id === followee.id) return; + + let alreadyFollowed = false; + + await Followings.insert({ + id: genId(), + createdAt: new Date(), + followerId: follower.id, + followeeId: followee.id, + + // 非正規化 + followerHost: follower.host, + followerInbox: Users.isRemoteUser(follower) ? follower.inbox : null, + followerSharedInbox: Users.isRemoteUser(follower) ? follower.sharedInbox : null, + followeeHost: followee.host, + followeeInbox: Users.isRemoteUser(followee) ? followee.inbox : null, + followeeSharedInbox: Users.isRemoteUser(followee) ? followee.sharedInbox : null, + }).catch(e => { + if (isDuplicateKeyValueError(e) && Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { + logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`); + alreadyFollowed = true; + } else { + throw e; + } + }); + + const req = await FollowRequests.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (req) { + await FollowRequests.delete({ + followeeId: followee.id, + followerId: follower.id, + }); + + // 通知を作成 + createNotification(follower.id, 'followRequestAccepted', { + notifierId: followee.id, + }); + } + + if (alreadyFollowed) return; + + //#region Increment counts + await Promise.all([ + Users.increment({ id: follower.id }, 'followingCount', 1), + Users.increment({ id: followee.id }, 'followersCount', 1), + ]); + //#endregion + + //#region Update instance stats + if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { + registerOrFetchInstanceDoc(follower.host).then(i => { + Instances.increment({ id: i.id }, 'followingCount', 1); + instanceChart.updateFollowing(i.host, true); + }); + } else if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) { + registerOrFetchInstanceDoc(followee.host).then(i => { + Instances.increment({ id: i.id }, 'followersCount', 1); + instanceChart.updateFollowers(i.host, true); + }); + } + //#endregion + + perUserFollowingChart.update(follower, followee, true); + + // Publish follow event + if (Users.isLocalUser(follower)) { + Users.pack(followee.id, follower, { + detail: true, + }).then(async packed => { + publishUserEvent(follower.id, 'follow', packed as Packed<"UserDetailedNotMe">); + publishMainStream(follower.id, 'follow', packed as Packed<"UserDetailedNotMe">); + + const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow')); + for (const webhook of webhooks) { + webhookDeliver(webhook, 'follow', { + user: packed, + }); + } + }); + } + + // Publish followed event + if (Users.isLocalUser(followee)) { + Users.pack(follower.id, followee).then(async packed => { + publishMainStream(followee.id, 'followed', packed); + + const webhooks = (await getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed')); + for (const webhook of webhooks) { + webhookDeliver(webhook, 'followed', { + user: packed, + }); + } + }); + + // 通知を作成 + createNotification(followee.id, 'follow', { + notifierId: follower.id, + }); + } +} + +export default async function(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string) { + const [follower, followee] = await Promise.all([ + Users.findOneByOrFail({ id: _follower.id }), + Users.findOneByOrFail({ id: _followee.id }), + ]); + + // check blocking + const [blocking, blocked] = await Promise.all([ + Blockings.findOneBy({ + blockerId: follower.id, + blockeeId: followee.id, + }), + Blockings.findOneBy({ + blockerId: followee.id, + blockeeId: follower.id, + }), + ]); + + if (Users.isRemoteUser(follower) && Users.isLocalUser(followee) && blocked) { + // リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。 + const content = renderActivity(renderReject(renderFollow(follower, followee, requestId), followee)); + deliver(followee , content, follower.inbox); + return; + } else if (Users.isRemoteUser(follower) && Users.isLocalUser(followee) && blocking) { + // リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。 + await Blockings.delete(blocking.id); + } else { + // それ以外は単純に例外 + if (blocking != null) throw new IdentifiableError('710e8fb0-b8c3-4922-be49-d5d93d8e6a6e', 'blocking'); + if (blocked != null) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked'); + } + + const followeeProfile = await UserProfiles.findOneByOrFail({ userId: followee.id }); + + // フォロー対象が鍵アカウントである or + // フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or + // フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである + // 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく + if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (Users.isLocalUser(follower) && Users.isRemoteUser(followee))) { + let autoAccept = false; + + // 鍵アカウントであっても、既にフォローされていた場合はスルー + const following = await Followings.findOneBy({ + followerId: follower.id, + followeeId: followee.id, + }); + if (following) { + autoAccept = true; + } + + // フォローしているユーザーは自動承認オプション + if (!autoAccept && (Users.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) { + const followed = await Followings.findOneBy({ + followerId: followee.id, + followeeId: follower.id, + }); + + if (followed) autoAccept = true; + } + + if (!autoAccept) { + await createFollowRequest(follower, followee, requestId); + return; + } + } + + await insertFollowingDoc(followee, follower); + + if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { + const content = renderActivity(renderAccept(renderFollow(follower, followee, requestId), followee)); + deliver(followee, content, follower.inbox); + } +} diff --git a/packages/backend/src/services/following/delete.ts b/packages/backend/src/services/following/delete.ts new file mode 100644 index 0000000000..91b5a3d61d --- /dev/null +++ b/packages/backend/src/services/following/delete.ts @@ -0,0 +1,83 @@ +import { publishMainStream, publishUserEvent } from '@/services/stream.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderFollow from '@/remote/activitypub/renderer/follow.js'; +import renderUndo from '@/remote/activitypub/renderer/undo.js'; +import renderReject from '@/remote/activitypub/renderer/reject.js'; +import { deliver, webhookDeliver } from '@/queue/index.js'; +import Logger from '../logger.js'; +import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js'; +import { User } from '@/models/entities/user.js'; +import { Followings, Users, Instances } from '@/models/index.js'; +import { instanceChart, perUserFollowingChart } from '@/services/chart/index.js'; +import { getActiveWebhooks } from '@/misc/webhook-cache.js'; + +const logger = new Logger('following/delete'); + +export default async function(follower: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, silent = false) { + const following = await Followings.findOneBy({ + followerId: follower.id, + followeeId: followee.id, + }); + + if (following == null) { + logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); + return; + } + + await Followings.delete(following.id); + + decrementFollowing(follower, followee); + + // Publish unfollow event + if (!silent && Users.isLocalUser(follower)) { + Users.pack(followee.id, follower, { + detail: true, + }).then(async packed => { + publishUserEvent(follower.id, 'unfollow', packed); + publishMainStream(follower.id, 'unfollow', packed); + + const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); + for (const webhook of webhooks) { + webhookDeliver(webhook, 'unfollow', { + user: packed, + }); + } + }); + } + + if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) { + const content = renderActivity(renderUndo(renderFollow(follower, followee), follower)); + deliver(follower, content, followee.inbox); + } + + if (Users.isLocalUser(followee) && Users.isRemoteUser(follower)) { + // local user has null host + const content = renderActivity(renderReject(renderFollow(follower, followee), followee)); + deliver(followee, content, follower.inbox); + } +} + +export async function decrementFollowing(follower: { id: User['id']; host: User['host']; }, followee: { id: User['id']; host: User['host']; }) { + //#region Decrement following / followers counts + await Promise.all([ + Users.decrement({ id: follower.id }, 'followingCount', 1), + Users.decrement({ id: followee.id }, 'followersCount', 1), + ]); + //#endregion + + //#region Update instance stats + if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { + registerOrFetchInstanceDoc(follower.host).then(i => { + Instances.decrement({ id: i.id }, 'followingCount', 1); + instanceChart.updateFollowing(i.host, false); + }); + } else if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) { + registerOrFetchInstanceDoc(followee.host).then(i => { + Instances.decrement({ id: i.id }, 'followersCount', 1); + instanceChart.updateFollowers(i.host, false); + }); + } + //#endregion + + perUserFollowingChart.update(follower, followee, false); +} diff --git a/packages/backend/src/services/following/reject.ts b/packages/backend/src/services/following/reject.ts new file mode 100644 index 0000000000..691fca2456 --- /dev/null +++ b/packages/backend/src/services/following/reject.ts @@ -0,0 +1,122 @@ +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderFollow from '@/remote/activitypub/renderer/follow.js'; +import renderReject from '@/remote/activitypub/renderer/reject.js'; +import { deliver, webhookDeliver } from '@/queue/index.js'; +import { publishMainStream, publishUserEvent } from '@/services/stream.js'; +import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js'; +import { Users, FollowRequests, Followings } from '@/models/index.js'; +import { decrementFollowing } from './delete.js'; +import { getActiveWebhooks } from '@/misc/webhook-cache.js'; + +type Local = ILocalUser | { + id: ILocalUser['id']; + host: ILocalUser['host']; + uri: ILocalUser['uri'] +}; +type Remote = IRemoteUser | { + id: IRemoteUser['id']; + host: IRemoteUser['host']; + uri: IRemoteUser['uri']; + inbox: IRemoteUser['inbox']; +}; +type Both = Local | Remote; + +/** + * API following/request/reject + */ +export async function rejectFollowRequest(user: Local, follower: Both) { + if (Users.isRemoteUser(follower)) { + deliverReject(user, follower); + } + + await removeFollowRequest(user, follower); + + if (Users.isLocalUser(follower)) { + publishUnfollow(user, follower); + } +} + +/** + * API following/reject + */ +export async function rejectFollow(user: Local, follower: Both) { + if (Users.isRemoteUser(follower)) { + deliverReject(user, follower); + } + + await removeFollow(user, follower); + + if (Users.isLocalUser(follower)) { + publishUnfollow(user, follower); + } +} + +/** + * AP Reject/Follow + */ +export async function remoteReject(actor: Remote, follower: Local) { + await removeFollowRequest(actor, follower); + await removeFollow(actor, follower); + publishUnfollow(actor, follower); +} + +/** + * Remove follow request record + */ +async function removeFollowRequest(followee: Both, follower: Both) { + const request = await FollowRequests.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (!request) return; + + await FollowRequests.delete(request.id); +} + +/** + * Remove follow record + */ +async function removeFollow(followee: Both, follower: Both) { + const following = await Followings.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (!following) return; + + await Followings.delete(following.id); + decrementFollowing(follower, followee); +} + +/** + * Deliver Reject to remote + */ +async function deliverReject(followee: Local, follower: Remote) { + const request = await FollowRequests.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + const content = renderActivity(renderReject(renderFollow(follower, followee, request?.requestId || undefined), followee)); + deliver(followee, content, follower.inbox); +} + +/** + * Publish unfollow to local + */ +async function publishUnfollow(followee: Both, follower: Local) { + const packedFollowee = await Users.pack(followee.id, follower, { + detail: true, + }); + + publishUserEvent(follower.id, 'unfollow', packedFollowee); + publishMainStream(follower.id, 'unfollow', packedFollowee); + + const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); + for (const webhook of webhooks) { + webhookDeliver(webhook, 'unfollow', { + user: packedFollowee, + }); + } +} diff --git a/packages/backend/src/services/following/requests/accept-all.ts b/packages/backend/src/services/following/requests/accept-all.ts new file mode 100644 index 0000000000..5fbb549e01 --- /dev/null +++ b/packages/backend/src/services/following/requests/accept-all.ts @@ -0,0 +1,18 @@ +import accept from './accept.js'; +import { User } from '@/models/entities/user.js'; +import { FollowRequests, Users } from '@/models/index.js'; + +/** + * 指定したユーザー宛てのフォローリクエストをすべて承認 + * @param user ユーザー + */ +export default async function(user: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }) { + const requests = await FollowRequests.findBy({ + followeeId: user.id, + }); + + for (const request of requests) { + const follower = await Users.findOneByOrFail({ id: request.followerId }); + accept(user, follower); + } +} diff --git a/packages/backend/src/services/following/requests/accept.ts b/packages/backend/src/services/following/requests/accept.ts new file mode 100644 index 0000000000..20829f70c7 --- /dev/null +++ b/packages/backend/src/services/following/requests/accept.ts @@ -0,0 +1,31 @@ +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderFollow from '@/remote/activitypub/renderer/follow.js'; +import renderAccept from '@/remote/activitypub/renderer/accept.js'; +import { deliver } from '@/queue/index.js'; +import { publishMainStream } from '@/services/stream.js'; +import { insertFollowingDoc } from '../create.js'; +import { User, ILocalUser, CacheableUser } from '@/models/entities/user.js'; +import { FollowRequests, Users } from '@/models/index.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; + +export default async function(followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, follower: CacheableUser) { + const request = await FollowRequests.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (request == null) { + throw new IdentifiableError('8884c2dd-5795-4ac9-b27e-6a01d38190f9', 'No follow request.'); + } + + await insertFollowingDoc(followee, follower); + + if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { + const content = renderActivity(renderAccept(renderFollow(follower, followee, request.requestId!), followee)); + deliver(followee, content, follower.inbox); + } + + Users.pack(followee.id, followee, { + detail: true, + }).then(packed => publishMainStream(followee.id, 'meUpdated', packed)); +} diff --git a/packages/backend/src/services/following/requests/cancel.ts b/packages/backend/src/services/following/requests/cancel.ts new file mode 100644 index 0000000000..56531fa1fd --- /dev/null +++ b/packages/backend/src/services/following/requests/cancel.ts @@ -0,0 +1,36 @@ +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderFollow from '@/remote/activitypub/renderer/follow.js'; +import renderUndo from '@/remote/activitypub/renderer/undo.js'; +import { deliver } from '@/queue/index.js'; +import { publishMainStream } from '@/services/stream.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { User, ILocalUser } from '@/models/entities/user.js'; +import { Users, FollowRequests } from '@/models/index.js'; + +export default async function(followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox'] }, follower: { id: User['id']; host: User['host']; uri: User['host'] }) { + if (Users.isRemoteUser(followee)) { + const content = renderActivity(renderUndo(renderFollow(follower, followee), follower)); + + if (Users.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので + deliver(follower, content, followee.inbox); + } + } + + const request = await FollowRequests.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (request == null) { + throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found'); + } + + await FollowRequests.delete({ + followeeId: followee.id, + followerId: follower.id, + }); + + Users.pack(followee.id, followee, { + detail: true, + }).then(packed => publishMainStream(followee.id, 'meUpdated', packed)); +} diff --git a/packages/backend/src/services/following/requests/create.ts b/packages/backend/src/services/following/requests/create.ts new file mode 100644 index 0000000000..bda2f8f92d --- /dev/null +++ b/packages/backend/src/services/following/requests/create.ts @@ -0,0 +1,63 @@ +import { publishMainStream } from '@/services/stream.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderFollow from '@/remote/activitypub/renderer/follow.js'; +import { deliver } from '@/queue/index.js'; +import { User } from '@/models/entities/user.js'; +import { Blockings, FollowRequests, Users } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { createNotification } from '../../create-notification.js'; + +export default async function(follower: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, requestId?: string) { + if (follower.id === followee.id) return; + + // check blocking + const [blocking, blocked] = await Promise.all([ + Blockings.findOneBy({ + blockerId: follower.id, + blockeeId: followee.id, + }), + Blockings.findOneBy({ + blockerId: followee.id, + blockeeId: follower.id, + }), + ]); + + if (blocking != null) throw new Error('blocking'); + if (blocked != null) throw new Error('blocked'); + + const followRequest = await FollowRequests.insert({ + id: genId(), + createdAt: new Date(), + followerId: follower.id, + followeeId: followee.id, + requestId, + + // 非正規化 + followerHost: follower.host, + followerInbox: Users.isRemoteUser(follower) ? follower.inbox : undefined, + followerSharedInbox: Users.isRemoteUser(follower) ? follower.sharedInbox : undefined, + followeeHost: followee.host, + followeeInbox: Users.isRemoteUser(followee) ? followee.inbox : undefined, + followeeSharedInbox: Users.isRemoteUser(followee) ? followee.sharedInbox : undefined, + }).then(x => FollowRequests.findOneByOrFail(x.identifiers[0])); + + // Publish receiveRequest event + if (Users.isLocalUser(followee)) { + Users.pack(follower.id, followee).then(packed => publishMainStream(followee.id, 'receiveFollowRequest', packed)); + + Users.pack(followee.id, followee, { + detail: true, + }).then(packed => publishMainStream(followee.id, 'meUpdated', packed)); + + // 通知を作成 + createNotification(followee.id, 'receiveFollowRequest', { + notifierId: follower.id, + followRequestId: followRequest.id, + }); + } + + if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) { + const content = renderActivity(renderFollow(follower, followee)); + deliver(follower, content, followee.inbox); + } +} diff --git a/packages/backend/src/services/i/pin.ts b/packages/backend/src/services/i/pin.ts new file mode 100644 index 0000000000..f35392a34b --- /dev/null +++ b/packages/backend/src/services/i/pin.ts @@ -0,0 +1,92 @@ +import config from '@/config/index.js'; +import renderAdd from '@/remote/activitypub/renderer/add.js'; +import renderRemove from '@/remote/activitypub/renderer/remove.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { User } from '@/models/entities/user.js'; +import { Note } from '@/models/entities/note.js'; +import { Notes, UserNotePinings, Users } from '@/models/index.js'; +import { UserNotePining } from '@/models/entities/user-note-pining.js'; +import { genId } from '@/misc/gen-id.js'; +import { deliverToFollowers } from '@/remote/activitypub/deliver-manager.js'; +import { deliverToRelays } from '../relay.js'; + +/** + * 指定した投稿をピン留めします + * @param user + * @param noteId + */ +export async function addPinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) { + // Fetch pinee + const note = await Notes.findOneBy({ + id: noteId, + userId: user.id, + }); + + if (note == null) { + throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', 'No such note.'); + } + + const pinings = await UserNotePinings.findBy({ userId: user.id }); + + if (pinings.length >= 5) { + throw new IdentifiableError('15a018eb-58e5-4da1-93be-330fcc5e4e1a', 'You can not pin notes any more.'); + } + + if (pinings.some(pining => pining.noteId === note.id)) { + throw new IdentifiableError('23f0cf4e-59a3-4276-a91d-61a5891c1514', 'That note has already been pinned.'); + } + + await UserNotePinings.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + noteId: note.id, + } as UserNotePining); + + // Deliver to remote followers + if (Users.isLocalUser(user)) { + deliverPinnedChange(user.id, note.id, true); + } +} + +/** + * 指定した投稿のピン留めを解除します + * @param user + * @param noteId + */ +export async function removePinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) { + // Fetch unpinee + const note = await Notes.findOneBy({ + id: noteId, + userId: user.id, + }); + + if (note == null) { + throw new IdentifiableError('b302d4cf-c050-400a-bbb3-be208681f40c', 'No such note.'); + } + + UserNotePinings.delete({ + userId: user.id, + noteId: note.id, + }); + + // Deliver to remote followers + if (Users.isLocalUser(user)) { + deliverPinnedChange(user.id, noteId, false); + } +} + +export async function deliverPinnedChange(userId: User['id'], noteId: Note['id'], isAddition: boolean) { + const user = await Users.findOneBy({ id: userId }); + if (user == null) throw new Error('user not found'); + + if (!Users.isLocalUser(user)) return; + + const target = `${config.url}/users/${user.id}/collections/featured`; + const item = `${config.url}/notes/${noteId}`; + const content = renderActivity(isAddition ? renderAdd(user, target, item) : renderRemove(user, target, item)); + + deliverToFollowers(user, content); + deliverToRelays(user, content); +} diff --git a/packages/backend/src/services/i/update.ts b/packages/backend/src/services/i/update.ts new file mode 100644 index 0000000000..27bd38bd39 --- /dev/null +++ b/packages/backend/src/services/i/update.ts @@ -0,0 +1,19 @@ +import renderUpdate from '@/remote/activitypub/renderer/update.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import { Users } from '@/models/index.js'; +import { User } from '@/models/entities/user.js'; +import { renderPerson } from '@/remote/activitypub/renderer/person.js'; +import { deliverToFollowers } from '@/remote/activitypub/deliver-manager.js'; +import { deliverToRelays } from '../relay.js'; + +export async function publishToFollowers(userId: User['id']) { + const user = await Users.findOneBy({ id: userId }); + if (user == null) throw new Error('user not found'); + + // フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 + if (Users.isLocalUser(user)) { + const content = renderActivity(renderUpdate(await renderPerson(user), user)); + deliverToFollowers(user, content); + deliverToRelays(user, content); + } +} diff --git a/packages/backend/src/services/insert-moderation-log.ts b/packages/backend/src/services/insert-moderation-log.ts new file mode 100644 index 0000000000..0a7c472d8d --- /dev/null +++ b/packages/backend/src/services/insert-moderation-log.ts @@ -0,0 +1,13 @@ +import { ModerationLogs } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { User } from '@/models/entities/user.js'; + +export async function insertModerationLog(moderator: { id: User['id'] }, type: string, info?: Record) { + await ModerationLogs.insert({ + id: genId(), + createdAt: new Date(), + userId: moderator.id, + type: type, + info: info || {}, + }); +} diff --git a/packages/backend/src/services/instance-actor.ts b/packages/backend/src/services/instance-actor.ts new file mode 100644 index 0000000000..bddd0355a2 --- /dev/null +++ b/packages/backend/src/services/instance-actor.ts @@ -0,0 +1,28 @@ +import { createSystemUser } from './create-system-user.js'; +import { ILocalUser } from '@/models/entities/user.js'; +import { Users } from '@/models/index.js'; +import { Cache } from '@/misc/cache.js'; +import { IsNull } from 'typeorm'; + +const ACTOR_USERNAME = 'instance.actor' as const; + +const cache = new Cache(Infinity); + +export async function getInstanceActor(): Promise { + const cached = cache.get(null); + if (cached) return cached; + + const user = await Users.findOneBy({ + host: IsNull(), + username: ACTOR_USERNAME, + }) as ILocalUser | undefined; + + if (user) { + cache.set(null, user); + return user; + } else { + const created = await createSystemUser(ACTOR_USERNAME) as ILocalUser; + cache.set(null, created); + return created; + } +} diff --git a/packages/backend/src/services/logger.ts b/packages/backend/src/services/logger.ts new file mode 100644 index 0000000000..89d6d57209 --- /dev/null +++ b/packages/backend/src/services/logger.ts @@ -0,0 +1,128 @@ +import cluster from 'node:cluster'; +import chalk from 'chalk'; +import { default as convertColor } from 'color-convert'; +import { format as dateFormat } from 'date-fns'; +import { envOption } from '../env.js'; +import config from '@/config/index.js'; + +import * as SyslogPro from 'syslog-pro'; + +type Domain = { + name: string; + color?: string; +}; + +type Level = 'error' | 'success' | 'warning' | 'debug' | 'info'; + +export default class Logger { + private domain: Domain; + private parentLogger: Logger | null = null; + private store: boolean; + private syslogClient: any | null = null; + + constructor(domain: string, color?: string, store = true) { + this.domain = { + name: domain, + color: color, + }; + this.store = store; + + if (config.syslog) { + this.syslogClient = new SyslogPro.RFC5424({ + applacationName: 'Misskey', + timestamp: true, + encludeStructuredData: true, + color: true, + extendedColor: true, + server: { + target: config.syslog.host, + port: config.syslog.port, + }, + }); + } + } + + public createSubLogger(domain: string, color?: string, store = true): Logger { + const logger = new Logger(domain, color, store); + logger.parentLogger = this; + return logger; + } + + private log(level: Level, message: string, data?: Record | null, important = false, subDomains: Domain[] = [], store = true): void { + if (envOption.quiet) return; + if (!this.store) store = false; + if (level === 'debug') store = false; + + if (this.parentLogger) { + this.parentLogger.log(level, message, data, important, [this.domain].concat(subDomains), store); + return; + } + + const time = dateFormat(new Date(), 'HH:mm:ss'); + const worker = cluster.isPrimary ? '*' : cluster.worker.id; + const l = + level === 'error' ? important ? chalk.bgRed.white('ERR ') : chalk.red('ERR ') : + level === 'warning' ? chalk.yellow('WARN') : + level === 'success' ? important ? chalk.bgGreen.white('DONE') : chalk.green('DONE') : + level === 'debug' ? chalk.gray('VERB') : + level === 'info' ? chalk.blue('INFO') : + null; + const domains = [this.domain].concat(subDomains).map(d => d.color ? chalk.rgb(...convertColor.keyword.rgb(d.color))(d.name) : chalk.white(d.name)); + const m = + level === 'error' ? chalk.red(message) : + level === 'warning' ? chalk.yellow(message) : + level === 'success' ? chalk.green(message) : + level === 'debug' ? chalk.gray(message) : + level === 'info' ? message : + null; + + let log = `${l} ${worker}\t[${domains.join(' ')}]\t${m}`; + if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log; + + console.log(important ? chalk.bold(log) : log); + + if (store) { + if (this.syslogClient) { + const send = + level === 'error' ? this.syslogClient.error : + level === 'warning' ? this.syslogClient.warning : + level === 'success' ? this.syslogClient.info : + level === 'debug' ? this.syslogClient.info : + level === 'info' ? this.syslogClient.info : + null as never; + + send.bind(this.syslogClient)(message).catch(() => {}); + } + } + } + + public error(x: string | Error, data?: Record | null, important = false): void { // 実行を継続できない状況で使う + if (x instanceof Error) { + data = data || {}; + data.e = x; + this.log('error', x.toString(), data, important); + } else if (typeof x === 'object') { + this.log('error', `${(x as any).message || (x as any).name || x}`, data, important); + } else { + this.log('error', `${x}`, data, important); + } + } + + public warn(message: string, data?: Record | null, important = false): void { // 実行を継続できるが改善すべき状況で使う + this.log('warning', message, data, important); + } + + public succ(message: string, data?: Record | null, important = false): void { // 何かに成功した状況で使う + this.log('success', message, data, important); + } + + public debug(message: string, data?: Record | null, important = false): void { // デバッグ用に使う(開発者に必要だが利用者に不要な情報) + if (process.env.NODE_ENV !== 'production' || envOption.verbose) { + this.log('debug', message, data, important); + } + } + + public info(message: string, data?: Record | null, important = false): void { // それ以外 + this.log('info', message, data, important); + } +} diff --git a/packages/backend/src/services/messages/create.ts b/packages/backend/src/services/messages/create.ts new file mode 100644 index 0000000000..e6b3204922 --- /dev/null +++ b/packages/backend/src/services/messages/create.ts @@ -0,0 +1,108 @@ +import { CacheableUser, User } from '@/models/entities/user.js'; +import { UserGroup } from '@/models/entities/user-group.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { MessagingMessages, UserGroupJoinings, Mutings, Users } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { MessagingMessage } from '@/models/entities/messaging-message.js'; +import { publishMessagingStream, publishMessagingIndexStream, publishMainStream, publishGroupMessagingStream } from '@/services/stream.js'; +import { pushNotification } from '@/services/push-notification.js'; +import { Not } from 'typeorm'; +import { Note } from '@/models/entities/note.js'; +import renderNote from '@/remote/activitypub/renderer/note.js'; +import renderCreate from '@/remote/activitypub/renderer/create.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import { deliver } from '@/queue/index.js'; + +export async function createMessage(user: { id: User['id']; host: User['host']; }, recipientUser: CacheableUser | undefined, recipientGroup: UserGroup | undefined, text: string | null | undefined, file: DriveFile | null, uri?: string) { + const message = { + id: genId(), + createdAt: new Date(), + fileId: file ? file.id : null, + recipientId: recipientUser ? recipientUser.id : null, + groupId: recipientGroup ? recipientGroup.id : null, + text: text ? text.trim() : null, + userId: user.id, + isRead: false, + reads: [] as any[], + uri, + } as MessagingMessage; + + await MessagingMessages.insert(message); + + const messageObj = await MessagingMessages.pack(message); + + if (recipientUser) { + if (Users.isLocalUser(user)) { + // 自分のストリーム + publishMessagingStream(message.userId, recipientUser.id, 'message', messageObj); + publishMessagingIndexStream(message.userId, 'message', messageObj); + publishMainStream(message.userId, 'messagingMessage', messageObj); + } + + if (Users.isLocalUser(recipientUser)) { + // 相手のストリーム + publishMessagingStream(recipientUser.id, message.userId, 'message', messageObj); + publishMessagingIndexStream(recipientUser.id, 'message', messageObj); + publishMainStream(recipientUser.id, 'messagingMessage', messageObj); + } + } else if (recipientGroup) { + // グループのストリーム + publishGroupMessagingStream(recipientGroup.id, 'message', messageObj); + + // メンバーのストリーム + const joinings = await UserGroupJoinings.findBy({ userGroupId: recipientGroup.id }); + for (const joining of joinings) { + publishMessagingIndexStream(joining.userId, 'message', messageObj); + publishMainStream(joining.userId, 'messagingMessage', messageObj); + } + } + + // 2秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する + setTimeout(async () => { + const freshMessage = await MessagingMessages.findOneBy({ id: message.id }); + if (freshMessage == null) return; // メッセージが削除されている場合もある + + if (recipientUser && Users.isLocalUser(recipientUser)) { + if (freshMessage.isRead) return; // 既読 + + //#region ただしミュートされているなら発行しない + const mute = await Mutings.findBy({ + muterId: recipientUser.id, + }); + if (mute.map(m => m.muteeId).includes(user.id)) return; + //#endregion + + publishMainStream(recipientUser.id, 'unreadMessagingMessage', messageObj); + pushNotification(recipientUser.id, 'unreadMessagingMessage', messageObj); + } else if (recipientGroup) { + const joinings = await UserGroupJoinings.findBy({ userGroupId: recipientGroup.id, userId: Not(user.id) }); + for (const joining of joinings) { + if (freshMessage.reads.includes(joining.userId)) return; // 既読 + publishMainStream(joining.userId, 'unreadMessagingMessage', messageObj); + pushNotification(joining.userId, 'unreadMessagingMessage', messageObj); + } + } + }, 2000); + + if (recipientUser && Users.isLocalUser(user) && Users.isRemoteUser(recipientUser)) { + const note = { + id: message.id, + createdAt: message.createdAt, + fileIds: message.fileId ? [ message.fileId ] : [], + text: message.text, + userId: message.userId, + visibility: 'specified', + mentions: [ recipientUser ].map(u => u.id), + mentionedRemoteUsers: JSON.stringify([ recipientUser ].map(u => ({ + uri: u.uri, + username: u.username, + host: u.host, + }))), + } as Note; + + const activity = renderActivity(renderCreate(await renderNote(note, false, true), note)); + + deliver(user, activity, recipientUser.inbox); + } + return messageObj; +} diff --git a/packages/backend/src/services/messages/delete.ts b/packages/backend/src/services/messages/delete.ts new file mode 100644 index 0000000000..1e7ce1981c --- /dev/null +++ b/packages/backend/src/services/messages/delete.ts @@ -0,0 +1,30 @@ +import config from '@/config/index.js'; +import { MessagingMessages, Users } from '@/models/index.js'; +import { MessagingMessage } from '@/models/entities/messaging-message.js'; +import { publishGroupMessagingStream, publishMessagingStream } from '@/services/stream.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderDelete from '@/remote/activitypub/renderer/delete.js'; +import renderTombstone from '@/remote/activitypub/renderer/tombstone.js'; +import { deliver } from '@/queue/index.js'; + +export async function deleteMessage(message: MessagingMessage) { + await MessagingMessages.delete(message.id); + postDeleteMessage(message); +} + +async function postDeleteMessage(message: MessagingMessage) { + if (message.recipientId) { + const user = await Users.findOneByOrFail({ id: message.userId }); + const recipient = await Users.findOneByOrFail({ id: message.recipientId }); + + if (Users.isLocalUser(user)) publishMessagingStream(message.userId, message.recipientId, 'deleted', message.id); + if (Users.isLocalUser(recipient)) publishMessagingStream(message.recipientId, message.userId, 'deleted', message.id); + + if (Users.isLocalUser(user) && Users.isRemoteUser(recipient)) { + const activity = renderActivity(renderDelete(renderTombstone(`${config.url}/notes/${message.id}`), user)); + deliver(user, activity, recipient.inbox); + } + } else if (message.groupId) { + publishGroupMessagingStream(message.groupId, 'deleted', message.id); + } +} diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts new file mode 100644 index 0000000000..e2bf9d5b59 --- /dev/null +++ b/packages/backend/src/services/note/create.ts @@ -0,0 +1,692 @@ +import * as mfm from 'mfm-js'; +import es from '../../db/elasticsearch.js'; +import { publishMainStream, publishNotesStream } from '@/services/stream.js'; +import DeliverManager from '@/remote/activitypub/deliver-manager.js'; +import renderNote from '@/remote/activitypub/renderer/note.js'; +import renderCreate from '@/remote/activitypub/renderer/create.js'; +import renderAnnounce from '@/remote/activitypub/renderer/announce.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import { resolveUser } from '@/remote/resolve-user.js'; +import config from '@/config/index.js'; +import { updateHashtags } from '../update-hashtag.js'; +import { concat } from '@/prelude/array.js'; +import { insertNoteUnread } from '@/services/note/unread.js'; +import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js'; +import { extractMentions } from '@/misc/extract-mentions.js'; +import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; +import { extractHashtags } from '@/misc/extract-hashtags.js'; +import { Note, IMentionedRemoteUsers } from '@/models/entities/note.js'; +import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes, Channels, ChannelFollowings, Blockings, NoteThreadMutings } from '@/models/index.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { App } from '@/models/entities/app.js'; +import { Not, In } from 'typeorm'; +import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js'; +import { genId } from '@/misc/gen-id.js'; +import { notesChart, perUserNotesChart, activeUsersChart, instanceChart } from '@/services/chart/index.js'; +import { Poll, IPoll } from '@/models/entities/poll.js'; +import { createNotification } from '../create-notification.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +import { checkHitAntenna } from '@/misc/check-hit-antenna.js'; +import { checkWordMute } from '@/misc/check-word-mute.js'; +import { addNoteToAntenna } from '../add-note-to-antenna.js'; +import { countSameRenotes } from '@/misc/count-same-renotes.js'; +import { deliverToRelays } from '../relay.js'; +import { Channel } from '@/models/entities/channel.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { getAntennas } from '@/misc/antenna-cache.js'; +import { endedPollNotificationQueue } from '@/queue/queues.js'; +import { webhookDeliver } from '@/queue/index.js'; +import { Cache } from '@/misc/cache.js'; +import { UserProfile } from '@/models/entities/user-profile.js'; +import { db } from '@/db/postgre.js'; +import { getActiveWebhooks } from '@/misc/webhook-cache.js'; + +const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); + +type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; + +class NotificationManager { + private notifier: { id: User['id']; }; + private note: Note; + private queue: { + target: ILocalUser['id']; + reason: NotificationType; + }[]; + + constructor(notifier: { id: User['id']; }, note: Note) { + this.notifier = notifier; + this.note = note; + this.queue = []; + } + + public push(notifiee: ILocalUser['id'], reason: NotificationType) { + // 自分自身へは通知しない + if (this.notifier.id === notifiee) return; + + const exist = this.queue.find(x => x.target === notifiee); + + if (exist) { + // 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする + if (reason !== 'mention') { + exist.reason = reason; + } + } else { + this.queue.push({ + reason: reason, + target: notifiee, + }); + } + } + + public async deliver() { + for (const x of this.queue) { + // ミュート情報を取得 + const mentioneeMutes = await Mutings.findBy({ + muterId: x.target, + }); + + const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId); + + // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する + if (!mentioneesMutedUserIds.includes(this.notifier.id)) { + createNotification(x.target, x.reason, { + notifierId: this.notifier.id, + noteId: this.note.id, + }); + } + } + } +} + +type MinimumUser = { + id: User['id']; + host: User['host']; + username: User['username']; + uri: User['uri']; +}; + +type Option = { + createdAt?: Date | null; + name?: string | null; + text?: string | null; + reply?: Note | null; + renote?: Note | null; + files?: DriveFile[] | null; + poll?: IPoll | null; + localOnly?: boolean | null; + cw?: string | null; + visibility?: string; + visibleUsers?: MinimumUser[] | null; + channel?: Channel | null; + apMentions?: MinimumUser[] | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; + uri?: string | null; + url?: string | null; + app?: App | null; +}; + +export default async (user: { id: User['id']; username: User['username']; host: User['host']; isSilenced: User['isSilenced']; createdAt: User['createdAt']; }, data: Option, silent = false) => new Promise(async (res, rej) => { + // チャンネル外にリプライしたら対象のスコープに合わせる + // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) + if (data.reply && data.channel && data.reply.channelId !== data.channel.id) { + if (data.reply.channelId) { + data.channel = await Channels.findOneBy({ id: data.reply.channelId }); + } else { + data.channel = null; + } + } + + // チャンネル内にリプライしたら対象のスコープに合わせる + // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) + if (data.reply && (data.channel == null) && data.reply.channelId) { + data.channel = await Channels.findOneBy({ id: data.reply.channelId }); + } + + if (data.createdAt == null) data.createdAt = new Date(); + if (data.visibility == null) data.visibility = 'public'; + if (data.localOnly == null) data.localOnly = false; + if (data.channel != null) data.visibility = 'public'; + if (data.channel != null) data.visibleUsers = []; + if (data.channel != null) data.localOnly = true; + + // サイレンス + if (user.isSilenced && data.visibility === 'public' && data.channel == null) { + data.visibility = 'home'; + } + + // Renote対象が「ホームまたは全体」以外の公開範囲ならreject + if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) { + return rej('Renote target is not public or home'); + } + + // Renote対象がpublicではないならhomeにする + if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') { + data.visibility = 'home'; + } + + // Renote対象がfollowersならfollowersにする + if (data.renote && data.renote.visibility === 'followers') { + data.visibility = 'followers'; + } + + // 返信対象がpublicではないならhomeにする + if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') { + data.visibility = 'home'; + } + + // ローカルのみをRenoteしたらローカルのみにする + if (data.renote && data.renote.localOnly && data.channel == null) { + data.localOnly = true; + } + + // ローカルのみにリプライしたらローカルのみにする + if (data.reply && data.reply.localOnly && data.channel == null) { + data.localOnly = true; + } + + if (data.text) { + data.text = data.text.trim(); + } else { + data.text = null; + } + + let tags = data.apHashtags; + let emojis = data.apEmojis; + let mentionedUsers = data.apMentions; + + // Parse MFM if needed + if (!tags || !emojis || !mentionedUsers) { + const tokens = data.text ? mfm.parse(data.text)! : []; + const cwTokens = data.cw ? mfm.parse(data.cw)! : []; + const choiceTokens = data.poll && data.poll.choices + ? concat(data.poll.choices.map(choice => mfm.parse(choice)!)) + : []; + + const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens); + + tags = data.apHashtags || extractHashtags(combinedTokens); + + emojis = data.apEmojis || extractCustomEmojisFromMfm(combinedTokens); + + mentionedUsers = data.apMentions || await extractMentionedUsers(user, combinedTokens); + } + + tags = tags.filter(tag => Array.from(tag || '').length <= 128).splice(0, 32); + + if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) { + mentionedUsers.push(await Users.findOneByOrFail({ id: data.reply!.userId })); + } + + if (data.visibility === 'specified') { + if (data.visibleUsers == null) throw new Error('invalid param'); + + for (const u of data.visibleUsers) { + if (!mentionedUsers.some(x => x.id === u.id)) { + mentionedUsers.push(u); + } + } + + if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) { + data.visibleUsers.push(await Users.findOneByOrFail({ id: data.reply!.userId })); + } + } + + const note = await insertNote(user, data, tags, emojis, mentionedUsers); + + res(note); + + // 統計を更新 + notesChart.update(note, true); + perUserNotesChart.update(user, note, true); + + // Register host + if (Users.isRemoteUser(user)) { + registerOrFetchInstanceDoc(user.host).then(i => { + Instances.increment({ id: i.id }, 'notesCount', 1); + instanceChart.updateNote(i.host, note, true); + }); + } + + // ハッシュタグ更新 + if (data.visibility === 'public' || data.visibility === 'home') { + updateHashtags(user, tags); + } + + // Increment notes count (user) + incNotesCountOfUser(user); + + // Word mute + mutedWordsCache.fetch(null, () => UserProfiles.find({ + where: { + enableWordMute: true, + }, + select: ['userId', 'mutedWords'], + })).then(us => { + for (const u of us) { + checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => { + if (shouldMute) { + MutedNotes.insert({ + id: genId(), + userId: u.userId, + noteId: note.id, + reason: 'word', + }); + } + }); + } + }); + + // Antenna + for (const antenna of (await getAntennas())) { + checkHitAntenna(antenna, note, user).then(hit => { + if (hit) { + addNoteToAntenna(antenna, note, user); + } + }); + } + + // Channel + if (note.channelId) { + ChannelFollowings.findBy({ followeeId: note.channelId }).then(followings => { + for (const following of followings) { + insertNoteUnread(following.followerId, note, { + isSpecified: false, + isMentioned: false, + }); + } + }); + } + + if (data.reply) { + saveReply(data.reply, note); + } + + // この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき + if (data.renote && (await countSameRenotes(user.id, data.renote.id, note.id) === 0)) { + incRenoteCount(data.renote); + } + + if (data.poll && data.poll.expiresAt) { + const delay = data.poll.expiresAt.getTime() - Date.now(); + endedPollNotificationQueue.add({ + noteId: note.id, + }, { + delay, + removeOnComplete: true, + }); + } + + if (!silent) { + if (Users.isLocalUser(user)) activeUsersChart.write(user); + + // 未読通知を作成 + if (data.visibility === 'specified') { + if (data.visibleUsers == null) throw new Error('invalid param'); + + for (const u of data.visibleUsers) { + // ローカルユーザーのみ + if (!Users.isLocalUser(u)) continue; + + insertNoteUnread(u.id, note, { + isSpecified: true, + isMentioned: false, + }); + } + } else { + for (const u of mentionedUsers) { + // ローカルユーザーのみ + if (!Users.isLocalUser(u)) continue; + + insertNoteUnread(u.id, note, { + isSpecified: false, + isMentioned: true, + }); + } + } + + // Pack the note + const noteObj = await Notes.pack(note); + + publishNotesStream(noteObj); + + getActiveWebhooks().then(webhooks => { + webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); + for (const webhook of webhooks) { + webhookDeliver(webhook, 'note', { + note: noteObj, + }); + } + }); + + const nm = new NotificationManager(user, note); + const nmRelatedPromises = []; + + await createMentionedEvents(mentionedUsers, note, nm); + + // If has in reply to note + if (data.reply) { + // Fetch watchers + nmRelatedPromises.push(notifyToWatchersOfReplyee(data.reply, user, nm)); + + // 通知 + if (data.reply.userHost === null) { + const threadMuted = await NoteThreadMutings.findOneBy({ + userId: data.reply.userId, + threadId: data.reply.threadId || data.reply.id, + }); + + if (!threadMuted) { + nm.push(data.reply.userId, 'reply'); + publishMainStream(data.reply.userId, 'reply', noteObj); + + const webhooks = (await getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply')); + for (const webhook of webhooks) { + webhookDeliver(webhook, 'reply', { + note: noteObj, + }); + } + } + } + } + + // If it is renote + if (data.renote) { + const type = data.text ? 'quote' : 'renote'; + + // Notify + if (data.renote.userHost === null) { + nm.push(data.renote.userId, type); + } + + // Fetch watchers + nmRelatedPromises.push(notifyToWatchersOfRenotee(data.renote, user, nm, type)); + + // Publish event + if ((user.id !== data.renote.userId) && data.renote.userHost === null) { + publishMainStream(data.renote.userId, 'renote', noteObj); + + const webhooks = (await getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote')); + for (const webhook of webhooks) { + webhookDeliver(webhook, 'renote', { + note: noteObj, + }); + } + } + } + + Promise.all(nmRelatedPromises).then(() => { + nm.deliver(); + }); + + //#region AP deliver + if (Users.isLocalUser(user)) { + (async () => { + const noteActivity = await renderNoteOrRenoteActivity(data, note); + const dm = new DeliverManager(user, noteActivity); + + // メンションされたリモートユーザーに配送 + for (const u of mentionedUsers.filter(u => Users.isRemoteUser(u))) { + dm.addDirectRecipe(u as IRemoteUser); + } + + // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送 + if (data.reply && data.reply.userHost !== null) { + const u = await Users.findOneBy({ id: data.reply.userId }); + if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u); + } + + // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送 + if (data.renote && data.renote.userHost !== null) { + const u = await Users.findOneBy({ id: data.renote.userId }); + if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u); + } + + // フォロワーに配送 + if (['public', 'home', 'followers'].includes(note.visibility)) { + dm.addFollowersRecipe(); + } + + if (['public'].includes(note.visibility)) { + deliverToRelays(user, noteActivity); + } + + dm.execute(); + })(); + } + //#endregion + } + + if (data.channel) { + Channels.increment({ id: data.channel.id }, 'notesCount', 1); + Channels.update(data.channel.id, { + lastNotedAt: new Date(), + }); + + Notes.countBy({ + userId: user.id, + channelId: data.channel.id, + }).then(count => { + // この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる + // TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい + if (count === 1) { + Channels.increment({ id: data.channel!.id }, 'usersCount', 1); + } + }); + } + + // Register to search database + index(note); +}); + +async function renderNoteOrRenoteActivity(data: Option, note: Note) { + if (data.localOnly) return null; + + const content = data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length === 0) + ? renderAnnounce(data.renote.uri ? data.renote.uri : `${config.url}/notes/${data.renote.id}`, note) + : renderCreate(await renderNote(note, false), note); + + return renderActivity(content); +} + +function incRenoteCount(renote: Note) { + Notes.createQueryBuilder().update() + .set({ + renoteCount: () => '"renoteCount" + 1', + score: () => '"score" + 1', + }) + .where('id = :id', { id: renote.id }) + .execute(); +} + +async function insertNote(user: { id: User['id']; host: User['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) { + const insert = new Note({ + id: genId(data.createdAt!), + createdAt: data.createdAt!, + fileIds: data.files ? data.files.map(file => file.id) : [], + replyId: data.reply ? data.reply.id : null, + renoteId: data.renote ? data.renote.id : null, + channelId: data.channel ? data.channel.id : null, + threadId: data.reply + ? data.reply.threadId + ? data.reply.threadId + : data.reply.id + : null, + name: data.name, + text: data.text, + hasPoll: data.poll != null, + cw: data.cw == null ? null : data.cw, + tags: tags.map(tag => normalizeForSearch(tag)), + emojis, + userId: user.id, + localOnly: data.localOnly!, + visibility: data.visibility as any, + visibleUserIds: data.visibility === 'specified' + ? data.visibleUsers + ? data.visibleUsers.map(u => u.id) + : [] + : [], + + attachedFileTypes: data.files ? data.files.map(file => file.type) : [], + + // 以下非正規化データ + replyUserId: data.reply ? data.reply.userId : null, + replyUserHost: data.reply ? data.reply.userHost : null, + renoteUserId: data.renote ? data.renote.userId : null, + renoteUserHost: data.renote ? data.renote.userHost : null, + userHost: user.host, + }); + + if (data.uri != null) insert.uri = data.uri; + if (data.url != null) insert.url = data.url; + + // Append mentions data + if (mentionedUsers.length > 0) { + insert.mentions = mentionedUsers.map(u => u.id); + const profiles = await UserProfiles.findBy({ userId: In(insert.mentions) }); + insert.mentionedRemoteUsers = JSON.stringify(mentionedUsers.filter(u => Users.isRemoteUser(u)).map(u => { + const profile = profiles.find(p => p.userId === u.id); + const url = profile != null ? profile.url : null; + return { + uri: u.uri, + url: url == null ? undefined : url, + username: u.username, + host: u.host, + } as IMentionedRemoteUsers[0]; + })); + } + + // 投稿を作成 + try { + if (insert.hasPoll) { + // Start transaction + await db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.insert(Note, insert); + + const poll = new Poll({ + noteId: insert.id, + choices: data.poll!.choices, + expiresAt: data.poll!.expiresAt, + multiple: data.poll!.multiple, + votes: new Array(data.poll!.choices.length).fill(0), + noteVisibility: insert.visibility, + userId: user.id, + userHost: user.host, + }); + + await transactionalEntityManager.insert(Poll, poll); + }); + } else { + await Notes.insert(insert); + } + + return insert; + } catch (e) { + // duplicate key error + if (isDuplicateKeyValueError(e)) { + const err = new Error('Duplicated note'); + err.name = 'duplicated'; + throw err; + } + + console.error(e); + + throw e; + } +} + +function index(note: Note) { + if (note.text == null || config.elasticsearch == null) return; + + es!.index({ + index: config.elasticsearch.index || 'misskey_note', + id: note.id.toString(), + body: { + text: normalizeForSearch(note.text), + userId: note.userId, + userHost: note.userHost, + }, + }); +} + +async function notifyToWatchersOfRenotee(renote: Note, user: { id: User['id']; }, nm: NotificationManager, type: NotificationType) { + const watchers = await NoteWatchings.findBy({ + noteId: renote.id, + userId: Not(user.id), + }); + + for (const watcher of watchers) { + nm.push(watcher.userId, type); + } +} + +async function notifyToWatchersOfReplyee(reply: Note, user: { id: User['id']; }, nm: NotificationManager) { + const watchers = await NoteWatchings.findBy({ + noteId: reply.id, + userId: Not(user.id), + }); + + for (const watcher of watchers) { + nm.push(watcher.userId, 'reply'); + } +} + +async function createMentionedEvents(mentionedUsers: MinimumUser[], note: Note, nm: NotificationManager) { + for (const u of mentionedUsers.filter(u => Users.isLocalUser(u))) { + const threadMuted = await NoteThreadMutings.findOneBy({ + userId: u.id, + threadId: note.threadId || note.id, + }); + + if (threadMuted) { + continue; + } + + const detailPackedNote = await Notes.pack(note, u, { + detail: true, + }); + + publishMainStream(u.id, 'mention', detailPackedNote); + + const webhooks = (await getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention')); + for (const webhook of webhooks) { + webhookDeliver(webhook, 'mention', { + note: detailPackedNote, + }); + } + + // Create notification + nm.push(u.id, 'mention'); + } +} + +function saveReply(reply: Note, note: Note) { + Notes.increment({ id: reply.id }, 'repliesCount', 1); +} + +function incNotesCountOfUser(user: { id: User['id']; }) { + Users.createQueryBuilder().update() + .set({ + updatedAt: new Date(), + notesCount: () => '"notesCount" + 1', + }) + .where('id = :id', { id: user.id }) + .execute(); +} + +async function extractMentionedUsers(user: { host: User['host']; }, tokens: mfm.MfmNode[]): Promise { + if (tokens == null) return []; + + const mentions = extractMentions(tokens); + + let mentionedUsers = (await Promise.all(mentions.map(m => + resolveUser(m.username, m.host || user.host).catch(() => null) + ))).filter(x => x != null) as User[]; + + // Drop duplicate users + mentionedUsers = mentionedUsers.filter((u, i, self) => + i === self.findIndex(u2 => u.id === u2.id) + ); + + return mentionedUsers; +} diff --git a/packages/backend/src/services/note/delete.ts b/packages/backend/src/services/note/delete.ts new file mode 100644 index 0000000000..4963200161 --- /dev/null +++ b/packages/backend/src/services/note/delete.ts @@ -0,0 +1,141 @@ +import { Brackets, In } from 'typeorm'; +import { publishNoteStream } from '@/services/stream.js'; +import renderDelete from '@/remote/activitypub/renderer/delete.js'; +import renderAnnounce from '@/remote/activitypub/renderer/announce.js'; +import renderUndo from '@/remote/activitypub/renderer/undo.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderTombstone from '@/remote/activitypub/renderer/tombstone.js'; +import config from '@/config/index.js'; +import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js'; +import { Note, IMentionedRemoteUsers } from '@/models/entities/note.js'; +import { Notes, Users, Instances } from '@/models/index.js'; +import { notesChart, perUserNotesChart, instanceChart } from '@/services/chart/index.js'; +import { deliverToFollowers, deliverToUser } from '@/remote/activitypub/deliver-manager.js'; +import { countSameRenotes } from '@/misc/count-same-renotes.js'; +import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js'; +import { deliverToRelays } from '../relay.js'; + +/** + * 投稿を削除します。 + * @param user 投稿者 + * @param note 投稿 + */ +export default async function(user: { id: User['id']; uri: User['uri']; host: User['host']; }, note: Note, quiet = false) { + const deletedAt = new Date(); + + // この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき + if (note.renoteId && (await countSameRenotes(user.id, note.renoteId, note.id)) === 0) { + Notes.decrement({ id: note.renoteId }, 'renoteCount', 1); + Notes.decrement({ id: note.renoteId }, 'score', 1); + } + + if (note.replyId) { + await Notes.decrement({ id: note.replyId }, 'repliesCount', 1); + } + + if (!quiet) { + publishNoteStream(note.id, 'deleted', { + deletedAt: deletedAt, + }); + + //#region ローカルの投稿なら削除アクティビティを配送 + if (Users.isLocalUser(user) && !note.localOnly) { + let renote: Note | null = null; + + // if deletd note is renote + if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { + renote = await Notes.findOneBy({ + id: note.renoteId, + }); + } + + const content = renderActivity(renote + ? renderUndo(renderAnnounce(renote.uri || `${config.url}/notes/${renote.id}`, note), user) + : renderDelete(renderTombstone(`${config.url}/notes/${note.id}`), user)); + + deliverToConcerned(user, note, content); + } + + // also deliever delete activity to cascaded notes + const cascadingNotes = (await findCascadingNotes(note)).filter(note => !note.localOnly); // filter out local-only notes + for (const cascadingNote of cascadingNotes) { + if (!cascadingNote.user) continue; + if (!Users.isLocalUser(cascadingNote.user)) continue; + const content = renderActivity(renderDelete(renderTombstone(`${config.url}/notes/${cascadingNote.id}`), cascadingNote.user)); + deliverToConcerned(cascadingNote.user, cascadingNote, content); + } + //#endregion + + // 統計を更新 + notesChart.update(note, false); + perUserNotesChart.update(user, note, false); + + if (Users.isRemoteUser(user)) { + registerOrFetchInstanceDoc(user.host).then(i => { + Instances.decrement({ id: i.id }, 'notesCount', 1); + instanceChart.updateNote(i.host, note, false); + }); + } + } + + await Notes.delete({ + id: note.id, + userId: user.id, + }); +} + +async function findCascadingNotes(note: Note) { + const cascadingNotes: Note[] = []; + + const recursive = async (noteId: string) => { + const query = Notes.createQueryBuilder('note') + .where('note.replyId = :noteId', { noteId }) + .orWhere(new Brackets(q => { + q.where('note.renoteId = :noteId', { noteId }) + .andWhere('note.text IS NOT NULL'); + })) + .leftJoinAndSelect('note.user', 'user'); + const replies = await query.getMany(); + for (const reply of replies) { + cascadingNotes.push(reply); + await recursive(reply.id); + } + }; + await recursive(note.id); + + return cascadingNotes.filter(note => note.userHost === null); // filter out non-local users +} + +async function getMentionedRemoteUsers(note: Note) { + const where = [] as any[]; + + // mention / reply / dm + const uris = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); + if (uris.length > 0) { + where.push( + { uri: In(uris) }, + ); + } + + // renote / quote + if (note.renoteUserId) { + where.push({ + id: note.renoteUserId, + }); + } + + if (where.length === 0) return []; + + return await Users.find({ + where, + }) as IRemoteUser[]; +} + +async function deliverToConcerned(user: { id: ILocalUser['id']; host: null; }, note: Note, content: any) { + deliverToFollowers(user, content); + deliverToRelays(user, content); + const remoteUsers = await getMentionedRemoteUsers(note); + for (const remoteUser of remoteUsers) { + deliverToUser(user, content, remoteUser); + } +} diff --git a/packages/backend/src/services/note/polls/update.ts b/packages/backend/src/services/note/polls/update.ts new file mode 100644 index 0000000000..68cbb9835a --- /dev/null +++ b/packages/backend/src/services/note/polls/update.ts @@ -0,0 +1,21 @@ +import renderUpdate from '@/remote/activitypub/renderer/update.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderNote from '@/remote/activitypub/renderer/note.js'; +import { Users, Notes } from '@/models/index.js'; +import { Note } from '@/models/entities/note.js'; +import { deliverToFollowers } from '@/remote/activitypub/deliver-manager.js'; +import { deliverToRelays } from '../../relay.js'; + +export async function deliverQuestionUpdate(noteId: Note['id']) { + const note = await Notes.findOneBy({ id: noteId }); + if (note == null) throw new Error('note not found'); + + const user = await Users.findOneBy({ id: note.userId }); + if (user == null) throw new Error('note not found'); + + if (Users.isLocalUser(user)) { + const content = renderActivity(renderUpdate(await renderNote(note, false), user)); + deliverToFollowers(user, content); + deliverToRelays(user, content); + } +} diff --git a/packages/backend/src/services/note/polls/vote.ts b/packages/backend/src/services/note/polls/vote.ts new file mode 100644 index 0000000000..84d98769d9 --- /dev/null +++ b/packages/backend/src/services/note/polls/vote.ts @@ -0,0 +1,81 @@ +import { publishNoteStream } from '@/services/stream.js'; +import { CacheableUser, User } from '@/models/entities/user.js'; +import { Note } from '@/models/entities/note.js'; +import { PollVotes, NoteWatchings, Polls, Blockings } from '@/models/index.js'; +import { Not } from 'typeorm'; +import { genId } from '@/misc/gen-id.js'; +import { createNotification } from '../../create-notification.js'; + +export default async function(user: CacheableUser, note: Note, choice: number) { + const poll = await Polls.findOneBy({ noteId: note.id }); + + if (poll == null) throw new Error('poll not found'); + + // Check whether is valid choice + if (poll.choices[choice] == null) throw new Error('invalid choice param'); + + // Check blocking + if (note.userId !== user.id) { + const block = await Blockings.findOneBy({ + blockerId: note.userId, + blockeeId: user.id, + }); + if (block) { + throw new Error('blocked'); + } + } + + // if already voted + const exist = await PollVotes.findBy({ + noteId: note.id, + userId: user.id, + }); + + if (poll.multiple) { + if (exist.some(x => x.choice === choice)) { + throw new Error('already voted'); + } + } else if (exist.length !== 0) { + throw new Error('already voted'); + } + + // Create vote + await PollVotes.insert({ + id: genId(), + createdAt: new Date(), + noteId: note.id, + userId: user.id, + choice: choice, + }); + + // Increment votes count + const index = choice + 1; // In SQL, array index is 1 based + await Polls.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`); + + publishNoteStream(note.id, 'pollVoted', { + choice: choice, + userId: user.id, + }); + + // Notify + createNotification(note.userId, 'pollVote', { + notifierId: user.id, + noteId: note.id, + choice: choice, + }); + + // Fetch watchers + NoteWatchings.findBy({ + noteId: note.id, + userId: Not(user.id), + }) + .then(watchers => { + for (const watcher of watchers) { + createNotification(watcher.userId, 'pollVote', { + notifierId: user.id, + noteId: note.id, + choice: choice, + }); + } + }); +} diff --git a/packages/backend/src/services/note/reaction/create.ts b/packages/backend/src/services/note/reaction/create.ts new file mode 100644 index 0000000000..83d302826a --- /dev/null +++ b/packages/backend/src/services/note/reaction/create.ts @@ -0,0 +1,145 @@ +import { publishNoteStream } from '@/services/stream.js'; +import { renderLike } from '@/remote/activitypub/renderer/like.js'; +import DeliverManager from '@/remote/activitypub/deliver-manager.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import { toDbReaction, decodeReaction } from '@/misc/reaction-lib.js'; +import { User, IRemoteUser } from '@/models/entities/user.js'; +import { Note } from '@/models/entities/note.js'; +import { NoteReactions, Users, NoteWatchings, Notes, Emojis, Blockings } from '@/models/index.js'; +import { IsNull, Not } from 'typeorm'; +import { perUserReactionsChart } from '@/services/chart/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { createNotification } from '../../create-notification.js'; +import deleteReaction from './delete.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +import { NoteReaction } from '@/models/entities/note-reaction.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; + +export default async (user: { id: User['id']; host: User['host']; }, note: Note, reaction?: string) => { + // Check blocking + if (note.userId !== user.id) { + const block = await Blockings.findOneBy({ + blockerId: note.userId, + blockeeId: user.id, + }); + if (block) { + throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7'); + } + } + + // check visibility + if (!await Notes.isVisibleForMe(note, user.id)) { + throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); + } + + // TODO: cache + reaction = await toDbReaction(reaction, user.host); + + const record: NoteReaction = { + id: genId(), + createdAt: new Date(), + noteId: note.id, + userId: user.id, + reaction, + }; + + // Create reaction + try { + await NoteReactions.insert(record); + } catch (e) { + if (isDuplicateKeyValueError(e)) { + const exists = await NoteReactions.findOneByOrFail({ + noteId: note.id, + userId: user.id, + }); + + if (exists.reaction !== reaction) { + // 別のリアクションがすでにされていたら置き換える + await deleteReaction(user, note); + await NoteReactions.insert(record); + } else { + // 同じリアクションがすでにされていたらエラー + throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298'); + } + } else { + throw e; + } + } + + // Increment reactions count + const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; + await Notes.createQueryBuilder().update() + .set({ + reactions: () => sql, + score: () => '"score" + 1', + }) + .where('id = :id', { id: note.id }) + .execute(); + + perUserReactionsChart.update(user, note); + + // カスタム絵文字リアクションだったら絵文字情報も送る + const decodedReaction = decodeReaction(reaction); + + const emoji = await Emojis.findOne({ + where: { + name: decodedReaction.name, + host: decodedReaction.host ?? IsNull(), + }, + select: ['name', 'host', 'originalUrl', 'publicUrl'], + }); + + publishNoteStream(note.id, 'reacted', { + reaction: decodedReaction.reaction, + emoji: emoji != null ? { + name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}@.`, + url: emoji.publicUrl || emoji.originalUrl, // || emoji.originalUrl してるのは後方互換性のため + } : null, + userId: user.id, + }); + + // リアクションされたユーザーがローカルユーザーなら通知を作成 + if (note.userHost === null) { + createNotification(note.userId, 'reaction', { + notifierId: user.id, + noteId: note.id, + reaction: reaction, + }); + } + + // Fetch watchers + NoteWatchings.findBy({ + noteId: note.id, + userId: Not(user.id), + }).then(watchers => { + for (const watcher of watchers) { + createNotification(watcher.userId, 'reaction', { + notifierId: user.id, + noteId: note.id, + reaction: reaction, + }); + } + }); + + //#region 配信 + if (Users.isLocalUser(user) && !note.localOnly) { + const content = renderActivity(await renderLike(record, note)); + const dm = new DeliverManager(user, content); + if (note.userHost !== null) { + const reactee = await Users.findOneBy({ id: note.userId }); + dm.addDirectRecipe(reactee as IRemoteUser); + } + + if (['public', 'home', 'followers'].includes(note.visibility)) { + dm.addFollowersRecipe(); + } else if (note.visibility === 'specified') { + const visibleUsers = await Promise.all(note.visibleUserIds.map(id => Users.findOneBy({ id }))); + for (const u of visibleUsers.filter(u => u && Users.isRemoteUser(u))) { + dm.addDirectRecipe(u as IRemoteUser); + } + } + + dm.execute(); + } + //#endregion +}; diff --git a/packages/backend/src/services/note/reaction/delete.ts b/packages/backend/src/services/note/reaction/delete.ts new file mode 100644 index 0000000000..a7cbcb1c17 --- /dev/null +++ b/packages/backend/src/services/note/reaction/delete.ts @@ -0,0 +1,58 @@ +import { publishNoteStream } from '@/services/stream.js'; +import { renderLike } from '@/remote/activitypub/renderer/like.js'; +import renderUndo from '@/remote/activitypub/renderer/undo.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import DeliverManager from '@/remote/activitypub/deliver-manager.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { User, IRemoteUser } from '@/models/entities/user.js'; +import { Note } from '@/models/entities/note.js'; +import { NoteReactions, Users, Notes } from '@/models/index.js'; +import { decodeReaction } from '@/misc/reaction-lib.js'; + +export default async (user: { id: User['id']; host: User['host']; }, note: Note) => { + // if already unreacted + const exist = await NoteReactions.findOneBy({ + noteId: note.id, + userId: user.id, + }); + + if (exist == null) { + throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); + } + + // Delete reaction + const result = await NoteReactions.delete(exist.id); + + if (result.affected !== 1) { + throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); + } + + // Decrement reactions count + const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`; + await Notes.createQueryBuilder().update() + .set({ + reactions: () => sql, + }) + .where('id = :id', { id: note.id }) + .execute(); + + Notes.decrement({ id: note.id }, 'score', 1); + + publishNoteStream(note.id, 'unreacted', { + reaction: decodeReaction(exist.reaction).reaction, + userId: user.id, + }); + + //#region 配信 + if (Users.isLocalUser(user) && !note.localOnly) { + const content = renderActivity(renderUndo(await renderLike(exist, note), user)); + const dm = new DeliverManager(user, content); + if (note.userHost !== null) { + const reactee = await Users.findOneBy({ id: note.userId }); + dm.addDirectRecipe(reactee as IRemoteUser); + } + dm.addFollowersRecipe(); + dm.execute(); + } + //#endregion +}; diff --git a/packages/backend/src/services/note/read.ts b/packages/backend/src/services/note/read.ts new file mode 100644 index 0000000000..915a9e9eef --- /dev/null +++ b/packages/backend/src/services/note/read.ts @@ -0,0 +1,132 @@ +import { publishMainStream } from '@/services/stream.js'; +import { Note } from '@/models/entities/note.js'; +import { User } from '@/models/entities/user.js'; +import { NoteUnreads, AntennaNotes, Users, Followings, ChannelFollowings } from '@/models/index.js'; +import { Not, IsNull, In } from 'typeorm'; +import { Channel } from '@/models/entities/channel.js'; +import { checkHitAntenna } from '@/misc/check-hit-antenna.js'; +import { getAntennas } from '@/misc/antenna-cache.js'; +import { readNotificationByQuery } from '@/server/api/common/read-notification.js'; +import { Packed } from '@/misc/schema.js'; + +/** + * Mark notes as read + */ +export default async function( + userId: User['id'], + notes: (Note | Packed<'Note'>)[], + info?: { + following: Set; + followingChannels: Set; + } +) { + const following = info?.following ? info.following : new Set((await Followings.find({ + where: { + followerId: userId, + }, + select: ['followeeId'], + })).map(x => x.followeeId)); + const followingChannels = info?.followingChannels ? info.followingChannels : new Set((await ChannelFollowings.find({ + where: { + followerId: userId, + }, + select: ['followeeId'], + })).map(x => x.followeeId)); + + const myAntennas = (await getAntennas()).filter(a => a.userId === userId); + const readMentions: (Note | Packed<'Note'>)[] = []; + const readSpecifiedNotes: (Note | Packed<'Note'>)[] = []; + const readChannelNotes: (Note | Packed<'Note'>)[] = []; + const readAntennaNotes: (Note | Packed<'Note'>)[] = []; + + for (const note of notes) { + if (note.mentions && note.mentions.includes(userId)) { + readMentions.push(note); + } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) { + readSpecifiedNotes.push(note); + } + + if (note.channelId && followingChannels.has(note.channelId)) { + readChannelNotes.push(note); + } + + if (note.user != null) { // たぶんnullになることは無いはずだけど一応 + for (const antenna of myAntennas) { + if (await checkHitAntenna(antenna, note, note.user, undefined, Array.from(following))) { + readAntennaNotes.push(note); + } + } + } + } + + if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) { + // Remove the record + await NoteUnreads.delete({ + userId: userId, + noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]), + }); + + // TODO: ↓まとめてクエリしたい + + NoteUnreads.countBy({ + userId: userId, + isMentioned: true, + }).then(mentionsCount => { + if (mentionsCount === 0) { + // 全て既読になったイベントを発行 + publishMainStream(userId, 'readAllUnreadMentions'); + } + }); + + NoteUnreads.countBy({ + userId: userId, + isSpecified: true, + }).then(specifiedCount => { + if (specifiedCount === 0) { + // 全て既読になったイベントを発行 + publishMainStream(userId, 'readAllUnreadSpecifiedNotes'); + } + }); + + NoteUnreads.countBy({ + userId: userId, + noteChannelId: Not(IsNull()), + }).then(channelNoteCount => { + if (channelNoteCount === 0) { + // 全て既読になったイベントを発行 + publishMainStream(userId, 'readAllChannels'); + } + }); + + readNotificationByQuery(userId, { + noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]), + }); + } + + if (readAntennaNotes.length > 0) { + await AntennaNotes.update({ + antennaId: In(myAntennas.map(a => a.id)), + noteId: In(readAntennaNotes.map(n => n.id)), + }, { + read: true, + }); + + // TODO: まとめてクエリしたい + for (const antenna of myAntennas) { + const count = await AntennaNotes.countBy({ + antennaId: antenna.id, + read: false, + }); + + if (count === 0) { + publishMainStream(userId, 'readAntenna', antenna); + } + } + + Users.getHasUnreadAntenna(userId).then(unread => { + if (!unread) { + publishMainStream(userId, 'readAllAntennas'); + } + }); + } +} diff --git a/packages/backend/src/services/note/unread.ts b/packages/backend/src/services/note/unread.ts new file mode 100644 index 0000000000..d9ed711e03 --- /dev/null +++ b/packages/backend/src/services/note/unread.ts @@ -0,0 +1,55 @@ +import { Note } from '@/models/entities/note.js'; +import { publishMainStream } from '@/services/stream.js'; +import { User } from '@/models/entities/user.js'; +import { Mutings, NoteThreadMutings, NoteUnreads } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; + +export async function insertNoteUnread(userId: User['id'], note: Note, params: { + // NOTE: isSpecifiedがtrueならisMentionedは必ずfalse + isSpecified: boolean; + isMentioned: boolean; +}) { + //#region ミュートしているなら無視 + // TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする + const mute = await Mutings.findBy({ + muterId: userId, + }); + if (mute.map(m => m.muteeId).includes(note.userId)) return; + //#endregion + + // スレッドミュート + const threadMute = await NoteThreadMutings.findOneBy({ + userId: userId, + threadId: note.threadId || note.id, + }); + if (threadMute) return; + + const unread = { + id: genId(), + noteId: note.id, + userId: userId, + isSpecified: params.isSpecified, + isMentioned: params.isMentioned, + noteChannelId: note.channelId, + noteUserId: note.userId, + }; + + await NoteUnreads.insert(unread); + + // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する + setTimeout(async () => { + const exist = await NoteUnreads.findOneBy({ id: unread.id }); + + if (exist == null) return; + + if (params.isMentioned) { + publishMainStream(userId, 'unreadMention', note.id); + } + if (params.isSpecified) { + publishMainStream(userId, 'unreadSpecifiedNote', note.id); + } + if (note.channelId) { + publishMainStream(userId, 'unreadChannel', note.id); + } + }, 2000); +} diff --git a/packages/backend/src/services/note/unwatch.ts b/packages/backend/src/services/note/unwatch.ts new file mode 100644 index 0000000000..3964b2ba5f --- /dev/null +++ b/packages/backend/src/services/note/unwatch.ts @@ -0,0 +1,10 @@ +import { User } from '@/models/entities/user.js'; +import { NoteWatchings } from '@/models/index.js'; +import { Note } from '@/models/entities/note.js'; + +export default async (me: User['id'], note: Note) => { + await NoteWatchings.delete({ + noteId: note.id, + userId: me, + }); +}; diff --git a/packages/backend/src/services/note/watch.ts b/packages/backend/src/services/note/watch.ts new file mode 100644 index 0000000000..2210c44a75 --- /dev/null +++ b/packages/backend/src/services/note/watch.ts @@ -0,0 +1,20 @@ +import { User } from '@/models/entities/user.js'; +import { Note } from '@/models/entities/note.js'; +import { NoteWatchings } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { NoteWatching } from '@/models/entities/note-watching.js'; + +export default async (me: User['id'], note: Note) => { + // 自分の投稿はwatchできない + if (me === note.userId) { + return; + } + + await NoteWatchings.insert({ + id: genId(), + createdAt: new Date(), + noteId: note.id, + userId: me, + noteUserId: note.userId, + } as NoteWatching); +}; diff --git a/packages/backend/src/services/push-notification.ts b/packages/backend/src/services/push-notification.ts new file mode 100644 index 0000000000..393a23d050 --- /dev/null +++ b/packages/backend/src/services/push-notification.ts @@ -0,0 +1,85 @@ +import push from 'web-push'; +import config from '@/config/index.js'; +import { SwSubscriptions } from '@/models/index.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Packed } from '@/misc/schema.js'; +import { getNoteSummary } from '@/misc/get-note-summary.js'; + +// Defined also packages/sw/types.ts#L14-L21 +type pushNotificationsTypes = { + 'notification': Packed<'Notification'>; + 'unreadMessagingMessage': Packed<'MessagingMessage'>; + 'readNotifications': { notificationIds: string[] }; + 'readAllNotifications': undefined; + 'readAllMessagingMessages': undefined; + 'readAllMessagingMessagesOfARoom': { userId: string } | { groupId: string }; +}; + +// プッシュメッセージサーバーには文字数制限があるため、内容を削減します +function truncateNotification(notification: Packed<'Notification'>): any { + if (notification.note) { + return { + ...notification, + note: { + ...notification.note, + // textをgetNoteSummaryしたものに置き換える + text: getNoteSummary(notification.type === 'renote' ? notification.note.renote as Packed<'Note'> : notification.note), + + cw: undefined, + reply: undefined, + renote: undefined, + user: undefined as any, // 通知を受け取ったユーザーである場合が多いのでこれも捨てる + } + }; + } + + return notification; +} + +export async function pushNotification(userId: string, type: T, body: pushNotificationsTypes[T]) { + const meta = await fetchMeta(); + + if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return; + + // アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録 + push.setVapidDetails(config.url, + meta.swPublicKey, + meta.swPrivateKey); + + // Fetch + const subscriptions = await SwSubscriptions.findBy({ + userId: userId, + }); + + for (const subscription of subscriptions) { + const pushSubscription = { + endpoint: subscription.endpoint, + keys: { + auth: subscription.auth, + p256dh: subscription.publickey, + }, + }; + + push.sendNotification(pushSubscription, JSON.stringify({ + type, + body: type === 'notification' ? truncateNotification(body as Packed<'Notification'>) : body, + userId, + dateTime: (new Date()).getTime(), + }), { + proxy: config.proxy, + }).catch((err: any) => { + //swLogger.info(err.statusCode); + //swLogger.info(err.headers); + //swLogger.info(err.body); + + if (err.statusCode === 410) { + SwSubscriptions.delete({ + userId: userId, + endpoint: subscription.endpoint, + auth: subscription.auth, + publickey: subscription.publickey, + }); + } + }); + } +} diff --git a/packages/backend/src/services/register-or-fetch-instance-doc.ts b/packages/backend/src/services/register-or-fetch-instance-doc.ts new file mode 100644 index 0000000000..df7d125d0b --- /dev/null +++ b/packages/backend/src/services/register-or-fetch-instance-doc.ts @@ -0,0 +1,31 @@ +import { Instance } from '@/models/entities/instance.js'; +import { Instances } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { toPuny } from '@/misc/convert-host.js'; +import { Cache } from '@/misc/cache.js'; + +const cache = new Cache(1000 * 60 * 60); + +export async function registerOrFetchInstanceDoc(host: string): Promise { + host = toPuny(host); + + const cached = cache.get(host); + if (cached) return cached; + + const index = await Instances.findOneBy({ host }); + + if (index == null) { + const i = await Instances.insert({ + id: genId(), + host, + caughtAt: new Date(), + lastCommunicatedAt: new Date(), + }).then(x => Instances.findOneByOrFail(x.identifiers[0])); + + cache.set(host, i); + return i; + } else { + cache.set(host, index); + return index; + } +} diff --git a/packages/backend/src/services/relay.ts b/packages/backend/src/services/relay.ts new file mode 100644 index 0000000000..6bc4304436 --- /dev/null +++ b/packages/backend/src/services/relay.ts @@ -0,0 +1,101 @@ +import { IsNull } from 'typeorm'; +import { renderFollowRelay } from '@/remote/activitypub/renderer/follow-relay.js'; +import { renderActivity, attachLdSignature } from '@/remote/activitypub/renderer/index.js'; +import renderUndo from '@/remote/activitypub/renderer/undo.js'; +import { deliver } from '@/queue/index.js'; +import { ILocalUser, User } from '@/models/entities/user.js'; +import { Users, Relays } from '@/models/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { Cache } from '@/misc/cache.js'; +import { Relay } from '@/models/entities/relay.js'; +import { createSystemUser } from './create-system-user.js'; + +const ACTOR_USERNAME = 'relay.actor' as const; + +const relaysCache = new Cache(1000 * 60 * 10); + +export async function getRelayActor(): Promise { + const user = await Users.findOneBy({ + host: IsNull(), + username: ACTOR_USERNAME, + }); + + if (user) return user as ILocalUser; + + const created = await createSystemUser(ACTOR_USERNAME); + return created as ILocalUser; +} + +export async function addRelay(inbox: string) { + const relay = await Relays.insert({ + id: genId(), + inbox, + status: 'requesting', + }).then(x => Relays.findOneByOrFail(x.identifiers[0])); + + const relayActor = await getRelayActor(); + const follow = await renderFollowRelay(relay, relayActor); + const activity = renderActivity(follow); + deliver(relayActor, activity, relay.inbox); + + return relay; +} + +export async function removeRelay(inbox: string) { + const relay = await Relays.findOneBy({ + inbox, + }); + + if (relay == null) { + throw 'relay not found'; + } + + const relayActor = await getRelayActor(); + const follow = renderFollowRelay(relay, relayActor); + const undo = renderUndo(follow, relayActor); + const activity = renderActivity(undo); + deliver(relayActor, activity, relay.inbox); + + await Relays.delete(relay.id); +} + +export async function listRelay() { + const relays = await Relays.find(); + return relays; +} + +export async function relayAccepted(id: string) { + const result = await Relays.update(id, { + status: 'accepted', + }); + + return JSON.stringify(result); +} + +export async function relayRejected(id: string) { + const result = await Relays.update(id, { + status: 'rejected', + }); + + return JSON.stringify(result); +} + +export async function deliverToRelays(user: { id: User['id']; host: null; }, activity: any) { + if (activity == null) return; + + const relays = await relaysCache.fetch(null, () => Relays.findBy({ + status: 'accepted', + })); + if (relays.length === 0) return; + + // TODO + //const copy = structuredClone(activity); + const copy = JSON.parse(JSON.stringify(activity)); + if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public']; + + const signed = await attachLdSignature(copy, user); + + for (const relay of relays) { + deliver(user, signed, relay.inbox); + } +} diff --git a/packages/backend/src/services/send-email-notification.ts b/packages/backend/src/services/send-email-notification.ts new file mode 100644 index 0000000000..4a2f94b425 --- /dev/null +++ b/packages/backend/src/services/send-email-notification.ts @@ -0,0 +1,36 @@ +import { UserProfiles } from '@/models/index.js'; +import { User } from '@/models/entities/user.js'; +import { sendEmail } from './send-email.js'; +import { I18n } from '@/misc/i18n.js'; +import * as Acct from '@/misc/acct.js'; +// TODO +//const locales = await import('../../../../locales/index.js'); + +// TODO: locale ファイルをクライアント用とサーバー用で分けたい + +async function follow(userId: User['id'], follower: User) { + /* + const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); + if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return; + const locale = locales[userProfile.lang || 'ja-JP']; + const i18n = new I18n(locale); + // TODO: render user information html + sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); + */ +} + +async function receiveFollowRequest(userId: User['id'], follower: User) { + /* + const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); + if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return; + const locale = locales[userProfile.lang || 'ja-JP']; + const i18n = new I18n(locale); + // TODO: render user information html + sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); + */ +} + +export const sendEmailNotification = { + follow, + receiveFollowRequest, +}; diff --git a/packages/backend/src/services/send-email.ts b/packages/backend/src/services/send-email.ts new file mode 100644 index 0000000000..b35d22548c --- /dev/null +++ b/packages/backend/src/services/send-email.ts @@ -0,0 +1,122 @@ +import * as nodemailer from 'nodemailer'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import Logger from './logger.js'; +import config from '@/config/index.js'; + +export const logger = new Logger('email'); + +export async function sendEmail(to: string, subject: string, html: string, text: string) { + const meta = await fetchMeta(true); + + const iconUrl = `${config.url}/static-assets/mi-white.png`; + const emailSettingUrl = `${config.url}/settings/email`; + + const enableAuth = meta.smtpUser != null && meta.smtpUser !== ''; + + const transporter = nodemailer.createTransport({ + host: meta.smtpHost, + port: meta.smtpPort, + secure: meta.smtpSecure, + ignoreTLS: !enableAuth, + proxy: config.proxySmtp, + auth: enableAuth ? { + user: meta.smtpUser, + pass: meta.smtpPass, + } : undefined, + } as any); + + try { + // TODO: htmlサニタイズ + const info = await transporter.sendMail({ + from: meta.email!, + to: to, + subject: subject, + text: text, + html: ` + + + + ${ subject } + + + +
+
+ +
+
+

${ subject }

+
${ html }
+
+ +
+ + +`, + }); + + logger.info(`Message sent: ${info.messageId}`); + } catch (err) { + logger.error(err as Error); + throw err; + } +} diff --git a/packages/backend/src/services/stream.ts b/packages/backend/src/services/stream.ts new file mode 100644 index 0000000000..9fa2b97135 --- /dev/null +++ b/packages/backend/src/services/stream.ts @@ -0,0 +1,116 @@ +import { redisClient } from '../db/redis.js'; +import { User } from '@/models/entities/user.js'; +import { Note } from '@/models/entities/note.js'; +import { UserList } from '@/models/entities/user-list.js'; +import { UserGroup } from '@/models/entities/user-group.js'; +import config from '@/config/index.js'; +import { Antenna } from '@/models/entities/antenna.js'; +import { Channel } from '@/models/entities/channel.js'; +import { + StreamChannels, + AdminStreamTypes, + AntennaStreamTypes, + BroadcastTypes, + ChannelStreamTypes, + DriveStreamTypes, + GroupMessagingStreamTypes, + InternalStreamTypes, + MainStreamTypes, + MessagingIndexStreamTypes, + MessagingStreamTypes, + NoteStreamTypes, + UserListStreamTypes, + UserStreamTypes, +} from '@/server/api/stream/types.js'; +import { Packed } from '@/misc/schema.js'; + +class Publisher { + private publish = (channel: StreamChannels, type: string | null, value?: any): void => { + const message = type == null ? value : value == null ? + { type: type, body: null } : + { type: type, body: value }; + + redisClient.publish(config.host, JSON.stringify({ + channel: channel, + message: message, + })); + }; + + public publishInternalEvent = (type: K, value?: InternalStreamTypes[K]): void => { + this.publish('internal', type, typeof value === 'undefined' ? null : value); + }; + + public publishUserEvent = (userId: User['id'], type: K, value?: UserStreamTypes[K]): void => { + this.publish(`user:${userId}`, type, typeof value === 'undefined' ? null : value); + }; + + public publishBroadcastStream = (type: K, value?: BroadcastTypes[K]): void => { + this.publish('broadcast', type, typeof value === 'undefined' ? null : value); + }; + + public publishMainStream = (userId: User['id'], type: K, value?: MainStreamTypes[K]): void => { + this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value); + }; + + public publishDriveStream = (userId: User['id'], type: K, value?: DriveStreamTypes[K]): void => { + this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value); + }; + + public publishNoteStream = (noteId: Note['id'], type: K, value?: NoteStreamTypes[K]): void => { + this.publish(`noteStream:${noteId}`, type, { + id: noteId, + body: value, + }); + }; + + public publishChannelStream = (channelId: Channel['id'], type: K, value?: ChannelStreamTypes[K]): void => { + this.publish(`channelStream:${channelId}`, type, typeof value === 'undefined' ? null : value); + }; + + public publishUserListStream = (listId: UserList['id'], type: K, value?: UserListStreamTypes[K]): void => { + this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value); + }; + + public publishAntennaStream = (antennaId: Antenna['id'], type: K, value?: AntennaStreamTypes[K]): void => { + this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); + }; + + public publishMessagingStream = (userId: User['id'], otherpartyId: User['id'], type: K, value?: MessagingStreamTypes[K]): void => { + this.publish(`messagingStream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); + }; + + public publishGroupMessagingStream = (groupId: UserGroup['id'], type: K, value?: GroupMessagingStreamTypes[K]): void => { + this.publish(`messagingStream:${groupId}`, type, typeof value === 'undefined' ? null : value); + }; + + public publishMessagingIndexStream = (userId: User['id'], type: K, value?: MessagingIndexStreamTypes[K]): void => { + this.publish(`messagingIndexStream:${userId}`, type, typeof value === 'undefined' ? null : value); + }; + + public publishNotesStream = (note: Packed<'Note'>): void => { + this.publish('notesStream', null, note); + }; + + public publishAdminStream = (userId: User['id'], type: K, value?: AdminStreamTypes[K]): void => { + this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value); + }; +} + +const publisher = new Publisher(); + +export default publisher; + +export const publishInternalEvent = publisher.publishInternalEvent; +export const publishUserEvent = publisher.publishUserEvent; +export const publishBroadcastStream = publisher.publishBroadcastStream; +export const publishMainStream = publisher.publishMainStream; +export const publishDriveStream = publisher.publishDriveStream; +export const publishNoteStream = publisher.publishNoteStream; +export const publishNotesStream = publisher.publishNotesStream; +export const publishChannelStream = publisher.publishChannelStream; +export const publishUserListStream = publisher.publishUserListStream; +export const publishAntennaStream = publisher.publishAntennaStream; +export const publishMessagingStream = publisher.publishMessagingStream; +export const publishGroupMessagingStream = publisher.publishGroupMessagingStream; +export const publishMessagingIndexStream = publisher.publishMessagingIndexStream; +export const publishAdminStream = publisher.publishAdminStream; diff --git a/packages/backend/src/services/suspend-user.ts b/packages/backend/src/services/suspend-user.ts new file mode 100644 index 0000000000..e96b06a351 --- /dev/null +++ b/packages/backend/src/services/suspend-user.ts @@ -0,0 +1,37 @@ +import renderDelete from '@/remote/activitypub/renderer/delete.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import { deliver } from '@/queue/index.js'; +import config from '@/config/index.js'; +import { User } from '@/models/entities/user.js'; +import { Users, Followings } from '@/models/index.js'; +import { Not, IsNull } from 'typeorm'; +import { publishInternalEvent } from '@/services/stream.js'; + +export async function doPostSuspend(user: { id: User['id']; host: User['host'] }) { + publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true }); + + if (Users.isLocalUser(user)) { + // 知り得る全SharedInboxにDelete配信 + const content = renderActivity(renderDelete(`${config.url}/users/${user.id}`, user)); + + const queue: string[] = []; + + const followings = await Followings.find({ + where: [ + { followerSharedInbox: Not(IsNull()) }, + { followeeSharedInbox: Not(IsNull()) }, + ], + select: ['followerSharedInbox', 'followeeSharedInbox'], + }); + + const inboxes = followings.map(x => x.followerSharedInbox || x.followeeSharedInbox); + + for (const inbox of inboxes) { + if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + } + + for (const inbox of queue) { + deliver(user, content, inbox); + } + } +} diff --git a/packages/backend/src/services/unsuspend-user.ts b/packages/backend/src/services/unsuspend-user.ts new file mode 100644 index 0000000000..44a0d01ca2 --- /dev/null +++ b/packages/backend/src/services/unsuspend-user.ts @@ -0,0 +1,38 @@ +import renderDelete from '@/remote/activitypub/renderer/delete.js'; +import renderUndo from '@/remote/activitypub/renderer/undo.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import { deliver } from '@/queue/index.js'; +import config from '@/config/index.js'; +import { User } from '@/models/entities/user.js'; +import { Users, Followings } from '@/models/index.js'; +import { Not, IsNull } from 'typeorm'; +import { publishInternalEvent } from '@/services/stream.js'; + +export async function doPostUnsuspend(user: User) { + publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false }); + + if (Users.isLocalUser(user)) { + // 知り得る全SharedInboxにUndo Delete配信 + const content = renderActivity(renderUndo(renderDelete(`${config.url}/users/${user.id}`, user), user)); + + const queue: string[] = []; + + const followings = await Followings.find({ + where: [ + { followerSharedInbox: Not(IsNull()) }, + { followeeSharedInbox: Not(IsNull()) }, + ], + select: ['followerSharedInbox', 'followeeSharedInbox'], + }); + + const inboxes = followings.map(x => x.followerSharedInbox || x.followeeSharedInbox); + + for (const inbox of inboxes) { + if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + } + + for (const inbox of queue) { + deliver(user as any, content, inbox); + } + } +} diff --git a/packages/backend/src/services/update-hashtag.ts b/packages/backend/src/services/update-hashtag.ts new file mode 100644 index 0000000000..23b210b7a9 --- /dev/null +++ b/packages/backend/src/services/update-hashtag.ts @@ -0,0 +1,128 @@ +import { User } from '@/models/entities/user.js'; +import { Hashtags, Users } from '@/models/index.js'; +import { hashtagChart } from '@/services/chart/index.js'; +import { genId } from '@/misc/gen-id.js'; +import { Hashtag } from '@/models/entities/hashtag.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; + +export async function updateHashtags(user: { id: User['id']; host: User['host']; }, tags: string[]) { + for (const tag of tags) { + await updateHashtag(user, tag); + } +} + +export async function updateUsertags(user: User, tags: string[]) { + for (const tag of tags) { + await updateHashtag(user, tag, true, true); + } + + for (const tag of (user.tags || []).filter(x => !tags.includes(x))) { + await updateHashtag(user, tag, true, false); + } +} + +export async function updateHashtag(user: { id: User['id']; host: User['host']; }, tag: string, isUserAttached = false, inc = true) { + tag = normalizeForSearch(tag); + + const index = await Hashtags.findOneBy({ name: tag }); + + if (index == null && !inc) return; + + if (index != null) { + const q = Hashtags.createQueryBuilder('tag').update() + .where('name = :name', { name: tag }); + + const set = {} as any; + + if (isUserAttached) { + if (inc) { + // 自分が初めてこのタグを使ったなら + if (!index.attachedUserIds.some(id => id === user.id)) { + set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`; + set.attachedUsersCount = () => `"attachedUsersCount" + 1`; + } + // 自分が(ローカル内で)初めてこのタグを使ったなら + if (Users.isLocalUser(user) && !index.attachedLocalUserIds.some(id => id === user.id)) { + set.attachedLocalUserIds = () => `array_append("attachedLocalUserIds", '${user.id}')`; + set.attachedLocalUsersCount = () => `"attachedLocalUsersCount" + 1`; + } + // 自分が(リモートで)初めてこのタグを使ったなら + if (Users.isRemoteUser(user) && !index.attachedRemoteUserIds.some(id => id === user.id)) { + set.attachedRemoteUserIds = () => `array_append("attachedRemoteUserIds", '${user.id}')`; + set.attachedRemoteUsersCount = () => `"attachedRemoteUsersCount" + 1`; + } + } else { + set.attachedUserIds = () => `array_remove("attachedUserIds", '${user.id}')`; + set.attachedUsersCount = () => `"attachedUsersCount" - 1`; + if (Users.isLocalUser(user)) { + set.attachedLocalUserIds = () => `array_remove("attachedLocalUserIds", '${user.id}')`; + set.attachedLocalUsersCount = () => `"attachedLocalUsersCount" - 1`; + } else { + set.attachedRemoteUserIds = () => `array_remove("attachedRemoteUserIds", '${user.id}')`; + set.attachedRemoteUsersCount = () => `"attachedRemoteUsersCount" - 1`; + } + } + } else { + // 自分が初めてこのタグを使ったなら + if (!index.mentionedUserIds.some(id => id === user.id)) { + set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`; + set.mentionedUsersCount = () => `"mentionedUsersCount" + 1`; + } + // 自分が(ローカル内で)初めてこのタグを使ったなら + if (Users.isLocalUser(user) && !index.mentionedLocalUserIds.some(id => id === user.id)) { + set.mentionedLocalUserIds = () => `array_append("mentionedLocalUserIds", '${user.id}')`; + set.mentionedLocalUsersCount = () => `"mentionedLocalUsersCount" + 1`; + } + // 自分が(リモートで)初めてこのタグを使ったなら + if (Users.isRemoteUser(user) && !index.mentionedRemoteUserIds.some(id => id === user.id)) { + set.mentionedRemoteUserIds = () => `array_append("mentionedRemoteUserIds", '${user.id}')`; + set.mentionedRemoteUsersCount = () => `"mentionedRemoteUsersCount" + 1`; + } + } + + if (Object.keys(set).length > 0) { + q.set(set); + q.execute(); + } + } else { + if (isUserAttached) { + Hashtags.insert({ + id: genId(), + name: tag, + mentionedUserIds: [], + mentionedUsersCount: 0, + mentionedLocalUserIds: [], + mentionedLocalUsersCount: 0, + mentionedRemoteUserIds: [], + mentionedRemoteUsersCount: 0, + attachedUserIds: [user.id], + attachedUsersCount: 1, + attachedLocalUserIds: Users.isLocalUser(user) ? [user.id] : [], + attachedLocalUsersCount: Users.isLocalUser(user) ? 1 : 0, + attachedRemoteUserIds: Users.isRemoteUser(user) ? [user.id] : [], + attachedRemoteUsersCount: Users.isRemoteUser(user) ? 1 : 0, + } as Hashtag); + } else { + Hashtags.insert({ + id: genId(), + name: tag, + mentionedUserIds: [user.id], + mentionedUsersCount: 1, + mentionedLocalUserIds: Users.isLocalUser(user) ? [user.id] : [], + mentionedLocalUsersCount: Users.isLocalUser(user) ? 1 : 0, + mentionedRemoteUserIds: Users.isRemoteUser(user) ? [user.id] : [], + mentionedRemoteUsersCount: Users.isRemoteUser(user) ? 1 : 0, + attachedUserIds: [], + attachedUsersCount: 0, + attachedLocalUserIds: [], + attachedLocalUsersCount: 0, + attachedRemoteUserIds: [], + attachedRemoteUsersCount: 0, + } as Hashtag); + } + } + + if (!isUserAttached) { + hashtagChart.update(tag, user); + } +} diff --git a/packages/backend/src/services/user-cache.ts b/packages/backend/src/services/user-cache.ts new file mode 100644 index 0000000000..407301f2fd --- /dev/null +++ b/packages/backend/src/services/user-cache.ts @@ -0,0 +1,44 @@ +import { CacheableLocalUser, CacheableUser, ILocalUser, User } from '@/models/entities/user.js'; +import { Users } from '@/models/index.js'; +import { Cache } from '@/misc/cache.js'; +import { subsdcriber } from '@/db/redis.js'; + +export const userByIdCache = new Cache(Infinity); +export const localUserByNativeTokenCache = new Cache(Infinity); +export const localUserByIdCache = new Cache(Infinity); +export const uriPersonCache = new Cache(Infinity); + +subsdcriber.on('message', async (_, data) => { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message; + switch (type) { + case 'userChangeSuspendedState': + case 'userChangeSilencedState': + case 'userChangeModeratorState': + case 'remoteUserUpdated': { + const user = await Users.findOneByOrFail({ id: body.id }); + userByIdCache.set(user.id, user); + for (const [k, v] of uriPersonCache.cache.entries()) { + if (v.value?.id === user.id) { + uriPersonCache.set(k, user); + } + } + if (Users.isLocalUser(user)) { + localUserByNativeTokenCache.set(user.token, user); + localUserByIdCache.set(user.id, user); + } + break; + } + case 'userTokenRegenerated': { + const user = await Users.findOneByOrFail({ id: body.id }) as ILocalUser; + localUserByNativeTokenCache.delete(body.oldToken); + localUserByNativeTokenCache.set(body.newToken, user); + break; + } + default: + break; + } + } +}); diff --git a/packages/backend/src/services/user-list/push.ts b/packages/backend/src/services/user-list/push.ts new file mode 100644 index 0000000000..d073afcd3a --- /dev/null +++ b/packages/backend/src/services/user-list/push.ts @@ -0,0 +1,27 @@ +import { publishUserListStream } from '@/services/stream.js'; +import { User } from '@/models/entities/user.js'; +import { UserList } from '@/models/entities/user-list.js'; +import { UserListJoinings, Users } from '@/models/index.js'; +import { UserListJoining } from '@/models/entities/user-list-joining.js'; +import { genId } from '@/misc/gen-id.js'; +import { fetchProxyAccount } from '@/misc/fetch-proxy-account.js'; +import createFollowing from '../following/create.js'; + +export async function pushUserToUserList(target: User, list: UserList) { + await UserListJoinings.insert({ + id: genId(), + createdAt: new Date(), + userId: target.id, + userListId: list.id, + } as UserListJoining); + + publishUserListStream(list.id, 'userAdded', await Users.pack(target)); + + // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする + if (Users.isRemoteUser(target)) { + const proxy = await fetchProxyAccount(); + if (proxy) { + createFollowing(proxy, target); + } + } +} diff --git a/packages/backend/src/services/validate-email-for-account.ts b/packages/backend/src/services/validate-email-for-account.ts new file mode 100644 index 0000000000..b5fa99b935 --- /dev/null +++ b/packages/backend/src/services/validate-email-for-account.ts @@ -0,0 +1,37 @@ +import { validate as validateEmail } from 'deep-email-validator'; +import { UserProfiles } from '@/models/index.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; + +export async function validateEmailForAccount(emailAddress: string): Promise<{ + available: boolean; + reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp'; +}> { + const meta = await fetchMeta(); + + const exist = await UserProfiles.countBy({ + emailVerified: true, + email: emailAddress, + }); + + const validated = meta.enableActiveEmailValidation ? await validateEmail({ + email: emailAddress, + validateRegex: true, + validateMx: true, + validateTypo: false, // TLDを見ているみたいだけどclubとか弾かれるので + validateDisposable: true, // 捨てアドかどうかチェック + validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので + }) : { valid: true }; + + const available = exist === 0 && validated.valid; + + return { + available, + reason: available ? null : + exist !== 0 ? 'used' : + validated.reason === 'regex' ? 'format' : + validated.reason === 'disposable' ? 'disposable' : + validated.reason === 'mx' ? 'mx' : + validated.reason === 'smtp' ? 'smtp' : + null, + }; +} diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index e55952f296..573e2faf87 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -1,293 +1,7 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -/** - * note - 通知オンにしているユーザーが投稿した - * follow - フォローされた - * mention - 投稿で自分が言及された - * reply - 投稿に返信された - * renote - 投稿がRenoteされた - * quote - 投稿が引用Renoteされた - * reaction - 投稿にリアクションされた - * pollEnded - 自分のアンケートもしくは自分が投票したアンケートが終了した - * receiveFollowRequest - フォローリクエストされた - * followRequestAccepted - 自分の送ったフォローリクエストが承認された - * roleAssigned - ロールが付与された - * achievementEarned - 実績を獲得 - * app - アプリ通知 - * test - テスト通知(サーバー側) - */ -export const notificationTypes = [ - 'note', - 'follow', - 'mention', - 'reply', - 'renote', - 'quote', - 'reaction', - 'pollEnded', - 'receiveFollowRequest', - 'followRequestAccepted', - 'roleAssigned', - 'achievementEarned', - 'app', - 'test'] as const; -export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const; +export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'] as const; export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const; -export const followingVisibilities = ['public', 'followers', 'private'] as const; -export const followersVisibilities = ['public', 'followers', 'private'] as const; - -export const moderationLogTypes = [ - 'updateServerSettings', - 'suspend', - 'approve', - 'unsuspend', - 'updateUserNote', - 'addCustomEmoji', - 'updateCustomEmoji', - 'deleteCustomEmoji', - 'assignRole', - 'unassignRole', - 'createRole', - 'updateRole', - 'deleteRole', - 'clearQueue', - 'promoteQueue', - 'deleteDriveFile', - 'deleteNote', - 'createGlobalAnnouncement', - 'createUserAnnouncement', - 'updateGlobalAnnouncement', - 'updateUserAnnouncement', - 'deleteGlobalAnnouncement', - 'deleteUserAnnouncement', - 'resetPassword', - 'suspendRemoteInstance', - 'unsuspendRemoteInstance', - 'markSensitiveDriveFile', - 'unmarkSensitiveDriveFile', - 'resolveAbuseReport', - 'createInvitation', - 'createAd', - 'updateAd', - 'deleteAd', - 'createAvatarDecoration', - 'updateAvatarDecoration', - 'deleteAvatarDecoration', - 'unsetUserAvatar', - 'unsetUserBanner', -] as const; - -export type ModerationLogPayloads = { - updateServerSettings: { - before: any | null; - after: any | null; - }; - suspend: { - userId: string; - userUsername: string; - userHost: string | null; - }; - approve: { - userId: string; - userUsername: string; - userHost: string | null; - }; - unsuspend: { - userId: string; - userUsername: string; - userHost: string | null; - }; - updateUserNote: { - userId: string; - userUsername: string; - userHost: string | null; - before: string | null; - after: string | null; - }; - addCustomEmoji: { - emojiId: string; - emoji: any; - }; - updateCustomEmoji: { - emojiId: string; - before: any; - after: any; - }; - deleteCustomEmoji: { - emojiId: string; - emoji: any; - }; - assignRole: { - userId: string; - userUsername: string; - userHost: string | null; - roleId: string; - roleName: string; - expiresAt: string | null; - }; - unassignRole: { - userId: string; - userUsername: string; - userHost: string | null; - roleId: string; - roleName: string; - }; - createRole: { - roleId: string; - role: any; - }; - updateRole: { - roleId: string; - before: any; - after: any; - }; - deleteRole: { - roleId: string; - role: any; - }; - clearQueue: Record; - promoteQueue: Record; - deleteDriveFile: { - fileId: string; - fileUserId: string | null; - fileUserUsername: string | null; - fileUserHost: string | null; - }; - deleteNote: { - noteId: string; - noteUserId: string; - noteUserUsername: string; - noteUserHost: string | null; - note: any; - }; - createGlobalAnnouncement: { - announcementId: string; - announcement: any; - }; - createUserAnnouncement: { - announcementId: string; - announcement: any; - userId: string; - userUsername: string; - userHost: string | null; - }; - updateGlobalAnnouncement: { - announcementId: string; - before: any; - after: any; - }; - updateUserAnnouncement: { - announcementId: string; - before: any; - after: any; - userId: string; - userUsername: string; - userHost: string | null; - }; - deleteGlobalAnnouncement: { - announcementId: string; - announcement: any; - }; - deleteUserAnnouncement: { - announcementId: string; - announcement: any; - userId: string; - userUsername: string; - userHost: string | null; - }; - resetPassword: { - userId: string; - userUsername: string; - userHost: string | null; - }; - suspendRemoteInstance: { - id: string; - host: string; - }; - unsuspendRemoteInstance: { - id: string; - host: string; - }; - markSensitiveDriveFile: { - fileId: string; - fileUserId: string | null; - fileUserUsername: string | null; - fileUserHost: string | null; - }; - unmarkSensitiveDriveFile: { - fileId: string; - fileUserId: string | null; - fileUserUsername: string | null; - fileUserHost: string | null; - }; - resolveAbuseReport: { - reportId: string; - report: any; - forwarded: boolean; - }; - createInvitation: { - invitations: any[]; - }; - createAd: { - adId: string; - ad: any; - }; - updateAd: { - adId: string; - before: any; - after: any; - }; - deleteAd: { - adId: string; - ad: any; - }; - createAvatarDecoration: { - avatarDecorationId: string; - avatarDecoration: any; - }; - updateAvatarDecoration: { - avatarDecorationId: string; - before: any; - after: any; - }; - deleteAvatarDecoration: { - avatarDecorationId: string; - avatarDecoration: any; - }; - unsetUserAvatar: { - userId: string; - userUsername: string; - userHost: string | null; - fileId: string; - }; - unsetUserBanner: { - userId: string; - userUsername: string; - userHost: string | null; - fileId: string; - }; -}; - -export type Serialized = { - [K in keyof T]: - T[K] extends Date - ? string - : T[K] extends (Date | null) - ? (string | null) - : T[K] extends Record - ? Serialized - : T[K]; -}; - -export type FilterUnionByProperty< - Union, - Property extends string | number | symbol, - Condition -> = Union extends Record ? Union : never; +export const ffVisibility = ['public', 'followers', 'private'] as const; diff --git a/packages/backend/test/.eslintrc.cjs b/packages/backend/test/.eslintrc.cjs index 41ecea0c3f..d83dc37d2f 100644 --- a/packages/backend/test/.eslintrc.cjs +++ b/packages/backend/test/.eslintrc.cjs @@ -6,6 +6,6 @@ module.exports = { extends: ['../.eslintrc.cjs'], env: { node: true, - jest: true, + mocha: true, }, }; diff --git a/packages/backend/test/activitypub.ts b/packages/backend/test/activitypub.ts new file mode 100644 index 0000000000..f4ae27e5ec --- /dev/null +++ b/packages/backend/test/activitypub.ts @@ -0,0 +1,96 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import rndstr from 'rndstr'; +import { initDb } from '../src/db/postgre.js'; +import { initTestDb } from './utils.js'; + +describe('ActivityPub', () => { + before(async () => { + //await initTestDb(); + await initDb(); + }); + + describe('Parse minimum object', () => { + const host = 'https://host1.test'; + const preferredUsername = `${rndstr('A-Z', 4)}${rndstr('a-z', 4)}`; + const actorId = `${host}/users/${preferredUsername.toLowerCase()}`; + + const actor = { + '@context': 'https://www.w3.org/ns/activitystreams', + id: actorId, + type: 'Person', + preferredUsername, + inbox: `${actorId}/inbox`, + outbox: `${actorId}/outbox`, + }; + + const post = { + '@context': 'https://www.w3.org/ns/activitystreams', + id: `${host}/users/${rndstr('0-9a-z', 8)}`, + type: 'Note', + attributedTo: actor.id, + to: 'https://www.w3.org/ns/activitystreams#Public', + content: 'あ', + }; + + it('Minimum Actor', async () => { + const { MockResolver } = await import('./misc/mock-resolver.js'); + const { createPerson } = await import('../src/remote/activitypub/models/person.js'); + + const resolver = new MockResolver(); + resolver._register(actor.id, actor); + + const user = await createPerson(actor.id, resolver); + + assert.deepStrictEqual(user.uri, actor.id); + assert.deepStrictEqual(user.username, actor.preferredUsername); + assert.deepStrictEqual(user.inbox, actor.inbox); + }); + + it('Minimum Note', async () => { + const { MockResolver } = await import('./misc/mock-resolver.js'); + const { createNote } = await import('../src/remote/activitypub/models/note.js'); + + const resolver = new MockResolver(); + resolver._register(actor.id, actor); + resolver._register(post.id, post); + + const note = await createNote(post.id, resolver, true); + + assert.deepStrictEqual(note?.uri, post.id); + assert.deepStrictEqual(note.visibility, 'public'); + assert.deepStrictEqual(note.text, post.content); + }); + }); + + describe('Truncate long name', () => { + const host = 'https://host1.test'; + const preferredUsername = `${rndstr('A-Z', 4)}${rndstr('a-z', 4)}`; + const actorId = `${host}/users/${preferredUsername.toLowerCase()}`; + + const name = rndstr('0-9a-z', 129); + + const actor = { + '@context': 'https://www.w3.org/ns/activitystreams', + id: actorId, + type: 'Person', + preferredUsername, + name, + inbox: `${actorId}/inbox`, + outbox: `${actorId}/outbox`, + }; + + it('Actor', async () => { + const { MockResolver } = await import('./misc/mock-resolver.js'); + const { createPerson } = await import('../src/remote/activitypub/models/person.js'); + + const resolver = new MockResolver(); + resolver._register(actor.id, actor); + + const user = await createPerson(actor.id, resolver); + + assert.deepStrictEqual(user.name, actor.name.substr(0, 128)); + }); + }); +}); diff --git a/packages/backend/test/ap-request.ts b/packages/backend/test/ap-request.ts new file mode 100644 index 0000000000..da95c421f3 --- /dev/null +++ b/packages/backend/test/ap-request.ts @@ -0,0 +1,55 @@ +import * as assert from 'assert'; +import httpSignature from 'http-signature'; +import { genRsaKeyPair } from '../src/misc/gen-key-pair.js'; +import { createSignedPost, createSignedGet } from '../src/remote/activitypub/ap-request.js'; + +export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => { + return { + scheme: 'Signature', + params: { + keyId: 'KeyID', // dummy, not used for verify + algorithm: algorithm, + headers: [ '(request-target)', 'date', 'host', 'digest' ], // dummy, not used for verify + signature: signature, + }, + signingString: signingString, + algorithm: algorithm.toUpperCase(), + keyId: 'KeyID', // dummy, not used for verify + }; +}; + +describe('ap-request', () => { + it('createSignedPost with verify', async () => { + const keypair = await genRsaKeyPair(); + const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey }; + const url = 'https://example.com/inbox'; + const activity = { a: 1 }; + const body = JSON.stringify(activity); + const headers = { + 'User-Agent': 'UA', + }; + + const req = createSignedPost({ key, url, body, additionalHeaders: headers }); + + const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256'); + + const result = httpSignature.verifySignature(parsed, keypair.publicKey); + assert.deepStrictEqual(result, true); + }); + + it('createSignedGet with verify', async () => { + const keypair = await genRsaKeyPair(); + const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey }; + const url = 'https://example.com/outbox'; + const headers = { + 'User-Agent': 'UA', + }; + + const req = createSignedGet({ key, url, additionalHeaders: headers }); + + const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256'); + + const result = httpSignature.verifySignature(parsed, keypair.publicKey); + assert.deepStrictEqual(result, true); + }); +}); diff --git a/packages/backend/test/api-visibility.ts b/packages/backend/test/api-visibility.ts new file mode 100644 index 0000000000..b155549f98 --- /dev/null +++ b/packages/backend/test/api-visibility.ts @@ -0,0 +1,476 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import * as childProcess from 'child_process'; +import { async, signup, request, post, startServer, shutdownServer } from './utils.js'; + +describe('API visibility', () => { + let p: childProcess.ChildProcess; + + before(async () => { + p = await startServer(); + }); + + after(async () => { + await shutdownServer(p); + }); + + describe('Note visibility', async () => { + //#region vars + /** ヒロイン */ + let alice: any; + /** フォロワー */ + let follower: any; + /** 非フォロワー */ + let other: any; + /** 非フォロワーでもリプライやメンションをされた人 */ + let target: any; + /** specified mentionでmentionを飛ばされる人 */ + let target2: any; + + /** public-post */ + let pub: any; + /** home-post */ + let home: any; + /** followers-post */ + let fol: any; + /** specified-post */ + let spe: any; + + /** public-reply to target's post */ + let pubR: any; + /** home-reply to target's post */ + let homeR: any; + /** followers-reply to target's post */ + let folR: any; + /** specified-reply to target's post */ + let speR: any; + + /** public-mention to target */ + let pubM: any; + /** home-mention to target */ + let homeM: any; + /** followers-mention to target */ + let folM: any; + /** specified-mention to target */ + let speM: any; + + /** reply target post */ + let tgt: any; + //#endregion + + const show = async (noteId: any, by: any) => { + return await request('/notes/show', { + noteId, + }, by); + }; + + before(async () => { + //#region prepare + // signup + alice = await signup({ username: 'alice' }); + follower = await signup({ username: 'follower' }); + other = await signup({ username: 'other' }); + target = await signup({ username: 'target' }); + target2 = await signup({ username: 'target2' }); + + // follow alice <= follower + await request('/following/create', { userId: alice.id }, follower); + + // normal posts + pub = await post(alice, { text: 'x', visibility: 'public' }); + home = await post(alice, { text: 'x', visibility: 'home' }); + fol = await post(alice, { text: 'x', visibility: 'followers' }); + spe = await post(alice, { text: 'x', visibility: 'specified', visibleUserIds: [target.id] }); + + // replies + tgt = await post(target, { text: 'y', visibility: 'public' }); + pubR = await post(alice, { text: 'x', replyId: tgt.id, visibility: 'public' }); + homeR = await post(alice, { text: 'x', replyId: tgt.id, visibility: 'home' }); + folR = await post(alice, { text: 'x', replyId: tgt.id, visibility: 'followers' }); + speR = await post(alice, { text: 'x', replyId: tgt.id, visibility: 'specified' }); + + // mentions + pubM = await post(alice, { text: '@target x', replyId: tgt.id, visibility: 'public' }); + homeM = await post(alice, { text: '@target x', replyId: tgt.id, visibility: 'home' }); + folM = await post(alice, { text: '@target x', replyId: tgt.id, visibility: 'followers' }); + speM = await post(alice, { text: '@target2 x', replyId: tgt.id, visibility: 'specified' }); + //#endregion + }); + + //#region show post + // public + it('[show] public-postを自分が見れる', async(async () => { + const res = await show(pub.id, alice); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] public-postをフォロワーが見れる', async(async () => { + const res = await show(pub.id, follower); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] public-postを非フォロワーが見れる', async(async () => { + const res = await show(pub.id, other); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] public-postを未認証が見れる', async(async () => { + const res = await show(pub.id, null); + assert.strictEqual(res.body.text, 'x'); + })); + + // home + it('[show] home-postを自分が見れる', async(async () => { + const res = await show(home.id, alice); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] home-postをフォロワーが見れる', async(async () => { + const res = await show(home.id, follower); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] home-postを非フォロワーが見れる', async(async () => { + const res = await show(home.id, other); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] home-postを未認証が見れる', async(async () => { + const res = await show(home.id, null); + assert.strictEqual(res.body.text, 'x'); + })); + + // followers + it('[show] followers-postを自分が見れる', async(async () => { + const res = await show(fol.id, alice); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] followers-postをフォロワーが見れる', async(async () => { + const res = await show(fol.id, follower); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] followers-postを非フォロワーが見れない', async(async () => { + const res = await show(fol.id, other); + assert.strictEqual(res.body.isHidden, true); + })); + + it('[show] followers-postを未認証が見れない', async(async () => { + const res = await show(fol.id, null); + assert.strictEqual(res.body.isHidden, true); + })); + + // specified + it('[show] specified-postを自分が見れる', async(async () => { + const res = await show(spe.id, alice); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] specified-postを指定ユーザーが見れる', async(async () => { + const res = await show(spe.id, target); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] specified-postをフォロワーが見れない', async(async () => { + const res = await show(spe.id, follower); + assert.strictEqual(res.body.isHidden, true); + })); + + it('[show] specified-postを非フォロワーが見れない', async(async () => { + const res = await show(spe.id, other); + assert.strictEqual(res.body.isHidden, true); + })); + + it('[show] specified-postを未認証が見れない', async(async () => { + const res = await show(spe.id, null); + assert.strictEqual(res.body.isHidden, true); + })); + //#endregion + + //#region show reply + // public + it('[show] public-replyを自分が見れる', async(async () => { + const res = await show(pubR.id, alice); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] public-replyをされた人が見れる', async(async () => { + const res = await show(pubR.id, target); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] public-replyをフォロワーが見れる', async(async () => { + const res = await show(pubR.id, follower); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] public-replyを非フォロワーが見れる', async(async () => { + const res = await show(pubR.id, other); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] public-replyを未認証が見れる', async(async () => { + const res = await show(pubR.id, null); + assert.strictEqual(res.body.text, 'x'); + })); + + // home + it('[show] home-replyを自分が見れる', async(async () => { + const res = await show(homeR.id, alice); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] home-replyをされた人が見れる', async(async () => { + const res = await show(homeR.id, target); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] home-replyをフォロワーが見れる', async(async () => { + const res = await show(homeR.id, follower); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] home-replyを非フォロワーが見れる', async(async () => { + const res = await show(homeR.id, other); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] home-replyを未認証が見れる', async(async () => { + const res = await show(homeR.id, null); + assert.strictEqual(res.body.text, 'x'); + })); + + // followers + it('[show] followers-replyを自分が見れる', async(async () => { + const res = await show(folR.id, alice); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] followers-replyを非フォロワーでもリプライされていれば見れる', async(async () => { + const res = await show(folR.id, target); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] followers-replyをフォロワーが見れる', async(async () => { + const res = await show(folR.id, follower); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] followers-replyを非フォロワーが見れない', async(async () => { + const res = await show(folR.id, other); + assert.strictEqual(res.body.isHidden, true); + })); + + it('[show] followers-replyを未認証が見れない', async(async () => { + const res = await show(folR.id, null); + assert.strictEqual(res.body.isHidden, true); + })); + + // specified + it('[show] specified-replyを自分が見れる', async(async () => { + const res = await show(speR.id, alice); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] specified-replyを指定ユーザーが見れる', async(async () => { + const res = await show(speR.id, target); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] specified-replyをされた人が指定されてなくても見れる', async(async () => { + const res = await show(speR.id, target); + assert.strictEqual(res.body.text, 'x'); + })); + + it('[show] specified-replyをフォロワーが見れない', async(async () => { + const res = await show(speR.id, follower); + assert.strictEqual(res.body.isHidden, true); + })); + + it('[show] specified-replyを非フォロワーが見れない', async(async () => { + const res = await show(speR.id, other); + assert.strictEqual(res.body.isHidden, true); + })); + + it('[show] specified-replyを未認証が見れない', async(async () => { + const res = await show(speR.id, null); + assert.strictEqual(res.body.isHidden, true); + })); + //#endregion + + //#region show mention + // public + it('[show] public-mentionを自分が見れる', async(async () => { + const res = await show(pubM.id, alice); + assert.strictEqual(res.body.text, '@target x'); + })); + + it('[show] public-mentionをされた人が見れる', async(async () => { + const res = await show(pubM.id, target); + assert.strictEqual(res.body.text, '@target x'); + })); + + it('[show] public-mentionをフォロワーが見れる', async(async () => { + const res = await show(pubM.id, follower); + assert.strictEqual(res.body.text, '@target x'); + })); + + it('[show] public-mentionを非フォロワーが見れる', async(async () => { + const res = await show(pubM.id, other); + assert.strictEqual(res.body.text, '@target x'); + })); + + it('[show] public-mentionを未認証が見れる', async(async () => { + const res = await show(pubM.id, null); + assert.strictEqual(res.body.text, '@target x'); + })); + + // home + it('[show] home-mentionを自分が見れる', async(async () => { + const res = await show(homeM.id, alice); + assert.strictEqual(res.body.text, '@target x'); + })); + + it('[show] home-mentionをされた人が見れる', async(async () => { + const res = await show(homeM.id, target); + assert.strictEqual(res.body.text, '@target x'); + })); + + it('[show] home-mentionをフォロワーが見れる', async(async () => { + const res = await show(homeM.id, follower); + assert.strictEqual(res.body.text, '@target x'); + })); + + it('[show] home-mentionを非フォロワーが見れる', async(async () => { + const res = await show(homeM.id, other); + assert.strictEqual(res.body.text, '@target x'); + })); + + it('[show] home-mentionを未認証が見れる', async(async () => { + const res = await show(homeM.id, null); + assert.strictEqual(res.body.text, '@target x'); + })); + + // followers + it('[show] followers-mentionを自分が見れる', async(async () => { + const res = await show(folM.id, alice); + assert.strictEqual(res.body.text, '@target x'); + })); + + it('[show] followers-mentionをメンションされていれば非フォロワーでも見れる', async(async () => { + const res = await show(folM.id, target); + assert.strictEqual(res.body.text, '@target x'); + })); + + it('[show] followers-mentionをフォロワーが見れる', async(async () => { + const res = await show(folM.id, follower); + assert.strictEqual(res.body.text, '@target x'); + })); + + it('[show] followers-mentionを非フォロワーが見れない', async(async () => { + const res = await show(folM.id, other); + assert.strictEqual(res.body.isHidden, true); + })); + + it('[show] followers-mentionを未認証が見れない', async(async () => { + const res = await show(folM.id, null); + assert.strictEqual(res.body.isHidden, true); + })); + + // specified + it('[show] specified-mentionを自分が見れる', async(async () => { + const res = await show(speM.id, alice); + assert.strictEqual(res.body.text, '@target2 x'); + })); + + it('[show] specified-mentionを指定ユーザーが見れる', async(async () => { + const res = await show(speM.id, target); + assert.strictEqual(res.body.text, '@target2 x'); + })); + + it('[show] specified-mentionをされた人が指定されてなかったら見れない', async(async () => { + const res = await show(speM.id, target2); + assert.strictEqual(res.body.isHidden, true); + })); + + it('[show] specified-mentionをフォロワーが見れない', async(async () => { + const res = await show(speM.id, follower); + assert.strictEqual(res.body.isHidden, true); + })); + + it('[show] specified-mentionを非フォロワーが見れない', async(async () => { + const res = await show(speM.id, other); + assert.strictEqual(res.body.isHidden, true); + })); + + it('[show] specified-mentionを未認証が見れない', async(async () => { + const res = await show(speM.id, null); + assert.strictEqual(res.body.isHidden, true); + })); + //#endregion + + //#region HTL + it('[HTL] public-post が 自分が見れる', async(async () => { + const res = await request('/notes/timeline', { limit: 100 }, alice); + assert.strictEqual(res.status, 200); + const notes = res.body.filter((n: any) => n.id == pub.id); + assert.strictEqual(notes[0].text, 'x'); + })); + + it('[HTL] public-post が 非フォロワーから見れない', async(async () => { + const res = await request('/notes/timeline', { limit: 100 }, other); + assert.strictEqual(res.status, 200); + const notes = res.body.filter((n: any) => n.id == pub.id); + assert.strictEqual(notes.length, 0); + })); + + it('[HTL] followers-post が フォロワーから見れる', async(async () => { + const res = await request('/notes/timeline', { limit: 100 }, follower); + assert.strictEqual(res.status, 200); + const notes = res.body.filter((n: any) => n.id == fol.id); + assert.strictEqual(notes[0].text, 'x'); + })); + //#endregion + + //#region RTL + it('[replies] followers-reply が フォロワーから見れる', async(async () => { + const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, follower); + assert.strictEqual(res.status, 200); + const notes = res.body.filter((n: any) => n.id == folR.id); + assert.strictEqual(notes[0].text, 'x'); + })); + + it('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async(async () => { + const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, other); + assert.strictEqual(res.status, 200); + const notes = res.body.filter((n: any) => n.id == folR.id); + assert.strictEqual(notes.length, 0); + })); + + it('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async(async () => { + const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, target); + assert.strictEqual(res.status, 200); + const notes = res.body.filter((n: any) => n.id == folR.id); + assert.strictEqual(notes[0].text, 'x'); + })); + //#endregion + + //#region MTL + it('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async(async () => { + const res = await request('/notes/mentions', { limit: 100 }, target); + assert.strictEqual(res.status, 200); + const notes = res.body.filter((n: any) => n.id == folR.id); + assert.strictEqual(notes[0].text, 'x'); + })); + + it('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async(async () => { + const res = await request('/notes/mentions', { limit: 100 }, target); + assert.strictEqual(res.status, 200); + const notes = res.body.filter((n: any) => n.id == folM.id); + assert.strictEqual(notes[0].text, '@target x'); + })); + //#endregion + }); +}); diff --git a/packages/backend/test/api.ts b/packages/backend/test/api.ts new file mode 100644 index 0000000000..b1b2ecafc7 --- /dev/null +++ b/packages/backend/test/api.ts @@ -0,0 +1,83 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import * as childProcess from 'child_process'; +import { async, signup, request, post, react, uploadFile, startServer, shutdownServer } from './utils.js'; + +describe('API', () => { + let p: childProcess.ChildProcess; + let alice: any; + let bob: any; + let carol: any; + + before(async () => { + p = await startServer(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + carol = await signup({ username: 'carol' }); + }); + + after(async () => { + await shutdownServer(p); + }); + + describe('General validation', () => { + it('wrong type', async(async () => { + const res = await request('/test', { + required: true, + string: 42, + }); + assert.strictEqual(res.status, 400); + })); + + it('missing require param', async(async () => { + const res = await request('/test', { + string: 'a', + }); + assert.strictEqual(res.status, 400); + })); + + it('invalid misskey:id (empty string)', async(async () => { + const res = await request('/test', { + required: true, + id: '', + }); + assert.strictEqual(res.status, 400); + })); + + it('valid misskey:id', async(async () => { + const res = await request('/test', { + required: true, + id: '8wvhjghbxu', + }); + assert.strictEqual(res.status, 200); + })); + + it('default value', async(async () => { + const res = await request('/test', { + required: true, + string: 'a', + }); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.default, 'hello'); + })); + + it('can set null even if it has default value', async(async () => { + const res = await request('/test', { + required: true, + nullableDefault: null, + }); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.nullableDefault, null); + })); + + it('cannot set undefined if it has default value', async(async () => { + const res = await request('/test', { + required: true, + nullableDefault: undefined, + }); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.nullableDefault, 'hello'); + })); + }); +}); diff --git a/packages/backend/test/block.ts b/packages/backend/test/block.ts new file mode 100644 index 0000000000..b3343813cd --- /dev/null +++ b/packages/backend/test/block.ts @@ -0,0 +1,85 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import * as childProcess from 'child_process'; +import { async, signup, request, post, startServer, shutdownServer } from './utils.js'; + +describe('Block', () => { + let p: childProcess.ChildProcess; + + // alice blocks bob + let alice: any; + let bob: any; + let carol: any; + + before(async () => { + p = await startServer(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + carol = await signup({ username: 'carol' }); + }); + + after(async () => { + await shutdownServer(p); + }); + + it('Block作成', async(async () => { + const res = await request('/blocking/create', { + userId: bob.id, + }, alice); + + assert.strictEqual(res.status, 200); + })); + + it('ブロックされているユーザーをフォローできない', async(async () => { + const res = await request('/following/create', { userId: alice.id }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.id, 'c4ab57cc-4e41-45e9-bfd9-584f61e35ce0'); + })); + + it('ブロックされているユーザーにリアクションできない', async(async () => { + const note = await post(alice, { text: 'hello' }); + + const res = await request('/notes/reactions/create', { noteId: note.id, reaction: '👍' }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.id, '20ef5475-9f38-4e4c-bd33-de6d979498ec'); + })); + + it('ブロックされているユーザーに返信できない', async(async () => { + const note = await post(alice, { text: 'hello' }); + + const res = await request('/notes/create', { replyId: note.id, text: 'yo' }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); + })); + + it('ブロックされているユーザーのノートをRenoteできない', async(async () => { + const note = await post(alice, { text: 'hello' }); + + const res = await request('/notes/create', { renoteId: note.id, text: 'yo' }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); + })); + + // TODO: ユーザーリストに入れられないテスト + + // TODO: ユーザーリストから除外されるテスト + + it('タイムライン(LTL)にブロックされているユーザーの投稿が含まれない', async(async () => { + const aliceNote = await post(alice); + const bobNote = await post(bob); + const carolNote = await post(carol); + + const res = await request('/notes/local-timeline', {}, bob); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + })); +}); diff --git a/packages/backend/test/unit/chart.ts b/packages/backend/test/chart.ts similarity index 76% rename from packages/backend/test/unit/chart.ts rename to packages/backend/test/chart.ts index 036e73fd5e..ac0844679f 100644 --- a/packages/backend/test/unit/chart.ts +++ b/packages/backend/test/chart.ts @@ -1,34 +1,14 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { jest } from '@jest/globals'; import * as lolex from '@sinonjs/fake-timers'; -import { DataSource } from 'typeorm'; -import TestChart from '@/core/chart/charts/test.js'; -import TestGroupedChart from '@/core/chart/charts/test-grouped.js'; -import TestUniqueChart from '@/core/chart/charts/test-unique.js'; -import TestIntersectionChart from '@/core/chart/charts/test-intersection.js'; -import { entity as TestChartEntity } from '@/core/chart/charts/entities/test.js'; -import { entity as TestGroupedChartEntity } from '@/core/chart/charts/entities/test-grouped.js'; -import { entity as TestUniqueChartEntity } from '@/core/chart/charts/entities/test-unique.js'; -import { entity as TestIntersectionChartEntity } from '@/core/chart/charts/entities/test-intersection.js'; -import { loadConfig } from '@/config.js'; -import type { AppLockService } from '@/core/AppLockService.js'; -import Logger from '@/logger.js'; +import TestChart from '../src/services/chart/charts/test.js'; +import TestGroupedChart from '../src/services/chart/charts/test-grouped.js'; +import TestUniqueChart from '../src/services/chart/charts/test-unique.js'; +import TestIntersectionChart from '../src/services/chart/charts/test-intersection.js'; +import { initDb } from '../src/db/postgre.js'; describe('Chart', () => { - const config = loadConfig(); - const appLockService = { - getChartInsertLock: () => () => Promise.resolve(() => {}), - } as unknown as jest.Mocked; - - let db: DataSource | undefined; - let testChart: TestChart; let testGroupedChart: TestGroupedChart; let testUniqueChart: TestUniqueChart; @@ -36,38 +16,12 @@ describe('Chart', () => { let clock: lolex.InstalledClock; beforeEach(async () => { - if (db) db.destroy(); + await initDb(true); - db = new DataSource({ - type: 'postgres', - host: config.db.host, - port: config.db.port, - username: config.db.user, - password: config.db.pass, - database: config.db.db, - extra: { - statement_timeout: 1000 * 10, - ...config.db.extra, - }, - synchronize: true, - dropSchema: true, - maxQueryExecutionTime: 300, - entities: [ - TestChartEntity.hour, TestChartEntity.day, - TestGroupedChartEntity.hour, TestGroupedChartEntity.day, - TestUniqueChartEntity.hour, TestUniqueChartEntity.day, - TestIntersectionChartEntity.hour, TestIntersectionChartEntity.day, - ], - migrations: ['../../migration/*.js'], - }); - - await db.initialize(); - - const logger = new Logger('chart'); // TODO: モックにする - testChart = new TestChart(db, appLockService, logger); - testGroupedChart = new TestGroupedChart(db, appLockService, logger); - testUniqueChart = new TestUniqueChart(db, appLockService, logger); - testIntersectionChart = new TestIntersectionChart(db, appLockService, logger); + testChart = new TestChart(); + testGroupedChart = new TestGroupedChart(); + testUniqueChart = new TestUniqueChart(); + testIntersectionChart = new TestIntersectionChart(); clock = lolex.install({ now: new Date(Date.UTC(2000, 0, 1, 0, 0, 0)), @@ -79,11 +33,7 @@ describe('Chart', () => { clock.uninstall(); }); - afterAll(async () => { - if (db) await db.destroy(); - }); - - test('Can updates', async () => { + it('Can updates', async () => { await testChart.increment(); await testChart.save(); @@ -107,7 +57,7 @@ describe('Chart', () => { }); }); - test('Can updates (dec)', async () => { + it('Can updates (dec)', async () => { await testChart.decrement(); await testChart.save(); @@ -131,7 +81,7 @@ describe('Chart', () => { }); }); - test('Empty chart', async () => { + it('Empty chart', async () => { const chartHours = await testChart.getChart('hour', 3, null); const chartDays = await testChart.getChart('day', 3, null); @@ -152,7 +102,7 @@ describe('Chart', () => { }); }); - test('Can updates at multiple times at same time', async () => { + it('Can updates at multiple times at same time', async () => { await testChart.increment(); await testChart.increment(); await testChart.increment(); @@ -178,7 +128,7 @@ describe('Chart', () => { }); }); - test('複数回saveされてもデータの更新は一度だけ', async () => { + it('複数回saveされてもデータの更新は一度だけ', async () => { await testChart.increment(); await testChart.save(); await testChart.save(); @@ -204,7 +154,7 @@ describe('Chart', () => { }); }); - test('Can updates at different times', async () => { + it('Can updates at different times', async () => { await testChart.increment(); await testChart.save(); @@ -235,7 +185,7 @@ describe('Chart', () => { // 仕様上はこうなってほしいけど、実装は難しそうなのでskip /* - test('Can updates at different times without save', async () => { + it('Can updates at different times without save', async () => { await testChart.increment(); clock.tick('01:00:00'); @@ -264,7 +214,7 @@ describe('Chart', () => { }); */ - test('Can padding', async () => { + it('Can padding', async () => { await testChart.increment(); await testChart.save(); @@ -294,7 +244,7 @@ describe('Chart', () => { }); // 要求された範囲にログがひとつもない場合でもパディングできる - test('Can padding from past range', async () => { + it('Can padding from past range', async () => { await testChart.increment(); await testChart.save(); @@ -322,7 +272,7 @@ describe('Chart', () => { // 要求された範囲の最も古い箇所に位置するログが存在しない場合でもパディングできる // Issue #3190 - test('Can padding from past range 2', async () => { + it('Can padding from past range 2', async () => { await testChart.increment(); await testChart.save(); @@ -351,7 +301,7 @@ describe('Chart', () => { }); }); - test('Can specify offset', async () => { + it('Can specify offset', async () => { await testChart.increment(); await testChart.save(); @@ -380,7 +330,7 @@ describe('Chart', () => { }); }); - test('Can specify offset (floor time)', async () => { + it('Can specify offset (floor time)', async () => { clock.tick('00:30:00'); await testChart.increment(); @@ -412,7 +362,7 @@ describe('Chart', () => { }); describe('Grouped', () => { - test('Can updates', async () => { + it('Can updates', async () => { await testGroupedChart.increment('alice'); await testGroupedChart.save(); @@ -456,7 +406,7 @@ describe('Chart', () => { }); describe('Unique increment', () => { - test('Can updates', async () => { + it('Can updates', async () => { await testUniqueChart.uniqueIncrement('alice'); await testUniqueChart.uniqueIncrement('alice'); await testUniqueChart.uniqueIncrement('bob'); @@ -475,21 +425,21 @@ describe('Chart', () => { }); describe('Intersection', () => { - test('条件が満たされていない場合はカウントされない', async () => { + it('条件が満たされていない場合はカウントされない', async () => { await testIntersectionChart.addA('alice'); await testIntersectionChart.addA('bob'); await testIntersectionChart.addB('carol'); await testIntersectionChart.save(); - + const chartHours = await testIntersectionChart.getChart('hour', 3, null); const chartDays = await testIntersectionChart.getChart('day', 3, null); - + assert.deepStrictEqual(chartHours, { a: [2, 0, 0], b: [1, 0, 0], aAndB: [0, 0, 0], }); - + assert.deepStrictEqual(chartDays, { a: [2, 0, 0], b: [1, 0, 0], @@ -497,22 +447,22 @@ describe('Chart', () => { }); }); - test('条件が満たされている場合にカウントされる', async () => { + it('条件が満たされている場合にカウントされる', async () => { await testIntersectionChart.addA('alice'); await testIntersectionChart.addA('bob'); await testIntersectionChart.addB('carol'); await testIntersectionChart.addB('alice'); await testIntersectionChart.save(); - + const chartHours = await testIntersectionChart.getChart('hour', 3, null); const chartDays = await testIntersectionChart.getChart('day', 3, null); - + assert.deepStrictEqual(chartHours, { a: [2, 0, 0], b: [2, 0, 0], aAndB: [1, 0, 0], }); - + assert.deepStrictEqual(chartDays, { a: [2, 0, 0], b: [2, 0, 0], @@ -523,7 +473,7 @@ describe('Chart', () => { }); describe('Resync', () => { - test('Can resync', async () => { + it('Can resync', async () => { testChart.total = 1; await testChart.resync(); @@ -548,7 +498,7 @@ describe('Chart', () => { }); }); - test('Can resync (2)', async () => { + it('Can resync (2)', async () => { await testChart.increment(); await testChart.save(); diff --git a/packages/backend/test/docker-compose.yml b/packages/backend/test/docker-compose.yml index f2d8990758..5f95bec4c0 100644 --- a/packages/backend/test/docker-compose.yml +++ b/packages/backend/test/docker-compose.yml @@ -2,12 +2,12 @@ version: "3" services: redistest: - image: redis:7 + image: redis:6 ports: - "127.0.0.1:56312:6379" dbtest: - image: postgres:15 + image: postgres:13 ports: - "127.0.0.1:54312:5432" environment: diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts deleted file mode 100644 index ed967d2620..0000000000 --- a/packages/backend/test/e2e/2fa.ts +++ /dev/null @@ -1,500 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import * as crypto from 'node:crypto'; -import cbor from 'cbor'; -import * as OTPAuth from 'otpauth'; -import { loadConfig } from '@/config.js'; -import { api, signup, startServer } from '../utils.js'; -import type { - AuthenticationResponseJSON, - AuthenticatorAssertionResponseJSON, - AuthenticatorAttestationResponseJSON, - PublicKeyCredentialCreationOptionsJSON, - PublicKeyCredentialRequestOptionsJSON, - RegistrationResponseJSON, -} from '@simplewebauthn/typescript-types'; -import type { INestApplicationContext } from '@nestjs/common'; -import type * as misskey from 'misskey-js'; - -describe('2要素認証', () => { - let app: INestApplicationContext; - let alice: misskey.entities.MeSignup; - - const config = loadConfig(); - const password = 'test'; - const username = 'alice'; - - // https://datatracker.ietf.org/doc/html/rfc8152 - // 各値の定義は上記規格に基づく。鍵ペアは適当に生成したやつ - const coseKtyEc2 = 2; - const coseKid = 'meriadoc.brandybuck@buckland.example'; - const coseAlgEs256 = -7; - const coseEc2CrvP256 = 1; - const coseEc2X = '4932eaacc657565705e4287e7870ce3aad55545d99d35a98a472dc52880cfc8f'; - const coseEc2Y = '5ca68303bf2c0433473e3d5cb8586bc2c8c43a4945a496fce8dbeda8b23ab0b1'; - - // private key only for testing - const pemToSign = '-----BEGIN EC PRIVATE KEY-----\n' + - 'MHcCAQEEIHqe/keuXyolbXzgLOu+YFJjDBGWVgXc3QCXfyqwDPf2oAoGCCqGSM49\n' + - 'AwEHoUQDQgAESTLqrMZXVlcF5Ch+eHDOOq1VVF2Z01qYpHLcUogM/I9cpoMDvywE\n' + - 'M0c+PVy4WGvCyMQ6SUWklvzo2+2osjqwsQ==\n' + - '-----END EC PRIVATE KEY-----\n'; - - const otpToken = (secret: string): string => { - return OTPAuth.TOTP.generate({ - secret: OTPAuth.Secret.fromBase32(secret), - digits: 6, - }); - }; - - const rpIdHash = (): Buffer => { - return crypto.createHash('sha256') - .update(Buffer.from(config.host, 'utf-8')) - .digest(); - }; - - const keyDoneParam = (param: { - token: string, - keyName: string, - credentialId: Buffer, - creationOptions: PublicKeyCredentialCreationOptionsJSON, - }): { - token: string, - password: string, - name: string, - credential: RegistrationResponseJSON, - } => { - // A COSE encoded public key - const credentialPublicKey = cbor.encode(new Map([ - [-1, coseEc2CrvP256], - [-2, Buffer.from(coseEc2X, 'hex')], - [-3, Buffer.from(coseEc2Y, 'hex')], - [1, coseKtyEc2], - [2, coseKid], - [3, coseAlgEs256], - ])); - - // AuthenticatorAssertionResponse.authenticatorData - // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData - const credentialIdLength = Buffer.allocUnsafe(2); - credentialIdLength.writeUInt16BE(param.credentialId.length, 0); - const authData = Buffer.concat([ - rpIdHash(), // rpIdHash(32) - Buffer.from([0x45]), // flags(1) - Buffer.from([0x00, 0x00, 0x00, 0x00]), // signCount(4) - Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), // AAGUID(16) - credentialIdLength, - param.credentialId, - credentialPublicKey, - ]); - - return { - password, - token: param.token, - name: param.keyName, - credential: { - id: param.credentialId.toString('base64url'), - rawId: param.credentialId.toString('base64url'), - response: { - clientDataJSON: Buffer.from(JSON.stringify({ - type: 'webauthn.create', - challenge: param.creationOptions.challenge, - origin: config.scheme + '://' + config.host, - androidPackageName: 'org.mozilla.firefox', - }), 'utf-8').toString('base64url'), - attestationObject: cbor.encode({ - fmt: 'none', - attStmt: {}, - authData, - }).toString('base64url'), - }, - clientExtensionResults: {}, - type: 'public-key', - }, - }; - }; - - const signinParam = (): { - username: string, - password: string, - 'g-recaptcha-response'?: string | null, - 'hcaptcha-response'?: string | null, - } => { - return { - username, - password, - 'g-recaptcha-response': null, - 'hcaptcha-response': null, - }; - }; - - const signinWithSecurityKeyParam = (param: { - keyName: string, - credentialId: Buffer, - requestOptions: PublicKeyCredentialRequestOptionsJSON, - }): { - username: string, - password: string, - credential: AuthenticationResponseJSON, - 'g-recaptcha-response'?: string | null, - 'hcaptcha-response'?: string | null, - } => { - // AuthenticatorAssertionResponse.authenticatorData - // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData - const authenticatorData = Buffer.concat([ - rpIdHash(), - Buffer.from([0x05]), // flags(1) - Buffer.from([0x00, 0x00, 0x00, 0x01]), // signCount(4) - ]); - const clientDataJSONBuffer = Buffer.from(JSON.stringify({ - type: 'webauthn.get', - challenge: param.requestOptions.challenge, - origin: config.scheme + '://' + config.host, - androidPackageName: 'org.mozilla.firefox', - }), 'utf-8'); - const hashedclientDataJSON = crypto.createHash('sha256') - .update(clientDataJSONBuffer) - .digest(); - const privateKey = crypto.createPrivateKey(pemToSign); - const signature = crypto.createSign('SHA256') - .update(Buffer.concat([authenticatorData, hashedclientDataJSON])) - .sign(privateKey); - return { - username, - password, - credential: { - id: param.credentialId.toString('base64url'), - rawId: param.credentialId.toString('base64url'), - response: { - clientDataJSON: clientDataJSONBuffer.toString('base64url'), - authenticatorData: authenticatorData.toString('base64url'), - signature: signature.toString('base64url'), - }, - clientExtensionResults: {}, - type: 'public-key', - }, - 'g-recaptcha-response': null, - 'hcaptcha-response': null, - }; - }; - - beforeAll(async () => { - app = await startServer(); - alice = await signup({ username, password }); - }, 1000 * 60 * 2); - - afterAll(async () => { - await app.close(); - }); - - test('が設定でき、OTPでログインできる。', async () => { - const registerResponse = await api('/i/2fa/register', { - password, - }, alice); - assert.strictEqual(registerResponse.status, 200); - assert.notEqual(registerResponse.body.qr, undefined); - assert.notEqual(registerResponse.body.url, undefined); - assert.notEqual(registerResponse.body.secret, undefined); - assert.strictEqual(registerResponse.body.label, username); - assert.strictEqual(registerResponse.body.issuer, config.host); - - const doneResponse = await api('/i/2fa/done', { - token: otpToken(registerResponse.body.secret), - }, alice); - assert.strictEqual(doneResponse.status, 200); - - const usersShowResponse = await api('/users/show', { - username, - }, alice); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true); - - const signinResponse = await api('/signin', { - ...signinParam(), - token: otpToken(registerResponse.body.secret), - }); - assert.strictEqual(signinResponse.status, 200); - assert.notEqual(signinResponse.body.i, undefined); - - // 後片付け - await api('/i/2fa/unregister', { - password, - token: otpToken(registerResponse.body.secret), - }, alice); - }); - - test('が設定でき、セキュリティキーでログインできる。', async () => { - const registerResponse = await api('/i/2fa/register', { - password, - }, alice); - assert.strictEqual(registerResponse.status, 200); - - const doneResponse = await api('/i/2fa/done', { - token: otpToken(registerResponse.body.secret), - }, alice); - assert.strictEqual(doneResponse.status, 200); - - const registerKeyResponse = await api('/i/2fa/register-key', { - password, - token: otpToken(registerResponse.body.secret), - }, alice); - assert.strictEqual(registerKeyResponse.status, 200); - assert.notEqual(registerKeyResponse.body.rp, undefined); - assert.notEqual(registerKeyResponse.body.challenge, undefined); - - const keyName = 'example-key'; - const credentialId = crypto.randomBytes(0x41); - const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ - token: otpToken(registerResponse.body.secret), - keyName, - credentialId, - creationOptions: registerKeyResponse.body, - }), alice); - assert.strictEqual(keyDoneResponse.status, 200); - assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url')); - assert.strictEqual(keyDoneResponse.body.name, keyName); - - const usersShowResponse = await api('/users/show', { - username, - }); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual(usersShowResponse.body.securityKeys, true); - - const signinResponse = await api('/signin', { - ...signinParam(), - }); - assert.strictEqual(signinResponse.status, 200); - assert.strictEqual(signinResponse.body.i, undefined); - assert.notEqual(signinResponse.body.challenge, undefined); - assert.notEqual(signinResponse.body.allowCredentials, undefined); - assert.strictEqual(signinResponse.body.allowCredentials[0].id, credentialId.toString('base64url')); - - const signinResponse2 = await api('/signin', signinWithSecurityKeyParam({ - keyName, - credentialId, - requestOptions: signinResponse.body, - })); - assert.strictEqual(signinResponse2.status, 200); - assert.notEqual(signinResponse2.body.i, undefined); - - // 後片付け - await api('/i/2fa/unregister', { - password, - token: otpToken(registerResponse.body.secret), - }, alice); - }); - - test('が設定でき、セキュリティキーでパスワードレスログインできる。', async () => { - const registerResponse = await api('/i/2fa/register', { - password, - }, alice); - assert.strictEqual(registerResponse.status, 200); - - const doneResponse = await api('/i/2fa/done', { - token: otpToken(registerResponse.body.secret), - }, alice); - assert.strictEqual(doneResponse.status, 200); - - const registerKeyResponse = await api('/i/2fa/register-key', { - token: otpToken(registerResponse.body.secret), - password, - }, alice); - assert.strictEqual(registerKeyResponse.status, 200); - - const keyName = 'example-key'; - const credentialId = crypto.randomBytes(0x41); - const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ - token: otpToken(registerResponse.body.secret), - keyName, - credentialId, - creationOptions: registerKeyResponse.body, - }), alice); - assert.strictEqual(keyDoneResponse.status, 200); - - const passwordLessResponse = await api('/i/2fa/password-less', { - value: true, - }, alice); - assert.strictEqual(passwordLessResponse.status, 204); - - const usersShowResponse = await api('/users/show', { - username, - }); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual(usersShowResponse.body.usePasswordLessLogin, true); - - const signinResponse = await api('/signin', { - ...signinParam(), - password: '', - }); - assert.strictEqual(signinResponse.status, 200); - assert.strictEqual(signinResponse.body.i, undefined); - - const signinResponse2 = await api('/signin', { - ...signinWithSecurityKeyParam({ - keyName, - credentialId, - requestOptions: signinResponse.body, - }), - password: '', - }); - assert.strictEqual(signinResponse2.status, 200); - assert.notEqual(signinResponse2.body.i, undefined); - - // 後片付け - await api('/i/2fa/unregister', { - password, - token: otpToken(registerResponse.body.secret), - }, alice); - }); - - test('が設定でき、設定したセキュリティキーの名前を変更できる。', async () => { - const registerResponse = await api('/i/2fa/register', { - password, - }, alice); - assert.strictEqual(registerResponse.status, 200); - - const doneResponse = await api('/i/2fa/done', { - token: otpToken(registerResponse.body.secret), - }, alice); - assert.strictEqual(doneResponse.status, 200); - - const registerKeyResponse = await api('/i/2fa/register-key', { - token: otpToken(registerResponse.body.secret), - password, - }, alice); - assert.strictEqual(registerKeyResponse.status, 200); - - const keyName = 'example-key'; - const credentialId = crypto.randomBytes(0x41); - const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ - token: otpToken(registerResponse.body.secret), - keyName, - credentialId, - creationOptions: registerKeyResponse.body, - }), alice); - assert.strictEqual(keyDoneResponse.status, 200); - - const renamedKey = 'other-key'; - const updateKeyResponse = await api('/i/2fa/update-key', { - name: renamedKey, - credentialId: credentialId.toString('base64url'), - }, alice); - assert.strictEqual(updateKeyResponse.status, 200); - - const iResponse = await api('/i', { - }, alice); - assert.strictEqual(iResponse.status, 200); - const securityKeys = iResponse.body.securityKeysList.filter((s: { id: string; }) => s.id === credentialId.toString('base64url')); - assert.strictEqual(securityKeys.length, 1); - assert.strictEqual(securityKeys[0].name, renamedKey); - assert.notEqual(securityKeys[0].lastUsed, undefined); - - // 後片付け - await api('/i/2fa/unregister', { - password, - token: otpToken(registerResponse.body.secret), - }, alice); - }); - - test('が設定でき、設定したセキュリティキーを削除できる。', async () => { - const registerResponse = await api('/i/2fa/register', { - password, - }, alice); - assert.strictEqual(registerResponse.status, 200); - - const doneResponse = await api('/i/2fa/done', { - token: otpToken(registerResponse.body.secret), - }, alice); - assert.strictEqual(doneResponse.status, 200); - - const registerKeyResponse = await api('/i/2fa/register-key', { - token: otpToken(registerResponse.body.secret), - password, - }, alice); - assert.strictEqual(registerKeyResponse.status, 200); - - const keyName = 'example-key'; - const credentialId = crypto.randomBytes(0x41); - const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ - token: otpToken(registerResponse.body.secret), - keyName, - credentialId, - creationOptions: registerKeyResponse.body, - }), alice); - assert.strictEqual(keyDoneResponse.status, 200); - - // テストの実行順によっては複数残ってるので全部消す - const iResponse = await api('/i', { - }, alice); - assert.strictEqual(iResponse.status, 200); - for (const key of iResponse.body.securityKeysList) { - const removeKeyResponse = await api('/i/2fa/remove-key', { - token: otpToken(registerResponse.body.secret), - password, - credentialId: key.id, - }, alice); - assert.strictEqual(removeKeyResponse.status, 200); - } - - const usersShowResponse = await api('/users/show', { - username, - }); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual(usersShowResponse.body.securityKeys, false); - - const signinResponse = await api('/signin', { - ...signinParam(), - token: otpToken(registerResponse.body.secret), - }); - assert.strictEqual(signinResponse.status, 200); - assert.notEqual(signinResponse.body.i, undefined); - - // 後片付け - await api('/i/2fa/unregister', { - password, - token: otpToken(registerResponse.body.secret), - }, alice); - }); - - test('が設定でき、設定解除できる。(パスワードのみでログインできる。)', async () => { - const registerResponse = await api('/i/2fa/register', { - password, - }, alice); - assert.strictEqual(registerResponse.status, 200); - - const doneResponse = await api('/i/2fa/done', { - token: otpToken(registerResponse.body.secret), - }, alice); - assert.strictEqual(doneResponse.status, 200); - - const usersShowResponse = await api('/users/show', { - username, - }); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true); - - const unregisterResponse = await api('/i/2fa/unregister', { - token: otpToken(registerResponse.body.secret), - password, - }, alice); - assert.strictEqual(unregisterResponse.status, 204); - - const signinResponse = await api('/signin', { - ...signinParam(), - }); - assert.strictEqual(signinResponse.status, 200); - assert.notEqual(signinResponse.body.i, undefined); - - // 後片付け - await api('/i/2fa/unregister', { - password, - token: otpToken(registerResponse.body.secret), - }, alice); - }); -}); diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts deleted file mode 100644 index c0317f1435..0000000000 --- a/packages/backend/test/e2e/antennas.ts +++ /dev/null @@ -1,659 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { inspect } from 'node:util'; -import { DEFAULT_POLICIES } from '@/core/RoleService.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { - signup, - post, - userList, - page, - role, - startServer, - api, - successfulApiCall, - failedApiCall, - uploadFile, - testPaginationConsistency, -} from '../utils.js'; -import type * as misskey from 'misskey-js'; -import type { INestApplicationContext } from '@nestjs/common'; - -const compareBy = (selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => { - return selector(a).localeCompare(selector(b)); -}; - -describe('アンテナ', () => { - // エンティティとしてのアンテナを主眼においたテストを記述する - // (Antennaを返すエンドポイント、Antennaエンティティを書き換えるエンドポイント、Antennaからノートを取得するエンドポイントをテストする) - - // BUG misskey-jsとjson-schemaが一致していない。 - // - srcのenumにgroupが残っている - // - userGroupIdが残っている, isActiveがない - type Antenna = misskey.entities.Antenna | Packed<'Antenna'>; - type User = misskey.entities.MeSignup; - type Note = misskey.entities.Note; - - // アンテナを作成できる最小のパラメタ - const defaultParam = { - caseSensitive: false, - excludeKeywords: [['']], - keywords: [['keyword']], - name: 'test', - notify: false, - src: 'all' as const, - userListId: null, - users: [''], - withFile: false, - withReplies: false, - }; - - let app: INestApplicationContext; - - let root: User; - let alice: User; - let bob: User; - let carol: User; - - let alicePost: Note; - let aliceList: misskey.entities.UserList; - let bobFile: misskey.entities.DriveFile; - let bobList: misskey.entities.UserList; - - let userNotExplorable: User; - let userLocking: User; - let userSilenced: User; - let userSuspended: User; - let userDeletedBySelf: User; - let userDeletedByAdmin: User; - let userFollowingAlice: User; - let userFollowedByAlice: User; - let userBlockingAlice: User; - let userBlockedByAlice: User; - let userMutingAlice: User; - let userMutedByAlice: User; - - beforeAll(async () => { - app = await startServer(); - }, 1000 * 60 * 2); - - beforeAll(async () => { - root = await signup({ username: 'root' }); - alice = await signup({ username: 'alice' }); - alicePost = await post(alice, { text: 'test' }); - aliceList = await userList(alice, {}); - bob = await signup({ username: 'bob' }); - aliceList = await userList(alice, {}); - bobFile = (await uploadFile(bob)).body; - bobList = await userList(bob); - carol = await signup({ username: 'carol' }); - await api('users/lists/push', { listId: aliceList.id, userId: bob.id }, alice); - await api('users/lists/push', { listId: aliceList.id, userId: carol.id }, alice); - - userNotExplorable = await signup({ username: 'userNotExplorable' }); - await post(userNotExplorable, { text: 'test' }); - await api('i/update', { isExplorable: false }, userNotExplorable); - userLocking = await signup({ username: 'userLocking' }); - await post(userLocking, { text: 'test' }); - await api('i/update', { isLocked: true }, userLocking); - userSilenced = await signup({ username: 'userSilenced' }); - await post(userSilenced, { text: 'test' }); - const roleSilenced = await role(root, {}, { canPublicNote: { priority: 0, useDefault: false, value: false } }); - await api('admin/roles/assign', { userId: userSilenced.id, roleId: roleSilenced.id }, root); - userSuspended = await signup({ username: 'userSuspended' }); - await post(userSuspended, { text: 'test' }); - await successfulApiCall({ endpoint: 'i/update', parameters: { description: '#user_testuserSuspended' }, user: userSuspended }); - await api('admin/suspend-user', { userId: userSuspended.id }, root); - userDeletedBySelf = await signup({ username: 'userDeletedBySelf', password: 'userDeletedBySelf' }); - await post(userDeletedBySelf, { text: 'test' }); - await api('i/delete-account', { password: 'userDeletedBySelf' }, userDeletedBySelf); - userDeletedByAdmin = await signup({ username: 'userDeletedByAdmin' }); - await post(userDeletedByAdmin, { text: 'test' }); - await api('admin/delete-account', { userId: userDeletedByAdmin.id }, root); - userFollowedByAlice = await signup({ username: 'userFollowedByAlice' }); - await post(userFollowedByAlice, { text: 'test' }); - await api('following/create', { userId: userFollowedByAlice.id }, alice); - userFollowingAlice = await signup({ username: 'userFollowingAlice' }); - await post(userFollowingAlice, { text: 'test' }); - await api('following/create', { userId: alice.id }, userFollowingAlice); - userBlockingAlice = await signup({ username: 'userBlockingAlice' }); - await post(userBlockingAlice, { text: 'test' }); - await api('blocking/create', { userId: alice.id }, userBlockingAlice); - userBlockedByAlice = await signup({ username: 'userBlockedByAlice' }); - await post(userBlockedByAlice, { text: 'test' }); - await api('blocking/create', { userId: userBlockedByAlice.id }, alice); - userMutingAlice = await signup({ username: 'userMutingAlice' }); - await post(userMutingAlice, { text: 'test' }); - await api('mute/create', { userId: alice.id }, userMutingAlice); - userMutedByAlice = await signup({ username: 'userMutedByAlice' }); - await post(userMutedByAlice, { text: 'test' }); - await api('mute/create', { userId: userMutedByAlice.id }, alice); - }, 1000 * 60 * 10); - - afterAll(async () => { - await app.close(); - }); - - beforeEach(async () => { - // テスト間で影響し合わないように毎回全部消す。 - for (const user of [alice, bob]) { - const list = await api('/antennas/list', {}, user); - for (const antenna of list.body) { - await api('/antennas/delete', { antennaId: antenna.id }, user); - } - } - }); - - //#region 作成(antennas/create) - - test('が作成できること、キーが過不足なく入っていること。', async () => { - const response = await successfulApiCall({ - endpoint: 'antennas/create', - parameters: { ...defaultParam }, - user: alice, - }); - assert.match(response.id, /[0-9a-z]{10}/); - const expected = { - id: response.id, - caseSensitive: false, - createdAt: new Date(response.createdAt).toISOString(), - excludeKeywords: [['']], - hasUnreadNote: false, - isActive: true, - keywords: [['keyword']], - name: 'test', - notify: false, - src: 'all', - userListId: null, - users: [''], - withFile: false, - withReplies: false, - localOnly: false, - } as Antenna; - assert.deepStrictEqual(response, expected); - }); - - test('が上限いっぱいまで作成できること', async () => { - // antennaLimit + 1まで作れるのがキモ - const response = await Promise.all([...Array(DEFAULT_POLICIES.antennaLimit + 1)].map(() => successfulApiCall({ - endpoint: 'antennas/create', - parameters: { ...defaultParam }, - user: alice, - }))); - - const expected = await successfulApiCall({ endpoint: 'antennas/list', parameters: {}, user: alice }); - assert.deepStrictEqual( - response.sort(compareBy(s => s.id)), - expected.sort(compareBy(s => s.id))); - - failedApiCall({ - endpoint: 'antennas/create', - parameters: { ...defaultParam }, - user: alice, - }, { - status: 400, - code: 'TOO_MANY_ANTENNAS', - id: 'faf47050-e8b5-438c-913c-db2b1576fde4', - }); - }); - - test('を作成するとき他人のリストを指定したらエラーになる', async () => { - failedApiCall({ - endpoint: 'antennas/create', - parameters: { ...defaultParam, src: 'list', userListId: bobList.id }, - user: alice, - }, { - status: 400, - code: 'NO_SUCH_USER_LIST', - id: '95063e93-a283-4b8b-9aa5-bcdb8df69a7f', - }); - }); - - const antennaParamPattern = [ - { parameters: (): object => ({ name: 'x'.repeat(100) }) }, - { parameters: (): object => ({ name: 'x' }) }, - { parameters: (): object => ({ src: 'home' }) }, - { parameters: (): object => ({ src: 'all' }) }, - { parameters: (): object => ({ src: 'users' }) }, - { parameters: (): object => ({ src: 'list' }) }, - { parameters: (): object => ({ userListId: null }) }, - { parameters: (): object => ({ src: 'list', userListId: aliceList.id }) }, - { parameters: (): object => ({ keywords: [['x']] }) }, - { parameters: (): object => ({ keywords: [['a', 'b', 'c'], ['x'], ['y'], ['z']] }) }, - { parameters: (): object => ({ excludeKeywords: [['a', 'b', 'c'], ['x'], ['y'], ['z']] }) }, - { parameters: (): object => ({ users: [alice.username] }) }, - { parameters: (): object => ({ users: [alice.username, bob.username, carol.username] }) }, - { parameters: (): object => ({ caseSensitive: false }) }, - { parameters: (): object => ({ caseSensitive: true }) }, - { parameters: (): object => ({ withReplies: false }) }, - { parameters: (): object => ({ withReplies: true }) }, - { parameters: (): object => ({ withFile: false }) }, - { parameters: (): object => ({ withFile: true }) }, - { parameters: (): object => ({ notify: false }) }, - { parameters: (): object => ({ notify: true }) }, - ]; - test.each(antennaParamPattern)('を作成できること($#)', async ({ parameters }) => { - const response = await successfulApiCall({ - endpoint: 'antennas/create', - parameters: { ...defaultParam, ...parameters() }, - user: alice, - }); - const expected = { ...response, ...parameters() }; - assert.deepStrictEqual(response, expected); - }); - - //#endregion - //#region 更新(antennas/update) - - test.each(antennaParamPattern)('を変更できること($#)', async ({ parameters }) => { - const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice }); - const response = await successfulApiCall({ - endpoint: 'antennas/update', - parameters: { antennaId: antenna.id, ...defaultParam, ...parameters() }, - user: alice, - }); - const expected = { ...response, ...parameters() }; - assert.deepStrictEqual(response, expected); - }); - test.todo('は他人のものは変更できない'); - - test('を変更するとき他人のリストを指定したらエラーになる', async () => { - const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice }); - failedApiCall({ - endpoint: 'antennas/update', - parameters: { antennaId: antenna.id, ...defaultParam, src: 'list', userListId: bobList.id }, - user: alice, - }, { - status: 400, - code: 'NO_SUCH_USER_LIST', - id: '1c6b35c9-943e-48c2-81e4-2844989407f7', - }); - }); - - //#endregion - //#region 表示(antennas/show) - - test('をID指定で表示できること。', async () => { - const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice }); - const response = await successfulApiCall({ - endpoint: 'antennas/show', - parameters: { antennaId: antenna.id }, - user: alice, - }); - const expected = { ...antenna }; - assert.deepStrictEqual(response, expected); - }); - test.todo('は他人のものをID指定で表示できない'); - - //#endregion - //#region 一覧(antennas/list) - - test('をリスト形式で取得できること。', async () => { - const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice }); - await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: bob }); - const response = await successfulApiCall({ - endpoint: 'antennas/list', - parameters: {}, - user: alice, - }); - const expected = [{ ...antenna }]; - assert.deepStrictEqual(response, expected); - }); - - //#endregion - //#region 削除(antennas/delete) - - test('を削除できること。', async () => { - const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice }); - const response = await successfulApiCall({ - endpoint: 'antennas/delete', - parameters: { antennaId: antenna.id }, - user: alice, - }); - assert.deepStrictEqual(response, null); - const list = await successfulApiCall({ endpoint: 'antennas/list', parameters: {}, user: alice }); - assert.deepStrictEqual(list, []); - }); - test.todo('は他人のものを削除できない'); - - //#endregion - - describe('のノート', () => { - //#region アンテナのノート取得(antennas/notes) - - test('を取得できること。', async () => { - const keyword = 'キーワード'; - await post(bob, { text: `test ${keyword} beforehand` }); - const antenna = await successfulApiCall({ - endpoint: 'antennas/create', - parameters: { ...defaultParam, keywords: [[keyword]] }, - user: alice, - }); - const note = await post(bob, { text: `test ${keyword}` }); - const response = await successfulApiCall({ - endpoint: 'antennas/notes', - parameters: { antennaId: antenna.id }, - user: alice, - }); - const expected = [note]; - assert.deepStrictEqual(response, expected); - }); - - const keyword = 'キーワード'; - test.each([ - { - label: '全体から', - parameters: (): object => ({ src: 'all' }), - posts: [ - { note: (): Promise => post(alice, { text: `${keyword}` }), included: true }, - { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}` }), included: true }, - { note: (): Promise => post(bob, { text: `test ${keyword}` }), included: true }, - { note: (): Promise => post(carol, { text: `test ${keyword}` }), included: true }, - ], - }, - { - // BUG e4144a1 以降home指定は壊れている(allと同じ) - label: 'ホーム指定はallと同じ', - parameters: (): object => ({ src: 'home' }), - posts: [ - { note: (): Promise => post(alice, { text: `${keyword}` }), included: true }, - { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}` }), included: true }, - { note: (): Promise => post(bob, { text: `test ${keyword}` }), included: true }, - { note: (): Promise => post(carol, { text: `test ${keyword}` }), included: true }, - ], - }, - { - // https://github.com/misskey-dev/misskey/issues/9025 - label: 'ただし、フォロワー限定投稿とDM投稿を含まない。フォロワーであっても。', - parameters: (): object => ({}), - posts: [ - { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'public' }), included: true }, - { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'home' }), included: true }, - { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }) }, - { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }) }, - ], - }, - { - label: 'ブロックしているユーザーのノートは含む', - parameters: (): object => ({}), - posts: [ - { note: (): Promise => post(userBlockedByAlice, { text: `${keyword}` }), included: true }, - ], - }, - { - label: 'ブロックされているユーザーのノートは含まない', - parameters: (): object => ({}), - posts: [ - { note: (): Promise => post(userBlockingAlice, { text: `${keyword}` }) }, - ], - }, - { - label: 'ミュートしているユーザーのノートは含まない', - parameters: (): object => ({}), - posts: [ - { note: (): Promise => post(userMutedByAlice, { text: `${keyword}` }) }, - ], - }, - { - label: 'ミュートされているユーザーのノートは含む', - parameters: (): object => ({}), - posts: [ - { note: (): Promise => post(userMutingAlice, { text: `${keyword}` }), included: true }, - ], - }, - { - label: '「見つけやすくする」がOFFのユーザーのノートも含まれる', - parameters: (): object => ({}), - posts: [ - { note: (): Promise => post(userNotExplorable, { text: `${keyword}` }), included: true }, - ], - }, - { - label: '鍵付きユーザーのノートも含まれる', - parameters: (): object => ({}), - posts: [ - { note: (): Promise => post(userLocking, { text: `${keyword}` }), included: true }, - ], - }, - { - label: 'サイレンスのノートも含まれる', - parameters: (): object => ({}), - posts: [ - { note: (): Promise => post(userSilenced, { text: `${keyword}` }), included: true }, - ], - }, - { - label: '削除ユーザーのノートも含まれる', - parameters: (): object => ({}), - posts: [ - { note: (): Promise => post(userDeletedBySelf, { text: `${keyword}` }), included: true }, - { note: (): Promise => post(userDeletedByAdmin, { text: `${keyword}` }), included: true }, - ], - }, - { - label: 'ユーザー指定で', - parameters: (): object => ({ src: 'users', users: [`@${bob.username}`, `@${carol.username}`] }), - posts: [ - { note: (): Promise => post(alice, { text: `test ${keyword}` }) }, - { note: (): Promise => post(bob, { text: `test ${keyword}` }), included: true }, - { note: (): Promise => post(carol, { text: `test ${keyword}` }), included: true }, - ], - }, - { - label: 'リスト指定で', - parameters: (): object => ({ src: 'list', userListId: aliceList.id }), - posts: [ - { note: (): Promise => post(alice, { text: `test ${keyword}` }) }, - { note: (): Promise => post(bob, { text: `test ${keyword}` }), included: true }, - { note: (): Promise => post(carol, { text: `test ${keyword}` }), included: true }, - ], - }, - { - label: 'CWにもマッチする', - parameters: (): object => ({ keywords: [[keyword]] }), - posts: [ - { note: (): Promise => post(bob, { text: 'test', cw: `cw ${keyword}` }), included: true }, - ], - }, - { - label: 'キーワード1つ', - parameters: (): object => ({ keywords: [[keyword]] }), - posts: [ - { note: (): Promise => post(alice, { text: 'test' }) }, - { note: (): Promise => post(bob, { text: `test ${keyword}` }), included: true }, - { note: (): Promise => post(carol, { text: 'test' }) }, - ], - }, - { - label: 'キーワード3つ(AND)', - parameters: (): object => ({ keywords: [['A', 'B', 'C']] }), - posts: [ - { note: (): Promise => post(bob, { text: 'test A' }) }, - { note: (): Promise => post(bob, { text: 'test A B' }) }, - { note: (): Promise => post(bob, { text: 'test B C' }) }, - { note: (): Promise => post(bob, { text: 'test A B C' }), included: true }, - { note: (): Promise => post(bob, { text: 'test C B A A B C' }), included: true }, - ], - }, - { - label: 'キーワード3つ(OR)', - parameters: (): object => ({ keywords: [['A'], ['B'], ['C']] }), - posts: [ - { note: (): Promise => post(bob, { text: 'test' }) }, - { note: (): Promise => post(bob, { text: 'test A' }), included: true }, - { note: (): Promise => post(bob, { text: 'test A B' }), included: true }, - { note: (): Promise => post(bob, { text: 'test B C' }), included: true }, - { note: (): Promise => post(bob, { text: 'test B C A' }), included: true }, - { note: (): Promise => post(bob, { text: 'test C B' }), included: true }, - { note: (): Promise => post(bob, { text: 'test C' }), included: true }, - ], - }, - { - label: '除外ワード3つ(AND)', - parameters: (): object => ({ excludeKeywords: [['A', 'B', 'C']] }), - posts: [ - { note: (): Promise => post(bob, { text: `test ${keyword}` }), included: true }, - { note: (): Promise => post(bob, { text: `test ${keyword} A` }), included: true }, - { note: (): Promise => post(bob, { text: `test ${keyword} A B` }), included: true }, - { note: (): Promise => post(bob, { text: `test ${keyword} B C` }), included: true }, - { note: (): Promise => post(bob, { text: `test ${keyword} B C A` }) }, - { note: (): Promise => post(bob, { text: `test ${keyword} C B` }), included: true }, - { note: (): Promise => post(bob, { text: `test ${keyword} C` }), included: true }, - ], - }, - { - label: '除外ワード3つ(OR)', - parameters: (): object => ({ excludeKeywords: [['A'], ['B'], ['C']] }), - posts: [ - { note: (): Promise => post(bob, { text: `test ${keyword}` }), included: true }, - { note: (): Promise => post(bob, { text: `test ${keyword} A` }) }, - { note: (): Promise => post(bob, { text: `test ${keyword} A B` }) }, - { note: (): Promise => post(bob, { text: `test ${keyword} B C` }) }, - { note: (): Promise => post(bob, { text: `test ${keyword} B C A` }) }, - { note: (): Promise => post(bob, { text: `test ${keyword} C B` }) }, - { note: (): Promise => post(bob, { text: `test ${keyword} C` }) }, - ], - }, - { - label: 'キーワード1つ(大文字小文字区別する)', - parameters: (): object => ({ keywords: [['KEYWORD']], caseSensitive: true }), - posts: [ - { note: (): Promise => post(bob, { text: 'keyword' }) }, - { note: (): Promise => post(bob, { text: 'kEyWoRd' }) }, - { note: (): Promise => post(bob, { text: 'KEYWORD' }), included: true }, - ], - }, - { - label: 'キーワード1つ(大文字小文字区別しない)', - parameters: (): object => ({ keywords: [['KEYWORD']], caseSensitive: false }), - posts: [ - { note: (): Promise => post(bob, { text: 'keyword' }), included: true }, - { note: (): Promise => post(bob, { text: 'kEyWoRd' }), included: true }, - { note: (): Promise => post(bob, { text: 'KEYWORD' }), included: true }, - ], - }, - { - label: '除外ワード1つ(大文字小文字区別する)', - parameters: (): object => ({ excludeKeywords: [['KEYWORD']], caseSensitive: true }), - posts: [ - { note: (): Promise => post(bob, { text: `${keyword}` }), included: true }, - { note: (): Promise => post(bob, { text: `${keyword} keyword` }), included: true }, - { note: (): Promise => post(bob, { text: `${keyword} kEyWoRd` }), included: true }, - { note: (): Promise => post(bob, { text: `${keyword} KEYWORD` }) }, - ], - }, - { - label: '除外ワード1つ(大文字小文字区別しない)', - parameters: (): object => ({ excludeKeywords: [['KEYWORD']], caseSensitive: false }), - posts: [ - { note: (): Promise => post(bob, { text: `${keyword}` }), included: true }, - { note: (): Promise => post(bob, { text: `${keyword} keyword` }) }, - { note: (): Promise => post(bob, { text: `${keyword} kEyWoRd` }) }, - { note: (): Promise => post(bob, { text: `${keyword} KEYWORD` }) }, - ], - }, - { - label: '添付ファイルを問わない', - parameters: (): object => ({ withFile: false }), - posts: [ - { note: (): Promise => post(bob, { text: `${keyword}`, fileIds: [bobFile.id] }), included: true }, - { note: (): Promise => post(bob, { text: `${keyword}` }), included: true }, - ], - }, - { - label: '添付ファイル付きのみ', - parameters: (): object => ({ withFile: true }), - posts: [ - { note: (): Promise => post(bob, { text: `${keyword}`, fileIds: [bobFile.id] }), included: true }, - { note: (): Promise => post(bob, { text: `${keyword}` }) }, - ], - }, - { - label: 'リプライ以外', - parameters: (): object => ({ withReplies: false }), - posts: [ - { note: (): Promise => post(bob, { text: `${keyword}`, replyId: alicePost.id }) }, - { note: (): Promise => post(bob, { text: `${keyword}` }), included: true }, - ], - }, - { - label: 'リプライも含む', - parameters: (): object => ({ withReplies: true }), - posts: [ - { note: (): Promise => post(bob, { text: `${keyword}`, replyId: alicePost.id }), included: true }, - { note: (): Promise => post(bob, { text: `${keyword}` }), included: true }, - ], - }, - ])('が取得できること($label)', async ({ parameters, posts }) => { - const antenna = await successfulApiCall({ - endpoint: 'antennas/create', - parameters: { ...defaultParam, keywords: [[keyword]], ...parameters() }, - user: alice, - }); - - const notes = await posts.reduce(async (prev, current) => { - // includedに関わらずnote()は評価して投稿する。 - const p = await prev; - const n = await current.note(); - if (current.included) return p.concat(n); - return p; - }, Promise.resolve([] as Note[])); - - // alice視点でNoteを取り直す - const expected = await Promise.all(notes.reverse().map(s => successfulApiCall({ - endpoint: 'notes/show', - parameters: { noteId: s.id }, - user: alice, - }))); - - const response = await successfulApiCall({ - endpoint: 'antennas/notes', - parameters: { antennaId: antenna.id }, - user: alice, - }); - assert.deepStrictEqual( - response.map(({ userId, id, text }) => ({ userId, id, text })), - expected.map(({ userId, id, text }) => ({ userId, id, text }))); - assert.deepStrictEqual(response, expected); - }); - - test.skip('が取得でき、日付指定のPaginationに一貫性があること', async () => { }); - test.each([ - { label: 'ID指定', offsetBy: 'id' }, - - // BUG sinceDate, untilDateはsinceIdや他のエンドポイントとは異なり、その時刻に一致するレコードを含んでしまう。 - // { label: '日付指定', offsetBy: 'createdAt' }, - ] as const)('が取得でき、$labelのPaginationに一貫性があること', async ({ offsetBy }) => { - const antenna = await successfulApiCall({ - endpoint: 'antennas/create', - parameters: { ...defaultParam, keywords: [[keyword]] }, - user: alice, - }); - const notes = await [...Array(30)].reduce(async (prev, current, index) => { - const p = await prev; - const n = await post(alice, { text: `${keyword} (${index})` }); - return [n].concat(p); - }, Promise.resolve([] as Note[])); - - // antennas/notesは降順のみで、昇順をサポートしない。 - await testPaginationConsistency(notes, async (paginationParam) => { - return successfulApiCall({ - endpoint: 'antennas/notes', - parameters: { antennaId: antenna.id, ...paginationParam }, - user: alice, - }) as any as Note[]; - }, offsetBy, 'desc'); - }); - - // BUG 7日過ぎると作り直すしかない。 https://github.com/misskey-dev/misskey/issues/10476 - test.todo('を取得したときActiveに戻る'); - - //#endregion - }); -}); diff --git a/packages/backend/test/e2e/api-visibility.ts b/packages/backend/test/e2e/api-visibility.ts deleted file mode 100644 index 33c8d03fdb..0000000000 --- a/packages/backend/test/e2e/api-visibility.ts +++ /dev/null @@ -1,483 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { signup, api, post, startServer } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; -import type * as misskey from 'misskey-js'; - -describe('API visibility', () => { - let app: INestApplicationContext; - - beforeAll(async () => { - app = await startServer(); - }, 1000 * 60 * 2); - - afterAll(async () => { - await app.close(); - }); - - describe('Note visibility', () => { - //#region vars - /** ヒロイン */ - let alice: misskey.entities.MeSignup; - /** フォロワー */ - let follower: misskey.entities.MeSignup; - /** 非フォロワー */ - let other: misskey.entities.MeSignup; - /** 非フォロワーでもリプライやメンションをされた人 */ - let target: misskey.entities.MeSignup; - /** specified mentionでmentionを飛ばされる人 */ - let target2: misskey.entities.MeSignup; - - /** public-post */ - let pub: any; - /** home-post */ - let home: any; - /** followers-post */ - let fol: any; - /** specified-post */ - let spe: any; - - /** public-reply to target's post */ - let pubR: any; - /** home-reply to target's post */ - let homeR: any; - /** followers-reply to target's post */ - let folR: any; - /** specified-reply to target's post */ - let speR: any; - - /** public-mention to target */ - let pubM: any; - /** home-mention to target */ - let homeM: any; - /** followers-mention to target */ - let folM: any; - /** specified-mention to target */ - let speM: any; - - /** reply target post */ - let tgt: any; - //#endregion - - const show = async (noteId: any, by: any) => { - return await api('/notes/show', { - noteId, - }, by); - }; - - beforeAll(async () => { - //#region prepare - // signup - alice = await signup({ username: 'alice' }); - follower = await signup({ username: 'follower' }); - other = await signup({ username: 'other' }); - target = await signup({ username: 'target' }); - target2 = await signup({ username: 'target2' }); - - // follow alice <= follower - await api('/following/create', { userId: alice.id }, follower); - - // normal posts - pub = await post(alice, { text: 'x', visibility: 'public' }); - home = await post(alice, { text: 'x', visibility: 'home' }); - fol = await post(alice, { text: 'x', visibility: 'followers' }); - spe = await post(alice, { text: 'x', visibility: 'specified', visibleUserIds: [target.id] }); - - // replies - tgt = await post(target, { text: 'y', visibility: 'public' }); - pubR = await post(alice, { text: 'x', replyId: tgt.id, visibility: 'public' }); - homeR = await post(alice, { text: 'x', replyId: tgt.id, visibility: 'home' }); - folR = await post(alice, { text: 'x', replyId: tgt.id, visibility: 'followers' }); - speR = await post(alice, { text: 'x', replyId: tgt.id, visibility: 'specified' }); - - // mentions - pubM = await post(alice, { text: '@target x', replyId: tgt.id, visibility: 'public' }); - homeM = await post(alice, { text: '@target x', replyId: tgt.id, visibility: 'home' }); - folM = await post(alice, { text: '@target x', replyId: tgt.id, visibility: 'followers' }); - speM = await post(alice, { text: '@target2 x', replyId: tgt.id, visibility: 'specified' }); - //#endregion - }); - - //#region show post - // public - test('[show] public-postを自分が見れる', async () => { - const res = await show(pub.id, alice); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] public-postをフォロワーが見れる', async () => { - const res = await show(pub.id, follower); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] public-postを非フォロワーが見れる', async () => { - const res = await show(pub.id, other); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] public-postを未認証が見れる', async () => { - const res = await show(pub.id, null); - assert.strictEqual(res.body.text, 'x'); - }); - - // home - test('[show] home-postを自分が見れる', async () => { - const res = await show(home.id, alice); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] home-postをフォロワーが見れる', async () => { - const res = await show(home.id, follower); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] home-postを非フォロワーが見れる', async () => { - const res = await show(home.id, other); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] home-postを未認証が見れる', async () => { - const res = await show(home.id, null); - assert.strictEqual(res.body.text, 'x'); - }); - - // followers - test('[show] followers-postを自分が見れる', async () => { - const res = await show(fol.id, alice); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] followers-postをフォロワーが見れる', async () => { - const res = await show(fol.id, follower); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] followers-postを非フォロワーが見れない', async () => { - const res = await show(fol.id, other); - assert.strictEqual(res.body.isHidden, true); - }); - - test('[show] followers-postを未認証が見れない', async () => { - const res = await show(fol.id, null); - assert.strictEqual(res.body.isHidden, true); - }); - - // specified - test('[show] specified-postを自分が見れる', async () => { - const res = await show(spe.id, alice); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] specified-postを指定ユーザーが見れる', async () => { - const res = await show(spe.id, target); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] specified-postをフォロワーが見れない', async () => { - const res = await show(spe.id, follower); - assert.strictEqual(res.body.isHidden, true); - }); - - test('[show] specified-postを非フォロワーが見れない', async () => { - const res = await show(spe.id, other); - assert.strictEqual(res.body.isHidden, true); - }); - - test('[show] specified-postを未認証が見れない', async () => { - const res = await show(spe.id, null); - assert.strictEqual(res.body.isHidden, true); - }); - //#endregion - - //#region show reply - // public - test('[show] public-replyを自分が見れる', async () => { - const res = await show(pubR.id, alice); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] public-replyをされた人が見れる', async () => { - const res = await show(pubR.id, target); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] public-replyをフォロワーが見れる', async () => { - const res = await show(pubR.id, follower); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] public-replyを非フォロワーが見れる', async () => { - const res = await show(pubR.id, other); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] public-replyを未認証が見れる', async () => { - const res = await show(pubR.id, null); - assert.strictEqual(res.body.text, 'x'); - }); - - // home - test('[show] home-replyを自分が見れる', async () => { - const res = await show(homeR.id, alice); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] home-replyをされた人が見れる', async () => { - const res = await show(homeR.id, target); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] home-replyをフォロワーが見れる', async () => { - const res = await show(homeR.id, follower); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] home-replyを非フォロワーが見れる', async () => { - const res = await show(homeR.id, other); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] home-replyを未認証が見れる', async () => { - const res = await show(homeR.id, null); - assert.strictEqual(res.body.text, 'x'); - }); - - // followers - test('[show] followers-replyを自分が見れる', async () => { - const res = await show(folR.id, alice); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] followers-replyを非フォロワーでもリプライされていれば見れる', async () => { - const res = await show(folR.id, target); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] followers-replyをフォロワーが見れる', async () => { - const res = await show(folR.id, follower); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] followers-replyを非フォロワーが見れない', async () => { - const res = await show(folR.id, other); - assert.strictEqual(res.body.isHidden, true); - }); - - test('[show] followers-replyを未認証が見れない', async () => { - const res = await show(folR.id, null); - assert.strictEqual(res.body.isHidden, true); - }); - - // specified - test('[show] specified-replyを自分が見れる', async () => { - const res = await show(speR.id, alice); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] specified-replyを指定ユーザーが見れる', async () => { - const res = await show(speR.id, target); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] specified-replyをされた人が指定されてなくても見れる', async () => { - const res = await show(speR.id, target); - assert.strictEqual(res.body.text, 'x'); - }); - - test('[show] specified-replyをフォロワーが見れない', async () => { - const res = await show(speR.id, follower); - assert.strictEqual(res.body.isHidden, true); - }); - - test('[show] specified-replyを非フォロワーが見れない', async () => { - const res = await show(speR.id, other); - assert.strictEqual(res.body.isHidden, true); - }); - - test('[show] specified-replyを未認証が見れない', async () => { - const res = await show(speR.id, null); - assert.strictEqual(res.body.isHidden, true); - }); - //#endregion - - //#region show mention - // public - test('[show] public-mentionを自分が見れる', async () => { - const res = await show(pubM.id, alice); - assert.strictEqual(res.body.text, '@target x'); - }); - - test('[show] public-mentionをされた人が見れる', async () => { - const res = await show(pubM.id, target); - assert.strictEqual(res.body.text, '@target x'); - }); - - test('[show] public-mentionをフォロワーが見れる', async () => { - const res = await show(pubM.id, follower); - assert.strictEqual(res.body.text, '@target x'); - }); - - test('[show] public-mentionを非フォロワーが見れる', async () => { - const res = await show(pubM.id, other); - assert.strictEqual(res.body.text, '@target x'); - }); - - test('[show] public-mentionを未認証が見れる', async () => { - const res = await show(pubM.id, null); - assert.strictEqual(res.body.text, '@target x'); - }); - - // home - test('[show] home-mentionを自分が見れる', async () => { - const res = await show(homeM.id, alice); - assert.strictEqual(res.body.text, '@target x'); - }); - - test('[show] home-mentionをされた人が見れる', async () => { - const res = await show(homeM.id, target); - assert.strictEqual(res.body.text, '@target x'); - }); - - test('[show] home-mentionをフォロワーが見れる', async () => { - const res = await show(homeM.id, follower); - assert.strictEqual(res.body.text, '@target x'); - }); - - test('[show] home-mentionを非フォロワーが見れる', async () => { - const res = await show(homeM.id, other); - assert.strictEqual(res.body.text, '@target x'); - }); - - test('[show] home-mentionを未認証が見れる', async () => { - const res = await show(homeM.id, null); - assert.strictEqual(res.body.text, '@target x'); - }); - - // followers - test('[show] followers-mentionを自分が見れる', async () => { - const res = await show(folM.id, alice); - assert.strictEqual(res.body.text, '@target x'); - }); - - test('[show] followers-mentionをメンションされていれば非フォロワーでも見れる', async () => { - const res = await show(folM.id, target); - assert.strictEqual(res.body.text, '@target x'); - }); - - test('[show] followers-mentionをフォロワーが見れる', async () => { - const res = await show(folM.id, follower); - assert.strictEqual(res.body.text, '@target x'); - }); - - test('[show] followers-mentionを非フォロワーが見れない', async () => { - const res = await show(folM.id, other); - assert.strictEqual(res.body.isHidden, true); - }); - - test('[show] followers-mentionを未認証が見れない', async () => { - const res = await show(folM.id, null); - assert.strictEqual(res.body.isHidden, true); - }); - - // specified - test('[show] specified-mentionを自分が見れる', async () => { - const res = await show(speM.id, alice); - assert.strictEqual(res.body.text, '@target2 x'); - }); - - test('[show] specified-mentionを指定ユーザーが見れる', async () => { - const res = await show(speM.id, target); - assert.strictEqual(res.body.text, '@target2 x'); - }); - - test('[show] specified-mentionをされた人が指定されてなかったら見れない', async () => { - const res = await show(speM.id, target2); - assert.strictEqual(res.body.isHidden, true); - }); - - test('[show] specified-mentionをフォロワーが見れない', async () => { - const res = await show(speM.id, follower); - assert.strictEqual(res.body.isHidden, true); - }); - - test('[show] specified-mentionを非フォロワーが見れない', async () => { - const res = await show(speM.id, other); - assert.strictEqual(res.body.isHidden, true); - }); - - test('[show] specified-mentionを未認証が見れない', async () => { - const res = await show(speM.id, null); - assert.strictEqual(res.body.isHidden, true); - }); - //#endregion - - //#region HTL - test('[HTL] public-post が 自分が見れる', async () => { - const res = await api('/notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === pub.id); - assert.strictEqual(notes[0].text, 'x'); - }); - - test('[HTL] public-post が 非フォロワーから見れない', async () => { - const res = await api('/notes/timeline', { limit: 100 }, other); - assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === pub.id); - assert.strictEqual(notes.length, 0); - }); - - test('[HTL] followers-post が フォロワーから見れる', async () => { - const res = await api('/notes/timeline', { limit: 100 }, follower); - assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === fol.id); - assert.strictEqual(notes[0].text, 'x'); - }); - //#endregion - - //#region RTL - test('[replies] followers-reply が フォロワーから見れる', async () => { - const res = await api('/notes/replies', { noteId: tgt.id, limit: 100 }, follower); - assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === folR.id); - assert.strictEqual(notes[0].text, 'x'); - }); - - test('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async () => { - const res = await api('/notes/replies', { noteId: tgt.id, limit: 100 }, other); - assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === folR.id); - assert.strictEqual(notes.length, 0); - }); - - test('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => { - const res = await api('/notes/replies', { noteId: tgt.id, limit: 100 }, target); - assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === folR.id); - assert.strictEqual(notes[0].text, 'x'); - }); - //#endregion - - //#region MTL - test('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => { - const res = await api('/notes/mentions', { limit: 100 }, target); - assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === folR.id); - assert.strictEqual(notes[0].text, 'x'); - }); - - test('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async () => { - const res = await api('/notes/mentions', { limit: 100 }, target); - assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === folM.id); - assert.strictEqual(notes[0].text, '@target x'); - }); - //#endregion - }); -}); - diff --git a/packages/backend/test/e2e/api.ts b/packages/backend/test/e2e/api.ts deleted file mode 100644 index cf24228b83..0000000000 --- a/packages/backend/test/e2e/api.ts +++ /dev/null @@ -1,305 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { IncomingMessage } from 'http'; -import { signup, api, startServer, successfulApiCall, failedApiCall, uploadFile, waitFire, connectStream, relativeFetch, createAppToken } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; -import type * as misskey from 'misskey-js'; - -describe('API', () => { - let app: INestApplicationContext; - let alice: misskey.entities.MeSignup; - let bob: misskey.entities.MeSignup; - let carol: misskey.entities.MeSignup; - - beforeAll(async () => { - app = await startServer(); - alice = await signup({ username: 'alice' }); - bob = await signup({ username: 'bob' }); - carol = await signup({ username: 'carol' }); - }, 1000 * 60 * 2); - - afterAll(async () => { - await app.close(); - }); - - describe('General validation', () => { - test('wrong type', async () => { - const res = await api('/test', { - required: true, - string: 42, - }); - assert.strictEqual(res.status, 400); - }); - - test('missing require param', async () => { - const res = await api('/test', { - string: 'a', - }); - assert.strictEqual(res.status, 400); - }); - - test('invalid misskey:id (empty string)', async () => { - const res = await api('/test', { - required: true, - id: '', - }); - assert.strictEqual(res.status, 400); - }); - - test('valid misskey:id', async () => { - const res = await api('/test', { - required: true, - id: '8wvhjghbxu', - }); - assert.strictEqual(res.status, 200); - }); - - test('default value', async () => { - const res = await api('/test', { - required: true, - string: 'a', - }); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.body.default, 'hello'); - }); - - test('can set null even if it has default value', async () => { - const res = await api('/test', { - required: true, - nullableDefault: null, - }); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.body.nullableDefault, null); - }); - - test('cannot set undefined if it has default value', async () => { - const res = await api('/test', { - required: true, - nullableDefault: undefined, - }); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.body.nullableDefault, 'hello'); - }); - }); - - test('管理者専用のAPIのアクセス制限', async () => { - const application = await createAppToken(alice, ['read:account']); - const application2 = await createAppToken(alice, ['read:admin:index-stats']); - const application3 = await createAppToken(bob, []); - const application4 = await createAppToken(bob, ['read:admin:index-stats']); - - // aliceは管理者、APIを使える - await successfulApiCall({ - endpoint: '/admin/get-index-stats', - parameters: {}, - user: alice, - }); - - // bobは一般ユーザーだからダメ - await failedApiCall({ - endpoint: '/admin/get-index-stats', - parameters: {}, - user: bob, - }, { - status: 403, - code: 'ROLE_PERMISSION_DENIED', - id: 'c3d38592-54c0-429d-be96-5636b0431a61', - }); - - // publicアクセスももちろんダメ - await failedApiCall({ - endpoint: '/admin/get-index-stats', - parameters: {}, - user: undefined, - }, { - status: 401, - code: 'CREDENTIAL_REQUIRED', - id: '1384574d-a912-4b81-8601-c7b1c4085df1', - }); - - // ごまがしもダメ - await failedApiCall({ - endpoint: '/admin/get-index-stats', - parameters: {}, - user: { token: 'tsukawasete' }, - }, { - status: 401, - code: 'AUTHENTICATION_FAILED', - id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', - }); - - await successfulApiCall({ - endpoint: '/admin/get-index-stats', - parameters: {}, - user: { token: application2 }, - }); - - await failedApiCall({ - endpoint: '/admin/get-index-stats', - parameters: {}, - user: { token: application }, - }, { - status: 403, - code: 'PERMISSION_DENIED', - id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', - }); - - await failedApiCall({ - endpoint: '/admin/get-index-stats', - parameters: {}, - user: { token: application3 }, - }, { - status: 403, - code: 'ROLE_PERMISSION_DENIED', - id: 'c3d38592-54c0-429d-be96-5636b0431a61', - }); - - await failedApiCall({ - endpoint: '/admin/get-index-stats', - parameters: {}, - user: { token: application4 }, - }, { - status: 403, - code: 'ROLE_PERMISSION_DENIED', - id: 'c3d38592-54c0-429d-be96-5636b0431a61', - }); - }); - - describe('Authentication header', () => { - test('一般リクエスト', async () => { - await successfulApiCall({ - endpoint: '/admin/get-index-stats', - parameters: {}, - user: { - token: alice.token, - bearer: true, - }, - }); - }); - - test('multipartリクエスト', async () => { - const result = await uploadFile({ - token: alice.token, - bearer: true, - }); - assert.strictEqual(result.status, 200); - }); - - test('streaming', async () => { - const fired = await waitFire( - { - token: alice.token, - bearer: true, - }, - 'homeTimeline', - () => api('notes/create', { text: 'foo' }, alice), - msg => msg.type === 'note' && msg.body.text === 'foo', - ); - assert.strictEqual(fired, true); - }); - }); - - describe('tokenエラー応答でWWW-Authenticate headerを送る', () => { - describe('invalid_token', () => { - test('一般リクエスト', async () => { - const result = await api('/admin/get-index-stats', {}, { - token: 'syuilo', - bearer: true, - }); - assert.strictEqual(result.status, 401); - assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_token", error_description')); - }); - - test('multipartリクエスト', async () => { - const result = await uploadFile({ - token: 'syuilo', - bearer: true, - }); - assert.strictEqual(result.status, 401); - assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_token", error_description')); - }); - - test('streaming', async () => { - await assert.rejects(connectStream( - { - token: 'syuilo', - bearer: true, - }, - 'homeTimeline', - () => { }, - ), (err: IncomingMessage) => { - assert.strictEqual(err.statusCode, 401); - assert.ok(err.headers['www-authenticate']?.startsWith('Bearer realm="Misskey", error="invalid_token", error_description')); - return true; - }); - }); - }); - - describe('tokenがないとrealmだけおくる', () => { - test('一般リクエスト', async () => { - const result = await api('/admin/get-index-stats', {}); - assert.strictEqual(result.status, 401); - assert.strictEqual(result.headers.get('WWW-Authenticate'), 'Bearer realm="Misskey"'); - }); - - test('multipartリクエスト', async () => { - const result = await uploadFile(); - assert.strictEqual(result.status, 401); - assert.strictEqual(result.headers.get('WWW-Authenticate'), 'Bearer realm="Misskey"'); - }); - }); - - test('invalid_request', async () => { - const result = await api('/notes/create', { text: true }, { - token: alice.token, - bearer: true, - }); - assert.strictEqual(result.status, 400); - assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_request", error_description')); - }); - - describe('invalid bearer format', () => { - test('No preceding bearer', async () => { - const result = await relativeFetch('api/notes/create', { - method: 'POST', - headers: { - Authorization: alice.token, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ text: 'test' }), - }); - assert.strictEqual(result.status, 401); - }); - - test('Lowercase bearer', async () => { - const result = await relativeFetch('api/notes/create', { - method: 'POST', - headers: { - Authorization: `bearer ${alice.token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ text: 'test' }), - }); - assert.strictEqual(result.status, 401); - }); - - test('No space after bearer', async () => { - const result = await relativeFetch('api/notes/create', { - method: 'POST', - headers: { - Authorization: `Bearer${alice.token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ text: 'test' }), - }); - assert.strictEqual(result.status, 401); - }); - }); - }); -}); diff --git a/packages/backend/test/e2e/block.ts b/packages/backend/test/e2e/block.ts deleted file mode 100644 index 4445d9036c..0000000000 --- a/packages/backend/test/e2e/block.ts +++ /dev/null @@ -1,91 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { signup, api, post, startServer } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; -import type * as misskey from 'misskey-js'; - -describe('Block', () => { - let app: INestApplicationContext; - - // alice blocks bob - let alice: misskey.entities.MeSignup; - let bob: misskey.entities.MeSignup; - let carol: misskey.entities.MeSignup; - - beforeAll(async () => { - app = await startServer(); - alice = await signup({ username: 'alice' }); - bob = await signup({ username: 'bob' }); - carol = await signup({ username: 'carol' }); - }, 1000 * 60 * 2); - - afterAll(async () => { - await app.close(); - }); - - test('Block作成', async () => { - const res = await api('/blocking/create', { - userId: bob.id, - }, alice); - - assert.strictEqual(res.status, 200); - }); - - test('ブロックされているユーザーをフォローできない', async () => { - const res = await api('/following/create', { userId: alice.id }, bob); - - assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.id, 'c4ab57cc-4e41-45e9-bfd9-584f61e35ce0'); - }); - - test('ブロックされているユーザーにリアクションできない', async () => { - const note = await post(alice, { text: 'hello' }); - - const res = await api('/notes/reactions/create', { noteId: note.id, reaction: '👍' }, bob); - - assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.id, '20ef5475-9f38-4e4c-bd33-de6d979498ec'); - }); - - test('ブロックされているユーザーに返信できない', async () => { - const note = await post(alice, { text: 'hello' }); - - const res = await api('/notes/create', { replyId: note.id, text: 'yo' }, bob); - - assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); - }); - - test('ブロックされているユーザーのノートをRenoteできない', async () => { - const note = await post(alice, { text: 'hello' }); - - const res = await api('/notes/create', { renoteId: note.id, text: 'yo' }, bob); - - assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); - }); - - // TODO: ユーザーリストに入れられないテスト - - // TODO: ユーザーリストから除外されるテスト - - test('タイムライン(LTL)にブロックされているユーザーの投稿が含まれない', async () => { - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi' }); - const carolNote = await post(carol, { text: 'hi' }); - - const res = await api('/notes/local-timeline', {}, bob); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); - }); -}); diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts deleted file mode 100644 index 49092fba63..0000000000 --- a/packages/backend/test/e2e/clips.ts +++ /dev/null @@ -1,967 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { JTDDataType } from 'ajv/dist/jtd'; -import { DEFAULT_POLICIES } from '@/core/RoleService.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { paramDef as CreateParamDef } from '@/server/api/endpoints/clips/create.js'; -import { paramDef as UpdateParamDef } from '@/server/api/endpoints/clips/update.js'; -import { paramDef as DeleteParamDef } from '@/server/api/endpoints/clips/delete.js'; -import { paramDef as ShowParamDef } from '@/server/api/endpoints/clips/show.js'; -import { paramDef as FavoriteParamDef } from '@/server/api/endpoints/clips/favorite.js'; -import { paramDef as UnfavoriteParamDef } from '@/server/api/endpoints/clips/unfavorite.js'; -import { paramDef as AddNoteParamDef } from '@/server/api/endpoints/clips/add-note.js'; -import { paramDef as RemoveNoteParamDef } from '@/server/api/endpoints/clips/remove-note.js'; -import { paramDef as NotesParamDef } from '@/server/api/endpoints/clips/notes.js'; -import { - signup, - post, - startServer, - api, - successfulApiCall, - failedApiCall, - ApiRequest, - hiddenNote, -} from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; - -describe('クリップ', () => { - type User = Packed<'User'>; - type Note = Packed<'Note'>; - type Clip = Packed<'Clip'>; - - let app: INestApplicationContext; - - let alice: User; - let bob: User; - let aliceNote: Note; - let aliceHomeNote: Note; - let aliceFollowersNote: Note; - let aliceSpecifiedNote: Note; - let bobNote: Note; - let bobHomeNote: Note; - let bobFollowersNote: Note; - let bobSpecifiedNote: Note; - - const compareBy = (selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => { - return selector(a).localeCompare(selector(b)); - }; - - type CreateParam = JTDDataType; - const defaultCreate = (): Partial => ({ - name: 'test', - }); - const create = async (parameters: Partial = {}, request: Partial = {}): Promise => { - const clip = await successfulApiCall({ - endpoint: '/clips/create', - parameters: { - ...defaultCreate(), - ...parameters, - }, - user: alice, - ...request, - }); - - // 入力が結果として入っていること - assert.deepStrictEqual(clip, { - ...clip, - ...defaultCreate(), - ...parameters, - }); - return clip; - }; - - const createMany = async (parameters: Partial, count = 10, user = alice): Promise => { - return await Promise.all([...Array(count)].map((_, i) => create({ - name: `test${i}`, - ...parameters, - }, { user }))); - }; - - type UpdateParam = JTDDataType; - const update = async (parameters: Partial, request: Partial = {}): Promise => { - const clip = await successfulApiCall({ - endpoint: '/clips/update', - parameters: { - name: 'updated', - ...parameters, - }, - user: alice, - ...request, - }); - - // 入力が結果として入っていること。clipIdはidになるので消しておく - delete (parameters as { clipId?: string }).clipId; - assert.deepStrictEqual(clip, { - ...clip, - ...parameters, - }); - return clip; - }; - - type DeleteParam = JTDDataType; - const deleteClip = async (parameters: DeleteParam, request: Partial = {}): Promise => { - return await successfulApiCall({ - endpoint: '/clips/delete', - parameters, - user: alice, - ...request, - }, { - status: 204, - }); - }; - - type ShowParam = JTDDataType; - const show = async (parameters: ShowParam, request: Partial = {}): Promise => { - return await successfulApiCall({ - endpoint: '/clips/show', - parameters, - user: alice, - ...request, - }); - }; - - const list = async (request: Partial): Promise => { - return successfulApiCall({ - endpoint: '/clips/list', - parameters: {}, - user: alice, - ...request, - }); - }; - - const usersClips = async (request: Partial): Promise => { - return await successfulApiCall({ - endpoint: '/users/clips', - parameters: {}, - user: alice, - ...request, - }); - }; - - beforeAll(async () => { - app = await startServer(); - alice = await signup({ username: 'alice' }); - bob = await signup({ username: 'bob' }); - - // FIXME: misskey-jsのNoteはoutdatedなので直接変換できない - aliceNote = await post(alice, { text: 'test' }) as any; - aliceHomeNote = await post(alice, { text: 'home only', visibility: 'home' }) as any; - aliceFollowersNote = await post(alice, { text: 'followers only', visibility: 'followers' }) as any; - aliceSpecifiedNote = await post(alice, { text: 'specified only', visibility: 'specified' }) as any; - bobNote = await post(bob, { text: 'test' }) as any; - bobHomeNote = await post(bob, { text: 'home only', visibility: 'home' }) as any; - bobFollowersNote = await post(bob, { text: 'followers only', visibility: 'followers' }) as any; - bobSpecifiedNote = await post(bob, { text: 'specified only', visibility: 'specified' }) as any; - }, 1000 * 60 * 2); - - afterAll(async () => { - await app.close(); - }); - - afterEach(async () => { - // テスト間で影響し合わないように毎回全部消す。 - for (const user of [alice, bob]) { - const list = await api('/clips/list', { limit: 11 }, user); - for (const clip of list.body) { - await api('/clips/delete', { clipId: clip.id }, user); - } - } - }); - - test('の作成ができる', async () => { - const res = await create(); - // ISO 8601で日付が返ってくること - assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString()); - assert.strictEqual(res.lastClippedAt, null); - assert.strictEqual(res.name, 'test'); - assert.strictEqual(res.description, null); - assert.strictEqual(res.isPublic, false); - assert.strictEqual(res.favoritedCount, 0); - assert.strictEqual(res.isFavorited, false); - }); - - test('の作成はポリシーで定められた数以上はできない。', async () => { - // ポリシー + 1まで作れるという所がミソ - const clipLimit = DEFAULT_POLICIES.clipLimit + 1; - for (let i = 0; i < clipLimit; i++) { - await create(); - } - - await failedApiCall({ - endpoint: '/clips/create', - parameters: defaultCreate(), - user: alice, - }, { - status: 400, - code: 'TOO_MANY_CLIPS', - id: '920f7c2d-6208-4b76-8082-e632020f5883', - }); - }); - - const createClipAllowedPattern = [ - { label: 'nameが最大長', parameters: { name: 'x'.repeat(100) } }, - { label: 'private', parameters: { isPublic: false } }, - { label: 'public', parameters: { isPublic: true } }, - { label: 'descriptionがnull', parameters: { description: null } }, - { label: 'descriptionが最大長', parameters: { description: 'a'.repeat(2048) } }, - ]; - test.each(createClipAllowedPattern)('の作成は$labelでもできる', async ({ parameters }) => await create(parameters)); - - const createClipDenyPattern = [ - { label: 'nameがnull', parameters: { name: null } }, - { label: 'nameが最大長+1', parameters: { name: 'x'.repeat(101) } }, - { label: 'isPublicがboolじゃない', parameters: { isPublic: 'true' } }, - { label: 'descriptionがゼロ長', parameters: { description: '' } }, - { label: 'descriptionが最大長+1', parameters: { description: 'a'.repeat(2049) } }, - ]; - test.each(createClipDenyPattern)('の作成は$labelならできない', async ({ parameters }) => failedApiCall({ - endpoint: '/clips/create', - parameters: { - ...defaultCreate(), - ...parameters, - }, - user: alice, - }, { - status: 400, - code: 'INVALID_PARAM', - id: '3d81ceae-475f-4600-b2a8-2bc116157532', - })); - - test('の更新ができる', async () => { - const res = await update({ - clipId: (await create()).id, - name: 'updated', - description: 'new description', - isPublic: true, - }); - - // ISO 8601で日付が返ってくること - assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString()); - assert.strictEqual(res.lastClippedAt, null); - assert.strictEqual(res.name, 'updated'); - assert.strictEqual(res.description, 'new description'); - assert.strictEqual(res.isPublic, true); - assert.strictEqual(res.favoritedCount, 0); - assert.strictEqual(res.isFavorited, false); - }); - - test.each(createClipAllowedPattern)('の更新は$labelでもできる', async ({ parameters }) => await update({ - clipId: (await create()).id, - name: 'updated', - ...parameters, - })); - - test.each([ - { label: 'clipIdがnull', parameters: { clipId: null } }, - { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { - code: 'NO_SUCH_CLIP', - id: 'b4d92d70-b216-46fa-9a3f-a8c811699257', - } }, - { label: '他人のクリップ', user: (): User => bob, assertion: { - code: 'NO_SUCH_CLIP', - id: 'b4d92d70-b216-46fa-9a3f-a8c811699257', - } }, - ...createClipDenyPattern as any, - ])('の更新は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ - endpoint: '/clips/update', - parameters: { - clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, - name: 'updated', - ...parameters, - }, - user: alice, - }, { - status: 400, - code: 'INVALID_PARAM', - id: '3d81ceae-475f-4600-b2a8-2bc116157532', - ...assertion, - })); - - test('の削除ができる', async () => { - await deleteClip({ - clipId: (await create()).id, - }); - assert.deepStrictEqual(await list({}), []); - }); - - test.each([ - { label: 'clipIdがnull', parameters: { clipId: null } }, - { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { - code: 'NO_SUCH_CLIP', - id: '70ca08ba-6865-4630-b6fb-8494759aa754', - } }, - { label: '他人のクリップ', user: (): User => bob, assertion: { - code: 'NO_SUCH_CLIP', - id: '70ca08ba-6865-4630-b6fb-8494759aa754', - } }, - ])('の削除は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ - endpoint: '/clips/delete', - parameters: { - clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, - ...parameters, - }, - user: alice, - }, { - status: 400, - code: 'INVALID_PARAM', - id: '3d81ceae-475f-4600-b2a8-2bc116157532', - ...assertion, - })); - - test('のID指定取得ができる', async () => { - const clip = await create(); - const res = await show({ clipId: clip.id }); - assert.deepStrictEqual(res, clip); - }); - - test('のID指定取得は他人のPrivateなクリップは取得できない', async () => { - const clip = await create({ isPublic: false }, { user: bob } ); - failedApiCall({ - endpoint: '/clips/show', - parameters: { clipId: clip.id }, - user: alice, - }, { - status: 400, - code: 'NO_SUCH_CLIP', - id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20', - }); - }); - - test.each([ - { label: 'clipId未指定', parameters: { clipId: undefined } }, - { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: { - code: 'NO_SUCH_CLIP', - id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20', - } }, - ])('のID指定取得は$labelならできない', async ({ parameters, assetion }) => failedApiCall({ - endpoint: '/clips/show', - parameters: { - ...parameters, - }, - user: alice, - }, { - status: 400, - code: 'INVALID_PARAM', - id: '3d81ceae-475f-4600-b2a8-2bc116157532', - ...assetion, - })); - - test('の一覧(clips/list)が取得できる(空)', async () => { - const res = await list({}); - assert.deepStrictEqual(res, []); - }); - - test('の一覧(clips/list)が取得できる(上限いっぱい)', async () => { - const clipLimit = DEFAULT_POLICIES.clipLimit + 1; - const clips = await createMany({}, clipLimit); - const res = await list({ - parameters: { limit: 1 }, // FIXME: 無視されて11全部返ってくる - }); - - // 返ってくる配列には順序保障がないのでidでソートして厳密比較 - assert.deepStrictEqual( - res.sort(compareBy(s => s.id)), - clips.sort(compareBy(s => s.id)), - ); - }); - - test('の一覧が取得できる(空)', async () => { - const res = await usersClips({ - parameters: { - userId: alice.id, - }, - }); - assert.deepStrictEqual(res, []); - }); - - test.each([ - { label: '' }, - { label: '他人アカウントから', user: (): User => bob }, - ])('の一覧が$label取得できる', async () => { - const clips = await createMany({ isPublic: true }); - const res = await usersClips({ - parameters: { - userId: alice.id, - }, - }); - - // 返ってくる配列には順序保障がないのでidでソートして厳密比較 - assert.deepStrictEqual( - res.sort(compareBy(s => s.id)), - clips.sort(compareBy(s => s.id))); - - // 認証状態で見たときだけisFavoritedが入っている - for (const clip of res) { - assert.strictEqual(clip.isFavorited, false); - } - }); - - test.each([ - { label: '未認証', user: (): undefined => undefined }, - { label: '存在しないユーザーのもの', parameters: { userId: 'xxxxxxx' } }, - ])('の一覧は$labelでも取得できる', async ({ parameters, user }) => { - const clips = await createMany({ isPublic: true }); - const res = await usersClips({ - parameters: { - userId: alice.id, - limit: clips.length, - ...parameters, - }, - user: (user ?? ((): User => alice))(), - }); - - // 未認証で見たときはisFavoritedは入らない - for (const clip of res) { - assert.strictEqual('isFavorited' in clip, false); - } - }); - - test('の一覧はPrivateなクリップを含まない(自分のものであっても。)', async () => { - await create({ isPublic: false }); - const aliceClip = await create({ isPublic: true }); - const res = await usersClips({ - parameters: { - userId: alice.id, - limit: 2, - }, - }); - assert.deepStrictEqual(res, [aliceClip]); - }); - - test('の一覧はID指定で範囲選択ができる', async () => { - const clips = await createMany({ isPublic: true }, 7); - clips.sort(compareBy(s => s.id)); - const res = await usersClips({ - parameters: { - userId: alice.id, - sinceId: clips[1].id, - untilId: clips[5].id, - limit: 4, - }, - }); - - // Promise.allで返ってくる配列には順序保障がないのでidでソートして厳密比較 - assert.deepStrictEqual( - res.sort(compareBy(s => s.id)), - [clips[2], clips[3], clips[4]], // sinceIdとuntilId自体は結果に含まれない - clips[1].id + ' ... ' + clips[3].id + ' with ' + clips.map(s => s.id) + ' vs. ' + res.map(s => s.id)); - }); - - test.each([ - { label: 'userId未指定', parameters: { userId: undefined } }, - { label: 'limitゼロ', parameters: { limit: 0 } }, - { label: 'limit最大+1', parameters: { limit: 101 } }, - ])('の一覧は$labelだと取得できない', async ({ parameters }) => failedApiCall({ - endpoint: '/users/clips', - parameters: { - userId: alice.id, - ...parameters, - }, - user: alice, - }, { - status: 400, - code: 'INVALID_PARAM', - id: '3d81ceae-475f-4600-b2a8-2bc116157532', - })); - - test.each([ - { label: '作成', endpoint: '/clips/create' }, - { label: '更新', endpoint: '/clips/update' }, - { label: '削除', endpoint: '/clips/delete' }, - { label: '取得', endpoint: '/clips/list' }, - { label: 'お気に入り設定', endpoint: '/clips/favorite' }, - { label: 'お気に入り解除', endpoint: '/clips/unfavorite' }, - { label: 'お気に入り取得', endpoint: '/clips/my-favorites' }, - { label: 'ノート追加', endpoint: '/clips/add-note' }, - { label: 'ノート削除', endpoint: '/clips/remove-note' }, - ])('の$labelは未認証ではできない', async ({ endpoint }) => await failedApiCall({ - endpoint: endpoint, - parameters: {}, - user: undefined, - }, { - status: 401, - code: 'CREDENTIAL_REQUIRED', - id: '1384574d-a912-4b81-8601-c7b1c4085df1', - })); - - describe('のお気に入り', () => { - let aliceClip: Clip; - - type FavoriteParam = JTDDataType; - const favorite = async (parameters: FavoriteParam, request: Partial = {}): Promise => { - return successfulApiCall({ - endpoint: '/clips/favorite', - parameters, - user: alice, - ...request, - }, { - status: 204, - }); - }; - - type UnfavoriteParam = JTDDataType; - const unfavorite = async (parameters: UnfavoriteParam, request: Partial = {}): Promise => { - return successfulApiCall({ - endpoint: '/clips/unfavorite', - parameters, - user: alice, - ...request, - }, { - status: 204, - }); - }; - - const myFavorites = async (request: Partial = {}): Promise => { - return successfulApiCall({ - endpoint: '/clips/my-favorites', - parameters: {}, - user: alice, - ...request, - }); - }; - - beforeEach(async () => { - aliceClip = await create(); - }); - - test('を設定できる。', async () => { - await favorite({ clipId: aliceClip.id }); - const clip = await show({ clipId: aliceClip.id }); - assert.strictEqual(clip.favoritedCount, 1); - assert.strictEqual(clip.isFavorited, true); - }); - - test('はPublicな他人のクリップに設定できる。', async () => { - const publicClip = await create({ isPublic: true }); - await favorite({ clipId: publicClip.id }, { user: bob }); - const clip = await show({ clipId: publicClip.id }, { user: bob }); - assert.strictEqual(clip.favoritedCount, 1); - assert.strictEqual(clip.isFavorited, true); - - // isFavoritedは見る人によって切り替わる。 - const clip2 = await show({ clipId: publicClip.id }); - assert.strictEqual(clip2.favoritedCount, 1); - assert.strictEqual(clip2.isFavorited, false); - }); - - test('は1つのクリップに対して複数人が設定できる。', async () => { - const publicClip = await create({ isPublic: true }); - await favorite({ clipId: publicClip.id }, { user: bob }); - await favorite({ clipId: publicClip.id }); - const clip = await show({ clipId: publicClip.id }, { user: bob }); - assert.strictEqual(clip.favoritedCount, 2); - assert.strictEqual(clip.isFavorited, true); - - const clip2 = await show({ clipId: publicClip.id }); - assert.strictEqual(clip2.favoritedCount, 2); - assert.strictEqual(clip2.isFavorited, true); - }); - - test('は11を超えて設定できる。', async () => { - const clips = [ - aliceClip, - ...await createMany({}, 10, alice), - ...await createMany({ isPublic: true }, 10, bob), - ]; - for (const clip of clips) { - await favorite({ clipId: clip.id }); - } - - // pagenationはない。全部一気にとれる。 - const favorited = await myFavorites(); - assert.strictEqual(favorited.length, clips.length); - for (const clip of favorited) { - assert.strictEqual(clip.favoritedCount, 1); - assert.strictEqual(clip.isFavorited, true); - } - }); - - test('は同じクリップに対して二回設定できない。', async () => { - await favorite({ clipId: aliceClip.id }); - await failedApiCall({ - endpoint: '/clips/favorite', - parameters: { - clipId: aliceClip.id, - }, - user: alice, - }, { - status: 400, - code: 'ALREADY_FAVORITED', - id: '92658936-c625-4273-8326-2d790129256e', - }); - }); - - test.each([ - { label: 'clipIdがnull', parameters: { clipId: null } }, - { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { - code: 'NO_SUCH_CLIP', - id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5', - } }, - { label: '他人のクリップ', user: (): User => bob, assertion: { - code: 'NO_SUCH_CLIP', - id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5', - } }, - ])('の設定は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ - endpoint: '/clips/favorite', - parameters: { - clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, - ...parameters, - }, - user: alice, - }, { - status: 400, - code: 'INVALID_PARAM', - id: '3d81ceae-475f-4600-b2a8-2bc116157532', - ...assertion, - })); - - test('を設定解除できる。', async () => { - await favorite({ clipId: aliceClip.id }); - await unfavorite({ clipId: aliceClip.id }); - const clip = await show({ clipId: aliceClip.id }); - assert.strictEqual(clip.favoritedCount, 0); - assert.strictEqual(clip.isFavorited, false); - assert.deepStrictEqual(await myFavorites(), []); - }); - - test.each([ - { label: 'clipIdがnull', parameters: { clipId: null } }, - { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { - code: 'NO_SUCH_CLIP', - id: '2603966e-b865-426c-94a7-af4a01241dc1', - } }, - { label: '他人のクリップ', user: (): User => bob, assertion: { - code: 'NOT_FAVORITED', - id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187', - } }, - { label: 'お気に入りしていないクリップ', assertion: { - code: 'NOT_FAVORITED', - id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187', - } }, - ])('の設定解除は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ - endpoint: '/clips/unfavorite', - parameters: { - clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, - ...parameters, - }, - user: alice, - }, { - status: 400, - code: 'INVALID_PARAM', - id: '3d81ceae-475f-4600-b2a8-2bc116157532', - ...assertion, - })); - - test('を取得できる。', async () => { - await favorite({ clipId: aliceClip.id }); - const favorited = await myFavorites(); - assert.deepStrictEqual(favorited, [await show({ clipId: aliceClip.id })]); - }); - - test('を取得したとき他人のお気に入りは含まない。', async () => { - await favorite({ clipId: aliceClip.id }); - const favorited = await myFavorites({ user: bob }); - assert.deepStrictEqual(favorited, []); - }); - }); - - describe('に紐づくノート', () => { - let aliceClip: Clip; - - const sampleNotes = (): Note[] => [ - aliceNote, aliceHomeNote, aliceFollowersNote, aliceSpecifiedNote, - bobNote, bobHomeNote, bobFollowersNote, bobSpecifiedNote, - ]; - - type AddNoteParam = JTDDataType; - const addNote = async (parameters: AddNoteParam, request: Partial = {}): Promise => { - return successfulApiCall({ - endpoint: '/clips/add-note', - parameters, - user: alice, - ...request, - }, { - status: 204, - }); - }; - - type RemoveNoteParam = JTDDataType; - const removeNote = async (parameters: RemoveNoteParam, request: Partial = {}): Promise => { - return successfulApiCall({ - endpoint: '/clips/remove-note', - parameters, - user: alice, - ...request, - }, { - status: 204, - }); - }; - - type NotesParam = JTDDataType; - const notes = async (parameters: Partial, request: Partial = {}): Promise => { - return successfulApiCall({ - endpoint: '/clips/notes', - parameters, - user: alice, - ...request, - }); - }; - - beforeEach(async () => { - aliceClip = await create(); - }); - - test('を追加できる。', async () => { - await addNote({ clipId: aliceClip.id, noteId: aliceNote.id }); - const res = await show({ clipId: aliceClip.id }); - assert.strictEqual(res.lastClippedAt, res.lastClippedAt ? new Date(res.lastClippedAt).toISOString() : null); - assert.deepStrictEqual((await notes({ clipId: aliceClip.id })).map(x => x.id), [aliceNote.id]); - - // 他人の非公開ノートも突っ込める - await addNote({ clipId: aliceClip.id, noteId: bobHomeNote.id }); - await addNote({ clipId: aliceClip.id, noteId: bobFollowersNote.id }); - await addNote({ clipId: aliceClip.id, noteId: bobSpecifiedNote.id }); - }); - - test('として同じノートを二回紐づけることはできない', async () => { - await addNote({ clipId: aliceClip.id, noteId: aliceNote.id }); - await failedApiCall({ - endpoint: '/clips/add-note', - parameters: { - clipId: aliceClip.id, - noteId: aliceNote.id, - }, - user: alice, - }, { - status: 400, - code: 'ALREADY_CLIPPED', - id: '734806c4-542c-463a-9311-15c512803965', - }); - }); - - // TODO: 17000msくらいかかる... - test('をポリシーで定められた上限いっぱい(200)を超えて追加はできない。', async () => { - const noteLimit = DEFAULT_POLICIES.noteEachClipsLimit + 1; - const noteList = await Promise.all([...Array(noteLimit)].map((_, i) => post(alice, { - text: `test ${i}`, - }) as unknown)) as Note[]; - await Promise.all(noteList.map(s => addNote({ clipId: aliceClip.id, noteId: s.id }))); - - await failedApiCall({ - endpoint: '/clips/add-note', - parameters: { - clipId: aliceClip.id, - noteId: aliceNote.id, - }, - user: alice, - }, { - status: 400, - code: 'TOO_MANY_CLIP_NOTES', - id: 'f0dba960-ff73-4615-8df4-d6ac5d9dc118', - }); - }); - - test('は他人のクリップへ追加できない。', async () => await failedApiCall({ - endpoint: '/clips/add-note', - parameters: { - clipId: aliceClip.id, - noteId: aliceNote.id, - }, - user: bob, - }, { - status: 400, - code: 'NO_SUCH_CLIP', - id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf', - })); - - test.each([ - { label: 'clipId未指定', parameters: { clipId: undefined } }, - { label: 'noteId未指定', parameters: { noteId: undefined } }, - { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: { - code: 'NO_SUCH_CLIP', - id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf', - } }, - { label: '存在しないノート', parameters: { noteId: 'xxxxxx' }, assetion: { - code: 'NO_SUCH_NOTE', - id: 'fc8c0b49-c7a3-4664-a0a6-b418d386bb8b', - } }, - { label: '他人のクリップ', user: (): object => bob, assetion: { - code: 'NO_SUCH_CLIP', - id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf', - } }, - ])('の追加は$labelだとできない', async ({ parameters, user, assetion }) => failedApiCall({ - endpoint: '/clips/add-note', - parameters: { - clipId: aliceClip.id, - noteId: aliceNote.id, - ...parameters, - }, - user: (user ?? ((): User => alice))(), - }, { - status: 400, - code: 'INVALID_PARAM', - id: '3d81ceae-475f-4600-b2a8-2bc116157532', - ...assetion, - })); - - test('を削除できる。', async () => { - await addNote({ clipId: aliceClip.id, noteId: aliceNote.id }); - await removeNote({ clipId: aliceClip.id, noteId: aliceNote.id }); - assert.deepStrictEqual(await notes({ clipId: aliceClip.id }), []); - }); - - test.each([ - { label: 'clipId未指定', parameters: { clipId: undefined } }, - { label: 'noteId未指定', parameters: { noteId: undefined } }, - { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: { - code: 'NO_SUCH_CLIP', - id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', // add-noteと異なる - } }, - { label: '存在しないノート', parameters: { noteId: 'xxxxxx' }, assetion: { - code: 'NO_SUCH_NOTE', - id: 'aff017de-190e-434b-893e-33a9ff5049d8', // add-noteと異なる - } }, - { label: '他人のクリップ', user: (): object => bob, assetion: { - code: 'NO_SUCH_CLIP', - id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', // add-noteと異なる - } }, - ])('の削除は$labelだとできない', async ({ parameters, user, assetion }) => failedApiCall({ - endpoint: '/clips/remove-note', - parameters: { - clipId: aliceClip.id, - noteId: aliceNote.id, - ...parameters, - }, - user: (user ?? ((): User => alice))(), - }, { - status: 400, - code: 'INVALID_PARAM', - id: '3d81ceae-475f-4600-b2a8-2bc116157532', - ...assetion, - })); - - test('を取得できる。', async () => { - const noteList = sampleNotes(); - for (const note of noteList) { - await addNote({ clipId: aliceClip.id, noteId: note.id }); - } - - const res = await notes({ clipId: aliceClip.id }); - - // 自分のノートは非公開でも入れられるし、見える - // 他人の非公開ノートは入れられるけど、除外される - const expects = [ - aliceNote, aliceHomeNote, aliceFollowersNote, aliceSpecifiedNote, - bobNote, bobHomeNote, - ]; - assert.deepStrictEqual( - res.sort(compareBy(s => s.id)).map(x => x.id), - expects.sort(compareBy(s => s.id)).map(x => x.id)); - }); - - test('を始端IDとlimitで取得できる。', async () => { - const noteList = sampleNotes(); - noteList.sort(compareBy(s => s.id)); - for (const note of noteList) { - await addNote({ clipId: aliceClip.id, noteId: note.id }); - } - - const res = await notes({ - clipId: aliceClip.id, - sinceId: noteList[2].id, - limit: 3, - }); - - // Promise.allで返ってくる配列はID順で並んでないのでソートして厳密比較 - const expects = [noteList[3], noteList[4], noteList[5]]; - assert.deepStrictEqual( - res.sort(compareBy(s => s.id)).map(x => x.id), - expects.sort(compareBy(s => s.id)).map(x => x.id)); - }); - - test('をID範囲指定で取得できる。', async () => { - const noteList = sampleNotes(); - noteList.sort(compareBy(s => s.id)); - for (const note of noteList) { - await addNote({ clipId: aliceClip.id, noteId: note.id }); - } - - const res = await notes({ - clipId: aliceClip.id, - sinceId: noteList[1].id, - untilId: noteList[4].id, - }); - - // Promise.allで返ってくる配列はID順で並んでないのでソートして厳密比較 - const expects = [noteList[2], noteList[3]]; - assert.deepStrictEqual( - res.sort(compareBy(s => s.id)).map(x => x.id), - expects.sort(compareBy(s => s.id)).map(x => x.id)); - }); - - test.todo('Remoteのノートもクリップできる。どうテストしよう?'); - - test('は他人のPublicなクリップからも取得できる。', async () => { - const bobClip = await create({ isPublic: true }, { user: bob } ); - await addNote({ clipId: bobClip.id, noteId: aliceNote.id }, { user: bob }); - const res = await notes({ clipId: bobClip.id }); - assert.deepStrictEqual(res.map(x => x.id), [aliceNote.id]); - }); - - test('はPublicなクリップなら認証なしでも取得できる。(非公開ノートはhideされて返ってくる)', async () => { - const publicClip = await create({ isPublic: true }); - await addNote({ clipId: publicClip.id, noteId: aliceNote.id }); - await addNote({ clipId: publicClip.id, noteId: aliceHomeNote.id }); - await addNote({ clipId: publicClip.id, noteId: aliceFollowersNote.id }); - await addNote({ clipId: publicClip.id, noteId: aliceSpecifiedNote.id }); - - const res = await notes({ clipId: publicClip.id }, { user: undefined }); - const expects = [ - aliceNote, aliceHomeNote, - // 認証なしだと非公開ノートは結果には含むけどhideされる。 - hiddenNote(aliceFollowersNote), hiddenNote(aliceSpecifiedNote), - ]; - assert.deepStrictEqual( - res.sort(compareBy(s => s.id)).map(x => x.id), - expects.sort(compareBy(s => s.id)).map(x => x.id)); - }); - - test.todo('ブロック、ミュートされたユーザーからの設定&取得etc.'); - - test.each([ - { label: 'clipId未指定', parameters: { clipId: undefined } }, - { label: 'limitゼロ', parameters: { limit: 0 } }, - { label: 'limit最大+1', parameters: { limit: 101 } }, - { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { - code: 'NO_SUCH_CLIP', - id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00', - } }, - { label: '他人のPrivateなクリップから', user: (): object => bob, assertion: { - code: 'NO_SUCH_CLIP', - id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00', - } }, - { label: '未認証でPrivateなクリップから', user: (): undefined => undefined, assertion: { - code: 'NO_SUCH_CLIP', - id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00', - } }, - ])('は$labelだと取得できない', async ({ parameters, user, assertion }) => failedApiCall({ - endpoint: '/clips/notes', - parameters: { - clipId: aliceClip.id, - ...parameters, - }, - user: (user ?? ((): User => alice))(), - }, { - status: 400, - code: 'INVALID_PARAM', - id: '3d81ceae-475f-4600-b2a8-2bc116157532', - ...assertion, - })); - }); -}); diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts deleted file mode 100644 index 2ef3434bca..0000000000 --- a/packages/backend/test/e2e/endpoints.ts +++ /dev/null @@ -1,1054 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -// node-fetch only supports it's own Blob yet -// https://github.com/node-fetch/node-fetch/pull/1664 -import { Blob } from 'node-fetch'; -import { MiUser } from '@/models/_.js'; -import { startServer, signup, post, api, uploadFile, simpleGet, initTestDb } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; -import type * as misskey from 'misskey-js'; - -describe('Endpoints', () => { - let app: INestApplicationContext; - - let alice: misskey.entities.MeSignup; - let bob: misskey.entities.MeSignup; - let carol: misskey.entities.MeSignup; - let dave: misskey.entities.MeSignup; - - beforeAll(async () => { - app = await startServer(); - alice = await signup({ username: 'alice' }); - bob = await signup({ username: 'bob' }); - carol = await signup({ username: 'carol' }); - dave = await signup({ username: 'dave' }); - }, 1000 * 60 * 2); - - afterAll(async () => { - await app.close(); - }); - - describe('signup', () => { - test('不正なユーザー名でアカウントが作成できない', async () => { - const res = await api('signup', { - username: 'test.', - password: 'test', - }); - assert.strictEqual(res.status, 400); - }); - - test('空のパスワードでアカウントが作成できない', async () => { - const res = await api('signup', { - username: 'test', - password: '', - }); - assert.strictEqual(res.status, 400); - }); - - test('正しくアカウントが作成できる', async () => { - const me = { - username: 'test1', - password: 'test1', - }; - - const res = await api('signup', me); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.username, me.username); - }); - - test('同じユーザー名のアカウントは作成できない', async () => { - const res = await api('signup', { - username: 'test1', - password: 'test1', - }); - - assert.strictEqual(res.status, 400); - }); - }); - - describe('signin', () => { - test('間違ったパスワードでサインインできない', async () => { - const res = await api('signin', { - username: 'test1', - password: 'bar', - }); - - assert.strictEqual(res.status, 403); - }); - - test('クエリをインジェクションできない', async () => { - const res = await api('signin', { - username: 'test1', - password: { - $gt: '', - }, - }); - - assert.strictEqual(res.status, 400); - }); - - test('正しい情報でサインインできる', async () => { - const res = await api('signin', { - username: 'test1', - password: 'test1', - }); - - assert.strictEqual(res.status, 200); - }); - }); - - describe('i/update', () => { - test('アカウント設定を更新できる', async () => { - const myName = '大室櫻子'; - const myLocation = '七森中'; - const myBirthday = '2000-09-07'; - - const res = await api('/i/update', { - name: myName, - location: myLocation, - birthday: myBirthday, - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.name, myName); - assert.strictEqual(res.body.location, myLocation); - assert.strictEqual(res.body.birthday, myBirthday); - }); - - test('名前を空白にできる', async () => { - const res = await api('/i/update', { - name: ' ', - }, alice); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.body.name, ' '); - }); - - test('誕生日の設定を削除できる', async () => { - await api('/i/update', { - birthday: '2000-09-07', - }, alice); - - const res = await api('/i/update', { - birthday: null, - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.birthday, null); - }); - - test('不正な誕生日の形式で怒られる', async () => { - const res = await api('/i/update', { - birthday: '2000/09/07', - }, alice); - assert.strictEqual(res.status, 400); - }); - }); - - describe('users/show', () => { - test('ユーザーが取得できる', async () => { - const res = await api('/users/show', { - userId: alice.id, - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.id, alice.id); - }); - - test('ユーザーが存在しなかったら怒る', async () => { - const res = await api('/users/show', { - userId: '000000000000000000000000', - }); - assert.strictEqual(res.status, 404); - }); - - test('間違ったIDで怒られる', async () => { - const res = await api('/users/show', { - userId: 'kyoppie', - }); - assert.strictEqual(res.status, 404); - }); - }); - - describe('notes/show', () => { - test('投稿が取得できる', async () => { - const myPost = await post(alice, { - text: 'test', - }); - - const res = await api('/notes/show', { - noteId: myPost.id, - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.id, myPost.id); - assert.strictEqual(res.body.text, myPost.text); - }); - - test('投稿が存在しなかったら怒る', async () => { - const res = await api('/notes/show', { - noteId: '000000000000000000000000', - }); - assert.strictEqual(res.status, 400); - }); - - test('間違ったIDで怒られる', async () => { - const res = await api('/notes/show', { - noteId: 'kyoppie', - }); - assert.strictEqual(res.status, 400); - }); - }); - - describe('notes/reactions/create', () => { - test('リアクションできる', async () => { - const bobPost = await post(bob, { text: 'hi' }); - - const res = await api('/notes/reactions/create', { - noteId: bobPost.id, - reaction: '🚀', - }, alice); - - assert.strictEqual(res.status, 204); - - const resNote = await api('/notes/show', { - noteId: bobPost.id, - }, alice); - - assert.strictEqual(resNote.status, 200); - assert.strictEqual(resNote.body.reactions['🚀'], 1); - }); - - test('自分の投稿にもリアクションできる', async () => { - const myPost = await post(alice, { text: 'hi' }); - - const res = await api('/notes/reactions/create', { - noteId: myPost.id, - reaction: '🚀', - }, alice); - - assert.strictEqual(res.status, 204); - }); - - test('二重にリアクションすると上書きされる', async () => { - const bobPost = await post(bob, { text: 'hi' }); - - await api('/notes/reactions/create', { - noteId: bobPost.id, - reaction: '🥰', - }, alice); - - const res = await api('/notes/reactions/create', { - noteId: bobPost.id, - reaction: '🚀', - }, alice); - - assert.strictEqual(res.status, 204); - - const resNote = await api('/notes/show', { - noteId: bobPost.id, - }, alice); - - assert.strictEqual(resNote.status, 200); - assert.deepStrictEqual(resNote.body.reactions, { '🚀': 1 }); - }); - - test('存在しない投稿にはリアクションできない', async () => { - const res = await api('/notes/reactions/create', { - noteId: '000000000000000000000000', - reaction: '🚀', - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('空のパラメータで怒られる', async () => { - const res = await api('/notes/reactions/create', {}, alice); - - assert.strictEqual(res.status, 400); - }); - - test('間違ったIDで怒られる', async () => { - const res = await api('/notes/reactions/create', { - noteId: 'kyoppie', - reaction: '🚀', - }, alice); - - assert.strictEqual(res.status, 400); - }); - }); - - describe('following/create', () => { - test('フォローできる', async () => { - const res = await api('/following/create', { - userId: alice.id, - }, bob); - - assert.strictEqual(res.status, 200); - - const connection = await initTestDb(true); - const Users = connection.getRepository(MiUser); - const newBob = await Users.findOneByOrFail({ id: bob.id }); - assert.strictEqual(newBob.followersCount, 0); - assert.strictEqual(newBob.followingCount, 1); - const newAlice = await Users.findOneByOrFail({ id: alice.id }); - assert.strictEqual(newAlice.followersCount, 1); - assert.strictEqual(newAlice.followingCount, 0); - connection.destroy(); - }); - - test('既にフォローしている場合は怒る', async () => { - const res = await api('/following/create', { - userId: alice.id, - }, bob); - - assert.strictEqual(res.status, 400); - }); - - test('存在しないユーザーはフォローできない', async () => { - const res = await api('/following/create', { - userId: '000000000000000000000000', - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('自分自身はフォローできない', async () => { - const res = await api('/following/create', { - userId: alice.id, - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('空のパラメータで怒られる', async () => { - const res = await api('/following/create', {}, alice); - - assert.strictEqual(res.status, 400); - }); - - test('間違ったIDで怒られる', async () => { - const res = await api('/following/create', { - userId: 'foo', - }, alice); - - assert.strictEqual(res.status, 400); - }); - }); - - describe('following/delete', () => { - test('フォロー解除できる', async () => { - await api('/following/create', { - userId: alice.id, - }, bob); - - const res = await api('/following/delete', { - userId: alice.id, - }, bob); - - assert.strictEqual(res.status, 200); - - const connection = await initTestDb(true); - const Users = connection.getRepository(MiUser); - const newBob = await Users.findOneByOrFail({ id: bob.id }); - assert.strictEqual(newBob.followersCount, 0); - assert.strictEqual(newBob.followingCount, 0); - const newAlice = await Users.findOneByOrFail({ id: alice.id }); - assert.strictEqual(newAlice.followersCount, 0); - assert.strictEqual(newAlice.followingCount, 0); - connection.destroy(); - }); - - test('フォローしていない場合は怒る', async () => { - const res = await api('/following/delete', { - userId: alice.id, - }, bob); - - assert.strictEqual(res.status, 400); - }); - - test('存在しないユーザーはフォロー解除できない', async () => { - const res = await api('/following/delete', { - userId: '000000000000000000000000', - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('自分自身はフォロー解除できない', async () => { - const res = await api('/following/delete', { - userId: alice.id, - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('空のパラメータで怒られる', async () => { - const res = await api('/following/delete', {}, alice); - - assert.strictEqual(res.status, 400); - }); - - test('間違ったIDで怒られる', async () => { - const res = await api('/following/delete', { - userId: 'kyoppie', - }, alice); - - assert.strictEqual(res.status, 400); - }); - }); - - describe('channels/search', () => { - test('空白検索で一覧を取得できる', async () => { - await api('/channels/create', { - name: 'aaa', - description: 'bbb', - }, bob); - await api('/channels/create', { - name: 'ccc1', - description: 'ddd1', - }, bob); - await api('/channels/create', { - name: 'ccc2', - description: 'ddd2', - }, bob); - - const res = await api('/channels/search', { - query: '', - }, bob); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); - assert.strictEqual(res.body.length, 3); - }); - test('名前のみの検索で名前を検索できる', async () => { - const res = await api('/channels/search', { - query: 'aaa', - type: 'nameOnly', - }, bob); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); - assert.strictEqual(res.body.length, 1); - assert.strictEqual(res.body[0].name, 'aaa'); - }); - test('名前のみの検索で名前を複数検索できる', async () => { - const res = await api('/channels/search', { - query: 'ccc', - type: 'nameOnly', - }, bob); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); - assert.strictEqual(res.body.length, 2); - }); - test('名前のみの検索で説明は検索できない', async () => { - const res = await api('/channels/search', { - query: 'bbb', - type: 'nameOnly', - }, bob); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); - assert.strictEqual(res.body.length, 0); - }); - test('名前と説明の検索で名前を検索できる', async () => { - const res = await api('/channels/search', { - query: 'ccc1', - }, bob); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); - assert.strictEqual(res.body.length, 1); - assert.strictEqual(res.body[0].name, 'ccc1'); - }); - test('名前と説明での検索で説明を検索できる', async () => { - const res = await api('/channels/search', { - query: 'ddd1', - }, bob); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); - assert.strictEqual(res.body.length, 1); - assert.strictEqual(res.body[0].name, 'ccc1'); - }); - test('名前と説明の検索で名前を複数検索できる', async () => { - const res = await api('/channels/search', { - query: 'ccc', - }, bob); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); - assert.strictEqual(res.body.length, 2); - }); - test('名前と説明での検索で説明を複数検索できる', async () => { - const res = await api('/channels/search', { - query: 'ddd', - }, bob); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); - assert.strictEqual(res.body.length, 2); - }); - }); - - describe('drive', () => { - test('ドライブ情報を取得できる', async () => { - await uploadFile(alice, { - blob: new Blob([new Uint8Array(256)]), - }); - await uploadFile(alice, { - blob: new Blob([new Uint8Array(512)]), - }); - await uploadFile(alice, { - blob: new Blob([new Uint8Array(1024)]), - }); - const res = await api('/drive', {}, alice); - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - expect(res.body).toHaveProperty('usage', 1792); - }); - }); - - describe('drive/files/create', () => { - test('ファイルを作成できる', async () => { - const res = await uploadFile(alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.name, 'Lenna.jpg'); - }); - - test('ファイルに名前を付けられる', async () => { - const res = await uploadFile(alice, { name: 'Belmond.jpg' }); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.name, 'Belmond.jpg'); - }); - - test('ファイルに名前を付けられるが、拡張子は正しいものになる', async () => { - const res = await uploadFile(alice, { name: 'Belmond.png' }); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.name, 'Belmond.png.jpg'); - }); - - test('ファイル無しで怒られる', async () => { - const res = await api('/drive/files/create', {}, alice); - - assert.strictEqual(res.status, 400); - }); - - test('SVGファイルを作成できる', async () => { - const res = await uploadFile(alice, { path: 'image.svg' }); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.name, 'image.svg'); - assert.strictEqual(res.body.type, 'image/svg+xml'); - }); - - for (const type of ['webp', 'avif']) { - const mediaType = `image/${type}`; - - const getWebpublicType = async (user: any, fileId: string): Promise => { - // drive/files/create does not expose webpublicType directly, so get it by posting it - const res = await post(user, { - text: mediaType, - fileIds: [fileId], - }); - const apRes = await simpleGet(`notes/${res.id}`, 'application/activity+json'); - assert.strictEqual(apRes.status, 200); - assert.ok(Array.isArray(apRes.body.attachment)); - return apRes.body.attachment[0].mediaType; - }; - - test(`透明な${type}ファイルを作成できる`, async () => { - const path = `with-alpha.${type}`; - const res = await uploadFile(alice, { path }); - - assert.strictEqual(res.status, 200); - assert.strictEqual(res.body.name, path); - assert.strictEqual(res.body.type, mediaType); - - const webpublicType = await getWebpublicType(alice, res.body.id); - assert.strictEqual(webpublicType, 'image/webp'); - }); - - test(`透明じゃない${type}ファイルを作成できる`, async () => { - const path = `without-alpha.${type}`; - const res = await uploadFile(alice, { path }); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.body.name, path); - assert.strictEqual(res.body.type, mediaType); - - const webpublicType = await getWebpublicType(alice, res.body.id); - assert.strictEqual(webpublicType, 'image/webp'); - }); - } - }); - - describe('drive/files/update', () => { - test('名前を更新できる', async () => { - const file = (await uploadFile(alice)).body; - const newName = 'いちごパスタ.png'; - - const res = await api('/drive/files/update', { - fileId: file.id, - name: newName, - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.name, newName); - }); - - test('他人のファイルは更新できない', async () => { - const file = (await uploadFile(alice)).body; - - const res = await api('/drive/files/update', { - fileId: file.id, - name: 'いちごパスタ.png', - }, bob); - - assert.strictEqual(res.status, 400); - }); - - test('親フォルダを更新できる', async () => { - const file = (await uploadFile(alice)).body; - const folder = (await api('/drive/folders/create', { - name: 'test', - }, alice)).body; - - const res = await api('/drive/files/update', { - fileId: file.id, - folderId: folder.id, - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.folderId, folder.id); - }); - - test('親フォルダを無しにできる', async () => { - const file = (await uploadFile(alice)).body; - - const folder = (await api('/drive/folders/create', { - name: 'test', - }, alice)).body; - - await api('/drive/files/update', { - fileId: file.id, - folderId: folder.id, - }, alice); - - const res = await api('/drive/files/update', { - fileId: file.id, - folderId: null, - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.folderId, null); - }); - - test('他人のフォルダには入れられない', async () => { - const file = (await uploadFile(alice)).body; - const folder = (await api('/drive/folders/create', { - name: 'test', - }, bob)).body; - - const res = await api('/drive/files/update', { - fileId: file.id, - folderId: folder.id, - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('存在しないフォルダで怒られる', async () => { - const file = (await uploadFile(alice)).body; - - const res = await api('/drive/files/update', { - fileId: file.id, - folderId: '000000000000000000000000', - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('不正なフォルダIDで怒られる', async () => { - const file = (await uploadFile(alice)).body; - - const res = await api('/drive/files/update', { - fileId: file.id, - folderId: 'foo', - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('ファイルが存在しなかったら怒る', async () => { - const res = await api('/drive/files/update', { - fileId: '000000000000000000000000', - name: 'いちごパスタ.png', - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('間違ったIDで怒られる', async () => { - const res = await api('/drive/files/update', { - fileId: 'kyoppie', - name: 'いちごパスタ.png', - }, alice); - - assert.strictEqual(res.status, 400); - }); - }); - - describe('drive/folders/create', () => { - test('フォルダを作成できる', async () => { - const res = await api('/drive/folders/create', { - name: 'test', - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.name, 'test'); - }); - }); - - describe('drive/folders/update', () => { - test('名前を更新できる', async () => { - const folder = (await api('/drive/folders/create', { - name: 'test', - }, alice)).body; - - const res = await api('/drive/folders/update', { - folderId: folder.id, - name: 'new name', - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.name, 'new name'); - }); - - test('他人のフォルダを更新できない', async () => { - const folder = (await api('/drive/folders/create', { - name: 'test', - }, bob)).body; - - const res = await api('/drive/folders/update', { - folderId: folder.id, - name: 'new name', - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('親フォルダを更新できる', async () => { - const folder = (await api('/drive/folders/create', { - name: 'test', - }, alice)).body; - const parentFolder = (await api('/drive/folders/create', { - name: 'parent', - }, alice)).body; - - const res = await api('/drive/folders/update', { - folderId: folder.id, - parentId: parentFolder.id, - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.parentId, parentFolder.id); - }); - - test('親フォルダを無しに更新できる', async () => { - const folder = (await api('/drive/folders/create', { - name: 'test', - }, alice)).body; - const parentFolder = (await api('/drive/folders/create', { - name: 'parent', - }, alice)).body; - await api('/drive/folders/update', { - folderId: folder.id, - parentId: parentFolder.id, - }, alice); - - const res = await api('/drive/folders/update', { - folderId: folder.id, - parentId: null, - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.parentId, null); - }); - - test('他人のフォルダを親フォルダに設定できない', async () => { - const folder = (await api('/drive/folders/create', { - name: 'test', - }, alice)).body; - const parentFolder = (await api('/drive/folders/create', { - name: 'parent', - }, bob)).body; - - const res = await api('/drive/folders/update', { - folderId: folder.id, - parentId: parentFolder.id, - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('フォルダが循環するような構造にできない', async () => { - const folder = (await api('/drive/folders/create', { - name: 'test', - }, alice)).body; - const parentFolder = (await api('/drive/folders/create', { - name: 'parent', - }, alice)).body; - await api('/drive/folders/update', { - folderId: parentFolder.id, - parentId: folder.id, - }, alice); - - const res = await api('/drive/folders/update', { - folderId: folder.id, - parentId: parentFolder.id, - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('フォルダが循環するような構造にできない(再帰的)', async () => { - const folderA = (await api('/drive/folders/create', { - name: 'test', - }, alice)).body; - const folderB = (await api('/drive/folders/create', { - name: 'test', - }, alice)).body; - const folderC = (await api('/drive/folders/create', { - name: 'test', - }, alice)).body; - await api('/drive/folders/update', { - folderId: folderB.id, - parentId: folderA.id, - }, alice); - await api('/drive/folders/update', { - folderId: folderC.id, - parentId: folderB.id, - }, alice); - - const res = await api('/drive/folders/update', { - folderId: folderA.id, - parentId: folderC.id, - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('フォルダが循環するような構造にできない(自身)', async () => { - const folderA = (await api('/drive/folders/create', { - name: 'test', - }, alice)).body; - - const res = await api('/drive/folders/update', { - folderId: folderA.id, - parentId: folderA.id, - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('存在しない親フォルダを設定できない', async () => { - const folder = (await api('/drive/folders/create', { - name: 'test', - }, alice)).body; - - const res = await api('/drive/folders/update', { - folderId: folder.id, - parentId: '000000000000000000000000', - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('不正な親フォルダIDで怒られる', async () => { - const folder = (await api('/drive/folders/create', { - name: 'test', - }, alice)).body; - - const res = await api('/drive/folders/update', { - folderId: folder.id, - parentId: 'foo', - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('存在しないフォルダを更新できない', async () => { - const res = await api('/drive/folders/update', { - folderId: '000000000000000000000000', - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('不正なフォルダIDで怒られる', async () => { - const res = await api('/drive/folders/update', { - folderId: 'foo', - }, alice); - - assert.strictEqual(res.status, 400); - }); - }); - - describe('notes/replies', () => { - test('自分に閲覧権限のない投稿は含まれない', async () => { - const alicePost = await post(alice, { - text: 'foo', - }); - - await post(bob, { - replyId: alicePost.id, - text: 'bar', - visibility: 'specified', - visibleUserIds: [alice.id], - }); - - const res = await api('/notes/replies', { - noteId: alicePost.id, - }, carol); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.length, 0); - }); - }); - - describe('notes/timeline', () => { - test('フォロワー限定投稿が含まれる', async () => { - await api('/following/create', { - userId: carol.id, - }, dave); - - const carolPost = await post(carol, { - text: 'foo', - visibility: 'followers', - }); - - const res = await api('/notes/timeline', {}, dave); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.length, 1); - assert.strictEqual(res.body[0].id, carolPost.id); - }); - }); - - describe('URL preview', () => { - test('Error from summaly becomes HTTP 422', async () => { - const res = await simpleGet('/url?url=https://e:xample.com'); - assert.strictEqual(res.status, 422); - assert.strictEqual(res.body.error.code, 'URL_PREVIEW_FAILED'); - }); - }); - - describe('パーソナルメモ機能のテスト', () => { - test('他者に関するメモを更新できる', async () => { - const memo = '10月まで低浮上とのこと。'; - - const res1 = await api('/users/update-memo', { - memo, - userId: bob.id, - }, alice); - - const res2 = await api('/users/show', { - userId: bob.id, - }, alice); - assert.strictEqual(res1.status, 204); - assert.strictEqual(res2.body?.memo, memo); - }); - - test('自分に関するメモを更新できる', async () => { - const memo = 'チケットを月末までに買う。'; - - const res1 = await api('/users/update-memo', { - memo, - userId: alice.id, - }, alice); - - const res2 = await api('/users/show', { - userId: alice.id, - }, alice); - assert.strictEqual(res1.status, 204); - assert.strictEqual(res2.body?.memo, memo); - }); - - test('メモを削除できる', async () => { - const memo = '10月まで低浮上とのこと。'; - - await api('/users/update-memo', { - memo, - userId: bob.id, - }, alice); - - await api('/users/update-memo', { - memo: '', - userId: bob.id, - }, alice); - - const res = await api('/users/show', { - userId: bob.id, - }, alice); - - // memoには常に文字列かnullが入っている(5cac151) - assert.strictEqual(res.body.memo, null); - }); - - test('メモは個人ごとに独立して保存される', async () => { - const memoAliceToBob = '10月まで低浮上とのこと。'; - const memoCarolToBob = '例の件について今度問いただす。'; - - await Promise.all([ - api('/users/update-memo', { - memo: memoAliceToBob, - userId: bob.id, - }, alice), - api('/users/update-memo', { - memo: memoCarolToBob, - userId: bob.id, - }, carol), - ]); - - const [resAlice, resCarol] = await Promise.all([ - api('/users/show', { - userId: bob.id, - }, alice), - api('/users/show', { - userId: bob.id, - }, carol), - ]); - - assert.strictEqual(resAlice.body.memo, memoAliceToBob); - assert.strictEqual(resCarol.body.memo, memoCarolToBob); - }); - }); -}); diff --git a/packages/backend/test/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts deleted file mode 100644 index 251d662760..0000000000 --- a/packages/backend/test/e2e/fetch-resource.ts +++ /dev/null @@ -1,482 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { startServer, channel, clip, cookie, galleryPost, signup, page, play, post, simpleGet, uploadFile } from '../utils.js'; -import type { SimpleGetResponse } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; -import type * as misskey from 'misskey-js'; - -// Request Accept -const ONLY_AP = 'application/activity+json'; -const PREFER_AP = 'application/activity+json, */*'; -const PREFER_HTML = 'text/html, */*'; -const UNSPECIFIED = '*/*'; - -// Response Content-Type -const AP = 'application/activity+json; charset=utf-8'; -const HTML = 'text/html; charset=utf-8'; -const JSON_UTF8 = 'application/json; charset=utf-8'; - -describe('Webリソース', () => { - let app: INestApplicationContext; - - let alice: misskey.entities.MeSignup; - let aliceUploadedFile: any; - let alicesPost: any; - let alicePage: any; - let alicePlay: any; - let aliceClip: any; - let aliceGalleryPost: any; - let aliceChannel: any; - - let bob: misskey.entities.MeSignup; - - type Request = { - path: string, - accept?: string, - cookie?: string, - }; - const ok = async (param: Request & { - type?: string, - }):Promise => { - const { path, accept, cookie, type } = param; - const res = await simpleGet(path, accept, cookie); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, type ?? HTML); - return res; - }; - - const notOk = async (param: Request & { - status?: number, - code?: string, - }): Promise => { - const { path, accept, cookie, status, code } = param; - const res = await simpleGet(path, accept, cookie); - assert.notStrictEqual(res.status, 200); - if (status != null) { - assert.strictEqual(res.status, status); - } - if (code != null) { - assert.strictEqual(res.body.error.code, code); - } - return res; - }; - - const notFound = async (param: Request): Promise => { - return await notOk({ - ...param, - status: 404, - }); - }; - - const metaTag = (res: SimpleGetResponse, key: string, superkey = 'name'): string => { - return res.body.window.document.querySelector('meta[' + superkey + '="' + key + '"]')?.content; - }; - - beforeAll(async () => { - app = await startServer(); - alice = await signup({ username: 'alice' }); - aliceUploadedFile = await uploadFile(alice); - alicesPost = await post(alice, { - text: 'test', - }); - alicePage = await page(alice, {}); - alicePlay = await play(alice, {}); - aliceClip = await clip(alice, {}); - aliceGalleryPost = await galleryPost(alice, { - fileIds: [aliceUploadedFile.body.id], - }); - aliceChannel = await channel(alice, {}); - - bob = await signup({ username: 'bob' }); - }, 1000 * 60 * 2); - - afterAll(async () => { - await app.close(); - }); - - describe.each([ - { path: '/', type: HTML }, - { path: '/docs/ja-JP/about', type: HTML }, // "指定されたURLに該当するページはありませんでした。" - // fastify-static gives charset=UTF-8 instead of utf-8 and that's okay - { path: '/api-doc', type: 'text/html; charset=UTF-8' }, - { path: '/api.json', type: JSON_UTF8 }, - { path: '/api-console', type: HTML }, - { path: '/_info_card_', type: HTML }, - { path: '/bios', type: HTML }, - { path: '/cli', type: HTML }, - { path: '/flush', type: HTML }, - { path: '/robots.txt', type: 'text/plain; charset=UTF-8' }, - { path: '/favicon.ico', type: 'image/vnd.microsoft.icon' }, - { path: '/opensearch.xml', type: 'application/opensearchdescription+xml' }, - { path: '/apple-touch-icon.png', type: 'image/png' }, - { path: '/twemoji/2764.svg', type: 'image/svg+xml' }, - { path: '/twemoji/2764-fe0f-200d-1f525.svg', type: 'image/svg+xml' }, - { path: '/twemoji-badge/2764.png', type: 'image/png' }, - { path: '/twemoji-badge/2764-fe0f-200d-1f525.png', type: 'image/png' }, - { path: '/fluent-emoji/2764.png', type: 'image/png' }, - { path: '/fluent-emoji/2764-fe0f-200d-1f525.png', type: 'image/png' }, - ])('$path', (p) => { - test('がGETできる。', async () => await ok({ ...p })); - - // 注意: Webページが200で取得できても、実際のHTMLが正しく表示できるとは限らない - // 例えば、 /@xxx/pages/yyy に存在しないIDを渡した場合、HTTPレスポンスではエラーを区別できない - // こういったアサーションはフロントエンドE2EやAPI Endpointのテストで担保する。 - }); - - describe.each([ - { path: '/twemoji/2764.png' }, - { path: '/twemoji/2764-fe0f-200d-1f525.png' }, - { path: '/twemoji-badge/2764.svg' }, - { path: '/twemoji-badge/2764-fe0f-200d-1f525.svg' }, - { path: '/fluent-emoji/2764.svg' }, - { path: '/fluent-emoji/2764-fe0f-200d-1f525.svg' }, - ])('$path', ({ path }) => { - test('はGETできない。', async () => await notFound({ path })); - }); - - describe.each([ - { ext: 'rss', type: 'application/rss+xml; charset=utf-8' }, - { ext: 'atom', type: 'application/atom+xml; charset=utf-8' }, - { ext: 'json', type: 'application/json; charset=utf-8' }, - ])('/@:username.$ext', ({ ext, type }) => { - const path = (username: string): string => `/@${username}.${ext}`; - - test('がGETできる。', async () => await ok({ - path: path(alice.username), - type, - })); - - test('がGETできる。(ノートが存在しない場合でも。)', async () => await ok({ - path: path(bob.username), - type, - })); - - test('は存在しないユーザーはGETできない。', async () => await notOk({ - path: path('nonexisting'), - status: 404, - })); - }); - - describe.each([{ path: '/api/foo' }])('$path', ({ path }) => { - test('はGETできない。', async () => await notOk({ - path, - status: 404, - code: 'UNKNOWN_API_ENDPOINT', - })); - }); - - describe.each([{ path: '/queue' }])('$path', ({ path }) => { - test('はログインしないとGETできない。', async () => await notOk({ - path, - status: 401, - })); - - test('はadminでなければGETできない。', async () => await notOk({ - path, - cookie: cookie(bob), - status: 403, - })); - - test('はadminならGETできる。', async () => await ok({ - path, - cookie: cookie(alice), - })); - }); - - describe.each([{ path: '/streaming' }])('$path', ({ path }) => { - test('はGETできない。', async () => await notOk({ - path, - status: 503, - })); - }); - - describe('/@:username', () => { - const path = (username: string): string => `/@${username}`; - - describe.each([ - { accept: PREFER_HTML }, - { accept: UNSPECIFIED }, - ])('(Acceptヘッダ: $accept)', ({ accept }) => { - test('はHTMLとしてGETできる。', async () => { - const res = await ok({ - path: path(alice.username), - accept, - type: HTML, - }); - assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); - assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); - - // TODO ogタグの検証 - // TODO profile.noCrawleの検証 - // TODO twitter:creatorの検証 - // TODO の検証 - }); - test('はHTMLとしてGETできる。(存在しないIDでも。)', async () => await ok({ - path: path('xxxxxxxxxx'), - type: HTML, - })); - }); - - describe.each([ - { accept: ONLY_AP }, - { accept: PREFER_AP }, - ])('(Acceptヘッダ: $accept)', ({ accept }) => { - test('はActivityPubとしてGETできる。', async () => { - const res = await ok({ - path: path(alice.username), - accept, - type: AP, - }); - assert.strictEqual(res.body.type, 'Person'); - }); - - test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notFound({ - path: path('xxxxxxxxxx'), - accept, - })); - }); - }); - - describe.each([ - // 実際のハンドルはフロントエンド(index.vue)で行われる - { sub: 'home' }, - { sub: 'notes' }, - { sub: 'activity' }, - { sub: 'achievements' }, - { sub: 'reactions' }, - { sub: 'clips' }, - { sub: 'pages' }, - { sub: 'gallery' }, - ])('/@:username/$sub', ({ sub }) => { - const path = (username: string): string => `/@${username}/${sub}`; - - test('はHTMLとしてGETできる。', async () => { - const res = await ok({ - path: path(alice.username), - }); - assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); - assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); - }); - }); - - describe('/@:user/pages/:page', () => { - const path = (username: string, pagename: string): string => `/@${username}/pages/${pagename}`; - - test('はHTMLとしてGETできる。', async () => { - const res = await ok({ - path: path(alice.username, alicePage.name), - }); - assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); - assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); - assert.strictEqual(metaTag(res, 'misskey:page-id'), alicePage.id); - - // TODO ogタグの検証 - // TODO profile.noCrawleの検証 - // TODO twitter:creatorの検証 - }); - - test('はGETできる。(存在しないIDでも。)', async () => await ok({ - path: path(alice.username, 'xxxxxxxxxx'), - })); - }); - - describe('/users/:id', () => { - const path = (id: string): string => `/users/${id}`; - - describe.each([ - { accept: PREFER_HTML }, - { accept: UNSPECIFIED }, - ])('(Acceptヘッダ: $accept)', ({ accept }) => { - test('は/@:usernameにリダイレクトする', async () => { - const res = await simpleGet(path(alice.id), accept); - assert.strictEqual(res.status, 302); - assert.strictEqual(res.location, `/@${alice.username}`); - }); - - test('は存在しないユーザーはGETできない。', async () => await notFound({ - path: path('xxxxxxxx'), - })); - }); - - describe.each([ - { accept: ONLY_AP }, - { accept: PREFER_AP }, - ])('(Acceptヘッダ: $accept)', ({ accept }) => { - test('はActivityPubとしてGETできる。', async () => { - const res = await ok({ - path: path(alice.id), - accept, - type: AP, - }); - assert.strictEqual(res.body.type, 'Person'); - }); - - test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notOk({ - path: path('xxxxxxxx'), - accept, - status: 404, - })); - }); - }); - - describe('/users/inbox', () => { - test('がGETできる。(POST専用だけど4xx/5xxにならずHTMLが返ってくる)', async () => await ok({ - path: '/inbox', - })); - - // test.todo('POSTできる?'); - }); - - describe('/users/:id/inbox', () => { - const path = (id: string): string => `/users/${id}/inbox`; - - test('がGETできる。(POST専用だけど4xx/5xxにならずHTMLが返ってくる)', async () => await ok({ - path: path(alice.id), - })); - - // test.todo('POSTできる?'); - }); - - describe('/users/:id/outbox', () => { - const path = (id: string): string => `/users/${id}/outbox`; - - test('がGETできる。', async () => { - const res = await ok({ - path: path(alice.id), - type: AP, - }); - assert.strictEqual(res.body.type, 'OrderedCollection'); - }); - }); - - describe('/notes/:id', () => { - const path = (noteId: string): string => `/notes/${noteId}`; - - describe.each([ - { accept: PREFER_HTML }, - { accept: UNSPECIFIED }, - ])('(Acceptヘッダ: $accept)', ({ accept }) => { - test('はHTMLとしてGETできる。', async () => { - const res = await ok({ - path: path(alicesPost.id), - accept, - type: HTML, - }); - assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); - assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); - assert.strictEqual(metaTag(res, 'misskey:note-id'), alicesPost.id); - - // TODO ogタグの検証 - // TODO profile.noCrawleの検証 - // TODO twitter:creatorの検証 - }); - - test('はHTMLとしてGETできる。(存在しないIDでも。)', async () => await ok({ - path: path('xxxxxxxxxx'), - })); - }); - - describe.each([ - { accept: ONLY_AP }, - { accept: PREFER_AP }, - ])('(Acceptヘッダ: $accept)', ({ accept }) => { - test('はActivityPubとしてGETできる。', async () => { - const res = await ok({ - path: path(alicesPost.id), - accept, - type: AP, - }); - assert.strictEqual(res.body.type, 'Note'); - }); - - test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notFound({ - path: path('xxxxxxxxxx'), - accept, - })); - }); - }); - - describe('/play/:id', () => { - const path = (playid: string): string => `/play/${playid}`; - - test('がGETできる。', async () => { - const res = await ok({ - path: path(alicePlay.id), - }); - assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); - assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); - assert.strictEqual(metaTag(res, 'misskey:flash-id'), alicePlay.id); - - // TODO ogタグの検証 - // TODO profile.noCrawleの検証 - // TODO twitter:creatorの検証 - }); - - test('がGETできる。(存在しないIDでも。)', async () => await ok({ - path: path('xxxxxxxxxx'), - })); - }); - - describe('/clips/:clip', () => { - const path = (clip: string): string => `/clips/${clip}`; - - test('がGETできる。', async () => { - const res = await ok({ - path: path(aliceClip.id), - }); - assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); - assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); - assert.strictEqual(metaTag(res, 'misskey:clip-id'), aliceClip.id); - - // TODO ogタグの検証 - // TODO profile.noCrawleの検証 - }); - - test('がGETできる。(存在しないIDでも。)', async () => await ok({ - path: path('xxxxxxxxxx'), - })); - }); - - describe('/gallery/:post', () => { - const path = (post: string): string => `/gallery/${post}`; - - test('がGETできる。', async () => { - const res = await ok({ - path: path(aliceGalleryPost.id), - }); - assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); - assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); - - // FIXME: misskey:gallery-post-idみたいなmetaタグの設定がない - // TODO profile.noCrawleの検証 - // TODO twitter:creatorの検証 - }); - - test('がGETできる。(存在しないIDでも。)', async () => await ok({ - path: path('xxxxxxxxxx'), - })); - }); - - describe('/channels/:channel', () => { - const path = (channel: string): string => `/channels/${channel}`; - - test('はGETできる。', async () => { - const res = await ok({ - path: path(aliceChannel.id), - }); - - // FIXME: misskey関連のmetaタグの設定がない - // TODO ogタグの検証 - }); - - test('がGETできる。(存在しないIDでも。)', async () => await ok({ - path: path('xxxxxxxxxx'), - })); - }); -}); diff --git a/packages/backend/test/e2e/fetch-validate-ap-deny.ts b/packages/backend/test/e2e/fetch-validate-ap-deny.ts deleted file mode 100644 index 434a9fe209..0000000000 --- a/packages/backend/test/e2e/fetch-validate-ap-deny.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import { validateContentTypeSetAsActivityPub, validateContentTypeSetAsJsonLD } from '@/core/activitypub/misc/validator.js'; -import { signup, uploadFile, relativeFetch } from '../utils.js'; -import type * as misskey from 'misskey-js'; - -describe('validateContentTypeSetAsActivityPub/JsonLD (deny case)', () => { - let alice: misskey.entities.SignupResponse; - let aliceUploadedFile: any; - - beforeAll(async () => { - alice = await signup({ username: 'alice' }); - aliceUploadedFile = await uploadFile(alice); - }, 1000 * 60 * 2); - - test('ActivityStreams: ファイルはエラーになる', async () => { - const res = await relativeFetch(aliceUploadedFile.webpublicUrl); - - function doValidate() { - validateContentTypeSetAsActivityPub(res); - } - - expect(doValidate).toThrow('Content type is not'); - }); - - test('JSON-LD: ファイルはエラーになる', async () => { - const res = await relativeFetch(aliceUploadedFile.webpublicUrl); - - function doValidate() { - validateContentTypeSetAsJsonLD(res); - } - - expect(doValidate).toThrow('Content type is not'); - }); -}); diff --git a/packages/backend/test/e2e/ff-visibility.ts b/packages/backend/test/e2e/ff-visibility.ts deleted file mode 100644 index 1fbd45c741..0000000000 --- a/packages/backend/test/e2e/ff-visibility.ts +++ /dev/null @@ -1,672 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { signup, api, startServer, simpleGet } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; -import type * as misskey from 'misskey-js'; - -describe('FF visibility', () => { - let app: INestApplicationContext; - - let alice: misskey.entities.MeSignup; - let bob: misskey.entities.MeSignup; - - beforeAll(async () => { - app = await startServer(); - alice = await signup({ username: 'alice' }); - bob = await signup({ username: 'bob' }); - }, 1000 * 60 * 2); - - afterAll(async () => { - await app.close(); - }); - - test('followingVisibility, followersVisibility がともに public なユーザーのフォロー/フォロワーを誰でも見れる', async () => { - await api('/i/update', { - followingVisibility: 'public', - followersVisibility: 'public', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, bob); - const followersRes = await api('/users/followers', { - userId: alice.id, - }, bob); - - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - }); - - test('followingVisibility が public であれば followersVisibility の設定に関わらずユーザーのフォローを誰でも見れる', async () => { - { - await api('/i/update', { - followingVisibility: 'public', - followersVisibility: 'public', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, bob); - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - } - { - await api('/i/update', { - followingVisibility: 'public', - followersVisibility: 'followers', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, bob); - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - } - { - await api('/i/update', { - followingVisibility: 'public', - followersVisibility: 'private', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, bob); - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - } - }); - - test('followersVisibility が public であれば followingVisibility の設定に関わらずユーザーのフォロワーを誰でも見れる', async () => { - { - await api('/i/update', { - followingVisibility: 'public', - followersVisibility: 'public', - }, alice); - - const followersRes = await api('/users/followers', { - userId: alice.id, - }, bob); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - } - { - await api('/i/update', { - followingVisibility: 'followers', - followersVisibility: 'public', - }, alice); - - const followersRes = await api('/users/followers', { - userId: alice.id, - }, bob); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - } - { - await api('/i/update', { - followingVisibility: 'private', - followersVisibility: 'public', - }, alice); - - const followersRes = await api('/users/followers', { - userId: alice.id, - }, bob); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - } - }); - - test('followingVisibility, followersVisibility がともに followers なユーザーのフォロー/フォロワーを自分で見れる', async () => { - await api('/i/update', { - followingVisibility: 'followers', - followersVisibility: 'followers', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, alice); - const followersRes = await api('/users/followers', { - userId: alice.id, - }, alice); - - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - }); - - test('followingVisibility が followers なユーザーのフォローを followersVisibility の設定に関わらず自分で見れる', async () => { - { - await api('/i/update', { - followingVisibility: 'followers', - followersVisibility: 'public', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, alice); - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - } - { - await api('/i/update', { - followingVisibility: 'followers', - followersVisibility: 'followers', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, alice); - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - } - { - await api('/i/update', { - followingVisibility: 'followers', - followersVisibility: 'private', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, alice); - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - } - }); - - test('followersVisibility が followers なユーザーのフォロワーを followingVisibility の設定に関わらず自分で見れる', async () => { - { - await api('/i/update', { - followingVisibility: 'public', - followersVisibility: 'followers', - }, alice); - - const followersRes = await api('/users/followers', { - userId: alice.id, - }, alice); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - } - { - await api('/i/update', { - followingVisibility: 'followers', - followersVisibility: 'followers', - }, alice); - - const followersRes = await api('/users/followers', { - userId: alice.id, - }, alice); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - } - { - await api('/i/update', { - followingVisibility: 'private', - followersVisibility: 'followers', - }, alice); - - const followersRes = await api('/users/followers', { - userId: alice.id, - }, alice); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - } - }); - - test('followingVisibility, followersVisibility がともに followers なユーザーのフォロー/フォロワーを非フォロワーが見れない', async () => { - await api('/i/update', { - followingVisibility: 'followers', - followersVisibility: 'followers', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, bob); - const followersRes = await api('/users/followers', { - userId: alice.id, - }, bob); - - assert.strictEqual(followingRes.status, 400); - assert.strictEqual(followersRes.status, 400); - }); - - test('followingVisibility が followers なユーザーのフォローを followersVisibility の設定に関わらず非フォロワーが見れない', async () => { - { - await api('/i/update', { - followingVisibility: 'followers', - followersVisibility: 'public', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, bob); - assert.strictEqual(followingRes.status, 400); - } - { - await api('/i/update', { - followingVisibility: 'followers', - followersVisibility: 'followers', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, bob); - assert.strictEqual(followingRes.status, 400); - } - { - await api('/i/update', { - followingVisibility: 'followers', - followersVisibility: 'private', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, bob); - assert.strictEqual(followingRes.status, 400); - } - }); - - test('followersVisibility が followers なユーザーのフォロワーを followingVisibility の設定に関わらず非フォロワーが見れない', async () => { - { - await api('/i/update', { - followingVisibility: 'public', - followersVisibility: 'followers', - }, alice); - - const followersRes = await api('/users/followers', { - userId: alice.id, - }, bob); - assert.strictEqual(followersRes.status, 400); - } - { - await api('/i/update', { - followingVisibility: 'followers', - followersVisibility: 'followers', - }, alice); - - const followersRes = await api('/users/followers', { - userId: alice.id, - }, bob); - assert.strictEqual(followersRes.status, 400); - } - { - await api('/i/update', { - followingVisibility: 'private', - followersVisibility: 'followers', - }, alice); - - const followersRes = await api('/users/followers', { - userId: alice.id, - }, bob); - assert.strictEqual(followersRes.status, 400); - } - }); - - test('followingVisibility, followersVisibility がともに followers なユーザーのフォロー/フォロワーをフォロワーが見れる', async () => { - await api('/i/update', { - followingVisibility: 'followers', - followersVisibility: 'followers', - }, alice); - - await api('/following/create', { - userId: alice.id, - }, bob); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, bob); - const followersRes = await api('/users/followers', { - userId: alice.id, - }, bob); - - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - }); - - test('followingVisibility が followers なユーザーのフォローを followersVisibility の設定に関わらずフォロワーが見れる', async () => { - { - await api('/i/update', { - followingVisibility: 'followers', - followersVisibility: 'public', - }, alice); - await api('/following/create', { - userId: alice.id, - }, bob); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, bob); - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - } - { - await api('/i/update', { - followingVisibility: 'followers', - followersVisibility: 'followers', - }, alice); - await api('/following/create', { - userId: alice.id, - }, bob); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, bob); - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - } - { - await api('/i/update', { - followingVisibility: 'followers', - followersVisibility: 'private', - }, alice); - await api('/following/create', { - userId: alice.id, - }, bob); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, bob); - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - } - }); - - test('followersVisibility が followers なユーザーのフォロワーを followingVisibility の設定に関わらずフォロワーが見れる', async () => { - { - await api('/i/update', { - followingVisibility: 'public', - followersVisibility: 'followers', - }, alice); - await api('/following/create', { - userId: alice.id, - }, bob); - - const followersRes = await api('/users/followers', { - userId: alice.id, - }, bob); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - } - { - await api('/i/update', { - followingVisibility: 'followers', - followersVisibility: 'followers', - }, alice); - await api('/following/create', { - userId: alice.id, - }, bob); - - const followersRes = await api('/users/followers', { - userId: alice.id, - }, bob); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - } - { - await api('/i/update', { - followingVisibility: 'private', - followersVisibility: 'followers', - }, alice); - await api('/following/create', { - userId: alice.id, - }, bob); - - const followersRes = await api('/users/followers', { - userId: alice.id, - }, bob); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - } - }); - - test('followingVisibility, followersVisibility がともに private なユーザーのフォロー/フォロワーを自分で見れる', async () => { - await api('/i/update', { - followingVisibility: 'private', - followersVisibility: 'private', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, alice); - const followersRes = await api('/users/followers', { - userId: alice.id, - }, alice); - - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - }); - - test('followingVisibility が private なユーザーのフォローを followersVisibility の設定に関わらず自分で見れる', async () => { - { - await api('/i/update', { - followingVisibility: 'private', - followersVisibility: 'public', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, alice); - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - } - { - await api('/i/update', { - followingVisibility: 'private', - followersVisibility: 'followers', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, alice); - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - } - { - await api('/i/update', { - followingVisibility: 'private', - followersVisibility: 'private', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, alice); - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - } - }); - - test('followersVisibility が private なユーザーのフォロワーを followingVisibility の設定に関わらず自分で見れる', async () => { - { - await api('/i/update', { - followingVisibility: 'public', - followersVisibility: 'private', - }, alice); - - const followersRes = await api('/users/followers', { - userId: alice.id, - }, alice); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - } - { - await api('/i/update', { - followingVisibility: 'followers', - followersVisibility: 'private', - }, alice); - - const followersRes = await api('/users/followers', { - userId: alice.id, - }, alice); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - } - { - await api('/i/update', { - followingVisibility: 'private', - followersVisibility: 'private', - }, alice); - - const followersRes = await api('/users/followers', { - userId: alice.id, - }, alice); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - } - }); - - test('followingVisibility, followersVisibility がともに private なユーザーのフォロー/フォロワーを他人が見れない', async () => { - await api('/i/update', { - followingVisibility: 'private', - followersVisibility: 'private', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, bob); - const followersRes = await api('/users/followers', { - userId: alice.id, - }, bob); - - assert.strictEqual(followingRes.status, 400); - assert.strictEqual(followersRes.status, 400); - }); - - test('followingVisibility が private なユーザーのフォローを followersVisibility の設定に関わらず他人が見れない', async () => { - { - await api('/i/update', { - followingVisibility: 'private', - followersVisibility: 'public', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, bob); - assert.strictEqual(followingRes.status, 400); - } - { - await api('/i/update', { - followingVisibility: 'private', - followersVisibility: 'followers', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, bob); - assert.strictEqual(followingRes.status, 400); - } - { - await api('/i/update', { - followingVisibility: 'private', - followersVisibility: 'private', - }, alice); - - const followingRes = await api('/users/following', { - userId: alice.id, - }, bob); - assert.strictEqual(followingRes.status, 400); - } - }); - - test('followersVisibility が private なユーザーのフォロワーを followingVisibility の設定に関わらず他人が見れない', async () => { - { - await api('/i/update', { - followingVisibility: 'public', - followersVisibility: 'private', - }, alice); - - const followersRes = await api('/users/followers', { - userId: alice.id, - }, bob); - assert.strictEqual(followersRes.status, 400); - } - { - await api('/i/update', { - followingVisibility: 'followers', - followersVisibility: 'private', - }, alice); - - const followersRes = await api('/users/followers', { - userId: alice.id, - }, bob); - assert.strictEqual(followersRes.status, 400); - } - { - await api('/i/update', { - followingVisibility: 'private', - followersVisibility: 'private', - }, alice); - - const followersRes = await api('/users/followers', { - userId: alice.id, - }, bob); - assert.strictEqual(followersRes.status, 400); - } - }); - - describe('AP', () => { - test('followingVisibility が public 以外ならばAPからはフォローを取得できない', async () => { - { - await api('/i/update', { - followingVisibility: 'public', - }, alice); - - const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json'); - assert.strictEqual(followingRes.status, 200); - } - { - await api('/i/update', { - followingVisibility: 'followers', - }, alice); - - const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json'); - assert.strictEqual(followingRes.status, 403); - } - { - await api('/i/update', { - followingVisibility: 'private', - }, alice); - - const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json'); - assert.strictEqual(followingRes.status, 403); - } - }); - - test('followersVisibility が public 以外ならばAPからはフォロワーを取得できない', async () => { - { - await api('/i/update', { - followersVisibility: 'public', - }, alice); - - const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json'); - assert.strictEqual(followersRes.status, 200); - } - { - await api('/i/update', { - followersVisibility: 'followers', - }, alice); - - const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json'); - assert.strictEqual(followersRes.status, 403); - } - { - await api('/i/update', { - followersVisibility: 'private', - }, alice); - - const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json'); - assert.strictEqual(followersRes.status, 403); - } - }); - }); -}); diff --git a/packages/backend/test/e2e/move.ts b/packages/backend/test/e2e/move.ts deleted file mode 100644 index b009ef124a..0000000000 --- a/packages/backend/test/e2e/move.ts +++ /dev/null @@ -1,464 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { loadConfig } from '@/config.js'; -import { MiUser, UsersRepository } from '@/models/_.js'; -import { jobQueue } from '@/boot/common.js'; -import { secureRndstr } from '@/misc/secure-rndstr.js'; -import { uploadFile, signup, startServer, initTestDb, api, sleep, successfulApiCall } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; -import type * as misskey from 'misskey-js'; - -describe('Account Move', () => { - let app: INestApplicationContext; - let jq: INestApplicationContext; - let url: URL; - - let root: any; - let alice: misskey.entities.MeSignup; - let bob: misskey.entities.MeSignup; - let carol: misskey.entities.MeSignup; - let dave: misskey.entities.MeSignup; - let eve: misskey.entities.MeSignup; - let frank: misskey.entities.MeSignup; - - let Users: UsersRepository; - - beforeAll(async () => { - app = await startServer(); - jq = await jobQueue(); - const config = loadConfig(); - url = new URL(config.url); - const connection = await initTestDb(false); - root = await signup({ username: 'root' }); - alice = await signup({ username: 'alice' }); - bob = await signup({ username: 'bob' }); - carol = await signup({ username: 'carol' }); - dave = await signup({ username: 'dave' }); - eve = await signup({ username: 'eve' }); - frank = await signup({ username: 'frank' }); - Users = connection.getRepository(MiUser); - }, 1000 * 60 * 2); - - afterAll(async () => { - await Promise.all([app.close(), jq.close()]); - }); - - describe('Create Alias', () => { - afterEach(async () => { - await Users.update(bob.id, { alsoKnownAs: null }); - }, 1000 * 10); - - test('Able to create an alias', async () => { - const res = await api('/i/update', { - alsoKnownAs: [`@alice@${url.hostname}`], - }, bob); - - const newBob = await Users.findOneByOrFail({ id: bob.id }); - assert.strictEqual(newBob.alsoKnownAs?.length, 1); - assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); - assert.strictEqual(res.body.alsoKnownAs?.length, 1); - assert.strictEqual(res.body.alsoKnownAs[0], alice.id); - }); - - test('Able to create a local alias without hostname', async () => { - await api('/i/update', { - alsoKnownAs: ['@alice'], - }, bob); - - const newBob = await Users.findOneByOrFail({ id: bob.id }); - assert.strictEqual(newBob.alsoKnownAs?.length, 1); - assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); - }); - - test('Able to create a local alias without @', async () => { - await api('/i/update', { - alsoKnownAs: ['alice'], - }, bob); - - const newBob = await Users.findOneByOrFail({ id: bob.id }); - assert.strictEqual(newBob.alsoKnownAs?.length, 1); - assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); - }); - - test('Able to set remote user (but may fail)', async () => { - const res = await api('/i/update', { - alsoKnownAs: ['@syuilo@example.com'], - }, bob); - - assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NO_SUCH_USER'); - assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); - }); - - test('Unable to add duplicated aliases to alsoKnownAs', async () => { - const res = await api('/i/update', { - alsoKnownAs: [`@alice@${url.hostname}`, `@alice@${url.hostname}`], - }, bob); - - assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'INVALID_PARAM'); - assert.strictEqual(res.body.error.id, '3d81ceae-475f-4600-b2a8-2bc116157532'); - }); - - test('Unable to add itself', async () => { - const res = await api('/i/update', { - alsoKnownAs: [`@bob@${url.hostname}`], - }, bob); - - assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'FORBIDDEN_TO_SET_YOURSELF'); - assert.strictEqual(res.body.error.id, '25c90186-4ab0-49c8-9bba-a1fa6c202ba4'); - }); - - test('Unable to add a nonexisting local account to alsoKnownAs', async () => { - const res1 = await api('/i/update', { - alsoKnownAs: [`@nonexist@${url.hostname}`], - }, bob); - - assert.strictEqual(res1.status, 400); - assert.strictEqual(res1.body.error.code, 'NO_SUCH_USER'); - assert.strictEqual(res1.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); - - const res2 = await api('/i/update', { - alsoKnownAs: ['@alice', 'nonexist'], - }, bob); - - assert.strictEqual(res2.status, 400); - assert.strictEqual(res2.body.error.code, 'NO_SUCH_USER'); - assert.strictEqual(res2.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); - }); - - test('Able to add two existing local account to alsoKnownAs', async () => { - await api('/i/update', { - alsoKnownAs: [`@alice@${url.hostname}`, `@carol@${url.hostname}`], - }, bob); - - const newBob = await Users.findOneByOrFail({ id: bob.id }); - assert.strictEqual(newBob.alsoKnownAs?.length, 2); - assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); - assert.strictEqual(newBob.alsoKnownAs[1], `${url.origin}/users/${carol.id}`); - }); - - test('Able to properly overwrite alsoKnownAs', async () => { - await api('/i/update', { - alsoKnownAs: [`@alice@${url.hostname}`], - }, bob); - await api('/i/update', { - alsoKnownAs: [`@carol@${url.hostname}`, `@dave@${url.hostname}`], - }, bob); - - const newBob = await Users.findOneByOrFail({ id: bob.id }); - assert.strictEqual(newBob.alsoKnownAs?.length, 2); - assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${carol.id}`); - assert.strictEqual(newBob.alsoKnownAs[1], `${url.origin}/users/${dave.id}`); - }); - }); - - describe('Local to Local', () => { - let antennaId = ''; - - beforeAll(async () => { - await api('/i/update', { - alsoKnownAs: [`@alice@${url.hostname}`], - }, root); - const listRoot = await api('/users/lists/create', { - name: secureRndstr(8), - }, root); - await api('/users/lists/push', { - listId: listRoot.body.id, - userId: alice.id, - }, root); - - await api('/following/create', { - userId: root.id, - }, alice); - await api('/following/create', { - userId: eve.id, - }, alice); - const antenna = await api('/antennas/create', { - name: secureRndstr(8), - src: 'home', - keywords: [secureRndstr(8)], - excludeKeywords: [], - users: [], - caseSensitive: false, - localOnly: false, - withReplies: false, - withFile: false, - notify: false, - }, alice); - antennaId = antenna.body.id; - - await api('/i/update', { - alsoKnownAs: [`@alice@${url.hostname}`], - }, bob); - - await api('/following/create', { - userId: alice.id, - }, carol); - - await api('/mute/create', { - userId: alice.id, - }, dave); - await api('/blocking/create', { - userId: alice.id, - }, dave); - await api('/following/create', { - userId: eve.id, - }, dave); - - await api('/following/create', { - userId: dave.id, - }, eve); - const listEve = await api('/users/lists/create', { - name: secureRndstr(8), - }, eve); - await api('/users/lists/push', { - listId: listEve.body.id, - userId: bob.id, - }, eve); - - await api('/i/update', { - isLocked: true, - }, frank); - await api('/following/create', { - userId: frank.id, - }, alice); - await api('/following/requests/accept', { - userId: alice.id, - }, frank); - }, 1000 * 10); - - test('Prohibit the root account from moving', async () => { - const res = await api('/i/move', { - moveToAccount: `@bob@${url.hostname}`, - }, root); - - assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NOT_ROOT_FORBIDDEN'); - assert.strictEqual(res.body.error.id, '4362e8dc-731f-4ad8-a694-be2a88922a24'); - }); - - test('Unable to move to a nonexisting local account', async () => { - const res = await api('/i/move', { - moveToAccount: `@nonexist@${url.hostname}`, - }, alice); - - assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NO_SUCH_USER'); - assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); - }); - - test('Unable to move if alsoKnownAs is invalid', async () => { - const res = await api('/i/move', { - moveToAccount: `@carol@${url.hostname}`, - }, alice); - - assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS'); - assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); - }); - - test('Relationships have been properly migrated', async () => { - const move = await api('/i/move', { - moveToAccount: `@bob@${url.hostname}`, - }, alice); - - assert.strictEqual(move.status, 200); - - await sleep(1000 * 3); // wait for jobs to finish - - // Unfollow delayed? - const aliceFollowings = await api('/users/following', { - userId: alice.id, - }, alice); - assert.strictEqual(aliceFollowings.status, 200); - assert.strictEqual(aliceFollowings.body.length, 3); - - const carolFollowings = await api('/users/following', { - userId: carol.id, - }, carol); - assert.strictEqual(carolFollowings.status, 200); - assert.strictEqual(carolFollowings.body.length, 2); - assert.strictEqual(carolFollowings.body[0].followeeId, bob.id); - assert.strictEqual(carolFollowings.body[1].followeeId, alice.id); - - const blockings = await api('/blocking/list', {}, dave); - assert.strictEqual(blockings.status, 200); - assert.strictEqual(blockings.body.length, 2); - assert.strictEqual(blockings.body[0].blockeeId, bob.id); - assert.strictEqual(blockings.body[1].blockeeId, alice.id); - - const mutings = await api('/mute/list', {}, dave); - assert.strictEqual(mutings.status, 200); - assert.strictEqual(mutings.body.length, 2); - assert.strictEqual(mutings.body[0].muteeId, bob.id); - assert.strictEqual(mutings.body[1].muteeId, alice.id); - - const rootLists = await api('/users/lists/list', {}, root); - assert.strictEqual(rootLists.status, 200); - assert.strictEqual(rootLists.body[0].userIds.length, 2); - assert.ok(rootLists.body[0].userIds.find((id: string) => id === bob.id)); - assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id)); - - const eveLists = await api('/users/lists/list', {}, eve); - assert.strictEqual(eveLists.status, 200); - assert.strictEqual(eveLists.body[0].userIds.length, 1); - assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id)); - }); - - test('A locked account automatically accept the follow request if it had already accepted the old account.', async () => { - await successfulApiCall({ - endpoint: '/following/create', - parameters: { - userId: frank.id, - }, - user: bob, - }); - const followers = await api('/users/followers', { - userId: frank.id, - }, frank); - - assert.strictEqual(followers.status, 200); - assert.strictEqual(followers.body.length, 2); - assert.strictEqual(followers.body[0].followerId, bob.id); - }); - - test('Unfollowed after 10 sec (24 hours in production).', async () => { - await sleep(1000 * 8); - - const following = await api('/users/following', { - userId: alice.id, - }, alice); - - assert.strictEqual(following.status, 200); - assert.strictEqual(following.body.length, 0); - }); - - test('Unable to move if the destination account has already moved.', async () => { - const res = await api('/i/move', { - moveToAccount: `@alice@${url.hostname}`, - }, bob); - - assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS'); - assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); - }); - - test('Follow and follower counts are properly adjusted', async () => { - await api('/following/create', { - userId: alice.id, - }, eve); - const newAlice = await Users.findOneByOrFail({ id: alice.id }); - const newCarol = await Users.findOneByOrFail({ id: carol.id }); - let newEve = await Users.findOneByOrFail({ id: eve.id }); - assert.strictEqual(newAlice.movedToUri, `${url.origin}/users/${bob.id}`); - assert.strictEqual(newAlice.followingCount, 0); - assert.strictEqual(newAlice.followersCount, 0); - assert.strictEqual(newCarol.followingCount, 1); - assert.strictEqual(newEve.followingCount, 1); - assert.strictEqual(newEve.followersCount, 1); - - await api('/following/delete', { - userId: alice.id, - }, eve); - newEve = await Users.findOneByOrFail({ id: eve.id }); - assert.strictEqual(newEve.followingCount, 1); - assert.strictEqual(newEve.followersCount, 1); - }); - - test.each([ - '/antennas/create', - '/channels/create', - '/channels/favorite', - '/channels/follow', - '/channels/unfavorite', - '/channels/unfollow', - '/clips/add-note', - '/clips/create', - '/clips/favorite', - '/clips/remove-note', - '/clips/unfavorite', - '/clips/update', - '/drive/files/upload-from-url', - '/flash/create', - '/flash/like', - '/flash/unlike', - '/flash/update', - '/following/create', - '/gallery/posts/create', - '/gallery/posts/like', - '/gallery/posts/unlike', - '/gallery/posts/update', - '/i/claim-achievement', - '/i/move', - '/i/import-blocking', - '/i/import-following', - '/i/import-muting', - '/i/import-user-lists', - '/i/pin', - '/mute/create', - '/notes/create', - '/notes/favorites/create', - '/notes/polls/vote', - '/notes/reactions/create', - '/pages/create', - '/pages/like', - '/pages/unlike', - '/pages/update', - '/renote-mute/create', - '/users/lists/create', - '/users/lists/pull', - '/users/lists/push', - ])('Prohibit access after moving: %s', async (endpoint) => { - const res = await api(endpoint, {}, alice); - assert.strictEqual(res.status, 403); - assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); - assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); - }); - - test('Prohibit access after moving: /antennas/update', async () => { - const res = await api('/antennas/update', { - antennaId, - name: secureRndstr(8), - src: 'users', - keywords: [secureRndstr(8)], - excludeKeywords: [], - users: [eve.id], - caseSensitive: false, - localOnly: false, - withReplies: false, - withFile: false, - notify: false, - }, alice); - - assert.strictEqual(res.status, 403); - assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); - assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); - }); - - test('Prohibit access after moving: /drive/files/create', async () => { - const res = await uploadFile(alice); - - assert.strictEqual(res.status, 403); - assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); - assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); - }); - - test('Prohibit updating alsoKnownAs after moving', async () => { - const res = await api('/i/update', { - alsoKnownAs: [`@eve@${url.hostname}`], - }, alice); - - assert.strictEqual(res.status, 403); - assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); - assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); - }); - }); -}); diff --git a/packages/backend/test/e2e/mute.ts b/packages/backend/test/e2e/mute.ts deleted file mode 100644 index a4b57a1eba..0000000000 --- a/packages/backend/test/e2e/mute.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { signup, api, post, react, startServer, waitFire } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; -import type * as misskey from 'misskey-js'; - -describe('Mute', () => { - let app: INestApplicationContext; - - // alice mutes carol - let alice: misskey.entities.MeSignup; - let bob: misskey.entities.MeSignup; - let carol: misskey.entities.MeSignup; - - beforeAll(async () => { - app = await startServer(); - alice = await signup({ username: 'alice' }); - bob = await signup({ username: 'bob' }); - carol = await signup({ username: 'carol' }); - }, 1000 * 60 * 2); - - afterAll(async () => { - await app.close(); - }); - - test('ミュート作成', async () => { - const res = await api('/mute/create', { - userId: carol.id, - }, alice); - - assert.strictEqual(res.status, 204); - }); - - test('「自分宛ての投稿」にミュートしているユーザーの投稿が含まれない', async () => { - const bobNote = await post(bob, { text: '@alice hi' }); - const carolNote = await post(carol, { text: '@alice hi' }); - - const res = await api('/notes/mentions', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async () => { - // 状態リセット - await api('/i/read-all-unread-notes', {}, alice); - - await post(carol, { text: '@alice hi' }); - - const res = await api('/i', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(res.body.hasUnreadMentions, false); - }); - - test('ミュートしているユーザーからメンションされても、ストリームに unreadMention イベントが流れてこない', async () => { - // 状態リセット - await api('/i/read-all-unread-notes', {}, alice); - - const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadMention'); - - assert.strictEqual(fired, false); - }); - - test('ミュートしているユーザーからメンションされても、ストリームに unreadNotification イベントが流れてこない', async () => { - // 状態リセット - await api('/i/read-all-unread-notes', {}, alice); - await api('/notifications/mark-all-as-read', {}, alice); - - const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadNotification'); - - assert.strictEqual(fired, false); - }); - - describe('Timeline', () => { - test('タイムラインにミュートしているユーザーの投稿が含まれない', async () => { - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi' }); - const carolNote = await post(carol, { text: 'hi' }); - - const res = await api('/notes/local-timeline', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => { - const aliceNote = await post(alice, { text: 'hi' }); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { - renoteId: carolNote.id, - }); - - const res = await api('/notes/local-timeline', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - }); - - describe('Notification', () => { - test('通知にミュートしているユーザーの通知が含まれない(リアクション)', async () => { - const aliceNote = await post(alice, { text: 'hi' }); - await react(bob, aliceNote, 'like'); - await react(carol, aliceNote, 'like'); - - const res = await api('/i/notifications', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); - }); - }); -}); diff --git a/packages/backend/test/e2e/nodeinfo.ts b/packages/backend/test/e2e/nodeinfo.ts deleted file mode 100644 index 7eed39c5ed..0000000000 --- a/packages/backend/test/e2e/nodeinfo.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { relativeFetch, startServer } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; - -describe('nodeinfo', () => { - let app: INestApplicationContext; - - beforeAll(async () => { - app = await startServer(); - }, 1000 * 60 * 2); - - afterAll(async () => { - await app.close(); - }); - - test('nodeinfo 2.1', async () => { - const res = await relativeFetch('nodeinfo/2.1'); - assert.ok(res.ok); - assert.strictEqual(res.headers.get('Access-Control-Allow-Origin'), '*'); - - const nodeInfo = await res.json() as any; - assert.strictEqual(nodeInfo.software.name, 'misskey'); - }); - - test('nodeinfo 2.0', async () => { - const res = await relativeFetch('nodeinfo/2.0'); - assert.ok(res.ok); - assert.strictEqual(res.headers.get('Access-Control-Allow-Origin'), '*'); - - const nodeInfo = await res.json() as any; - assert.strictEqual(nodeInfo.software.name, 'misskey'); - }); -}); diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts deleted file mode 100644 index 961df99cc2..0000000000 --- a/packages/backend/test/e2e/note.ts +++ /dev/null @@ -1,637 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { MiNote } from '@/models/Note.js'; -import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; -import { signup, post, uploadUrl, startServer, initTestDb, api, uploadFile } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; -import type * as misskey from 'misskey-js'; - -describe('Note', () => { - let app: INestApplicationContext; - let Notes: any; - - let alice: misskey.entities.MeSignup; - let bob: misskey.entities.MeSignup; - - beforeAll(async () => { - app = await startServer(); - const connection = await initTestDb(true); - Notes = connection.getRepository(MiNote); - alice = await signup({ username: 'alice' }); - bob = await signup({ username: 'bob' }); - }, 1000 * 60 * 2); - - afterAll(async () => { - await app.close(); - }); - - test('投稿できる', async () => { - const post = { - text: 'test', - }; - - const res = await api('/notes/create', post, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.createdNote.text, post.text); - }); - - test('ファイルを添付できる', async () => { - const file = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); - - const res = await api('/notes/create', { - fileIds: [file.id], - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.deepStrictEqual(res.body.createdNote.fileIds, [file.id]); - }, 1000 * 10); - - test('他人のファイルで怒られる', async () => { - const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); - - const res = await api('/notes/create', { - text: 'test', - fileIds: [file.id], - }, alice); - - assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); - assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); - }, 1000 * 10); - - test('存在しないファイルで怒られる', async () => { - const res = await api('/notes/create', { - text: 'test', - fileIds: ['000000000000000000000000'], - }, alice); - - assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); - assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); - }); - - test('不正なファイルIDで怒られる', async () => { - const res = await api('/notes/create', { - fileIds: ['kyoppie'], - }, alice); - assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); - assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); - }); - - test('返信できる', async () => { - const bobPost = await post(bob, { - text: 'foo', - }); - - const alicePost = { - text: 'bar', - replyId: bobPost.id, - }; - - const res = await api('/notes/create', alicePost, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.createdNote.text, alicePost.text); - assert.strictEqual(res.body.createdNote.replyId, alicePost.replyId); - assert.strictEqual(res.body.createdNote.reply.text, bobPost.text); - }); - - test('renoteできる', async () => { - const bobPost = await post(bob, { - text: 'test', - }); - - const alicePost = { - renoteId: bobPost.id, - }; - - const res = await api('/notes/create', alicePost, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId); - assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); - }); - - test('引用renoteできる', async () => { - const bobPost = await post(bob, { - text: 'test', - }); - - const alicePost = { - text: 'test', - renoteId: bobPost.id, - }; - - const res = await api('/notes/create', alicePost, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.createdNote.text, alicePost.text); - assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId); - assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); - }); - - test('visibility: followersでrenoteできる', async () => { - const createRes = await api('/notes/create', { - text: 'test', - visibility: 'followers', - }, alice); - - assert.strictEqual(createRes.status, 200); - - const renoteId = createRes.body.createdNote.id; - const renoteRes = await api('/notes/create', { - visibility: 'followers', - renoteId, - }, alice); - - assert.strictEqual(renoteRes.status, 200); - assert.strictEqual(renoteRes.body.createdNote.renoteId, renoteId); - assert.strictEqual(renoteRes.body.createdNote.visibility, 'followers'); - - const deleteRes = await api('/notes/delete', { - noteId: renoteRes.body.createdNote.id, - }, alice); - - assert.strictEqual(deleteRes.status, 204); - }); - - test('文字数ぎりぎりで怒られない', async () => { - const post = { - text: '!'.repeat(MAX_NOTE_TEXT_LENGTH), // 3000文字 - }; - const res = await api('/notes/create', post, alice); - assert.strictEqual(res.status, 200); - }); - - test('文字数オーバーで怒られる', async () => { - const post = { - text: '!'.repeat(MAX_NOTE_TEXT_LENGTH + 1), // 3001文字 - }; - const res = await api('/notes/create', post, alice); - assert.strictEqual(res.status, 400); - }); - - test('存在しないリプライ先で怒られる', async () => { - const post = { - text: 'test', - replyId: '000000000000000000000000', - }; - const res = await api('/notes/create', post, alice); - assert.strictEqual(res.status, 400); - }); - - test('存在しないrenote対象で怒られる', async () => { - const post = { - renoteId: '000000000000000000000000', - }; - const res = await api('/notes/create', post, alice); - assert.strictEqual(res.status, 400); - }); - - test('不正なリプライ先IDで怒られる', async () => { - const post = { - text: 'test', - replyId: 'foo', - }; - const res = await api('/notes/create', post, alice); - assert.strictEqual(res.status, 400); - }); - - test('不正なrenote対象IDで怒られる', async () => { - const post = { - renoteId: 'foo', - }; - const res = await api('/notes/create', post, alice); - assert.strictEqual(res.status, 400); - }); - - test('存在しないユーザーにメンションできる', async () => { - const post = { - text: '@ghost yo', - }; - - const res = await api('/notes/create', post, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.createdNote.text, post.text); - }); - - test('同じユーザーに複数メンションしても内部的にまとめられる', async () => { - const post = { - text: '@bob @bob @bob yo', - }; - - const res = await api('/notes/create', post, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.createdNote.text, post.text); - - const noteDoc = await Notes.findOneBy({ id: res.body.createdNote.id }); - assert.deepStrictEqual(noteDoc.mentions, [bob.id]); - }); - - describe('添付ファイル情報', () => { - test('ファイルを添付した場合、投稿成功時にファイル情報入りのレスポンスが帰ってくる', async () => { - const file = await uploadFile(alice); - const res = await api('/notes/create', { - fileIds: [file.body.id], - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.createdNote.files.length, 1); - assert.strictEqual(res.body.createdNote.files[0].id, file.body.id); - }); - - test('ファイルを添付した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { - const file = await uploadFile(alice); - const createdNote = await api('/notes/create', { - fileIds: [file.body.id], - }, alice); - - assert.strictEqual(createdNote.status, 200); - - const res = await api('/notes', { - withFiles: true, - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - const myNote = res.body.find((note: { id: string; files: { id: string }[] }) => note.id === createdNote.body.createdNote.id); - assert.notEqual(myNote, null); - assert.strictEqual(myNote.files.length, 1); - assert.strictEqual(myNote.files[0].id, file.body.id); - }); - - test('ファイルが添付されたノートをリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { - const file = await uploadFile(alice); - const createdNote = await api('/notes/create', { - fileIds: [file.body.id], - }, alice); - - assert.strictEqual(createdNote.status, 200); - - const renoted = await api('/notes/create', { - renoteId: createdNote.body.createdNote.id, - }, alice); - assert.strictEqual(renoted.status, 200); - - const res = await api('/notes', { - renote: true, - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id); - assert.notEqual(myNote, null); - assert.strictEqual(myNote.renote.files.length, 1); - assert.strictEqual(myNote.renote.files[0].id, file.body.id); - }); - - test('ファイルが添付されたノートに返信した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { - const file = await uploadFile(alice); - const createdNote = await api('/notes/create', { - fileIds: [file.body.id], - }, alice); - - assert.strictEqual(createdNote.status, 200); - - const reply = await api('/notes/create', { - replyId: createdNote.body.createdNote.id, - text: 'this is reply', - }, alice); - assert.strictEqual(reply.status, 200); - - const res = await api('/notes', { - reply: true, - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - const myNote = res.body.find((note: { id: string }) => note.id === reply.body.createdNote.id); - assert.notEqual(myNote, null); - assert.strictEqual(myNote.reply.files.length, 1); - assert.strictEqual(myNote.reply.files[0].id, file.body.id); - }); - - test('ファイルが添付されたノートへの返信をリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { - const file = await uploadFile(alice); - const createdNote = await api('/notes/create', { - fileIds: [file.body.id], - }, alice); - - assert.strictEqual(createdNote.status, 200); - - const reply = await api('/notes/create', { - replyId: createdNote.body.createdNote.id, - text: 'this is reply', - }, alice); - assert.strictEqual(reply.status, 200); - - const renoted = await api('/notes/create', { - renoteId: reply.body.createdNote.id, - }, alice); - assert.strictEqual(renoted.status, 200); - - const res = await api('/notes', { - renote: true, - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id); - assert.notEqual(myNote, null); - assert.strictEqual(myNote.renote.reply.files.length, 1); - assert.strictEqual(myNote.renote.reply.files[0].id, file.body.id); - }); - - test('NSFWが強制されている場合変更できない', async () => { - const file = await uploadFile(alice); - - const res = await api('admin/roles/create', { - name: 'test', - description: '', - color: null, - iconUrl: null, - displayOrder: 0, - target: 'manual', - condFormula: {}, - isAdministrator: false, - isModerator: false, - isPublic: false, - isExplorable: false, - asBadge: false, - canEditMembersByModerator: false, - policies: { - alwaysMarkNsfw: { - useDefault: false, - priority: 0, - value: true, - }, - }, - }, alice); - - assert.strictEqual(res.status, 200); - - const assign = await api('admin/roles/assign', { - userId: alice.id, - roleId: res.body.id, - }, alice); - - assert.strictEqual(assign.status, 204); - assert.strictEqual(file.body.isSensitive, false); - - const nsfwfile = await uploadFile(alice); - - assert.strictEqual(nsfwfile.status, 200); - assert.strictEqual(nsfwfile.body.isSensitive, true); - - const liftnsfw = await api('drive/files/update', { - fileId: nsfwfile.body.id, - isSensitive: false, - }, alice); - - assert.strictEqual(liftnsfw.status, 400); - assert.strictEqual(liftnsfw.body.error.code, 'RESTRICTED_BY_ROLE'); - - const oldaddnsfw = await api('drive/files/update', { - fileId: file.body.id, - isSensitive: true, - }, alice); - - assert.strictEqual(oldaddnsfw.status, 200); - - await api('admin/roles/unassign', { - userId: alice.id, - roleId: res.body.id, - }); - - await api('admin/roles/delete', { - roleId: res.body.id, - }, alice); - }); - }); - - describe('notes/create', () => { - test('投票を添付できる', async () => { - const res = await api('/notes/create', { - text: 'test', - poll: { - choices: ['foo', 'bar'], - }, - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.createdNote.poll != null, true); - }); - - test('投票の選択肢が無くて怒られる', async () => { - const res = await api('/notes/create', { - poll: {}, - }, alice); - assert.strictEqual(res.status, 400); - }); - - test('投票の選択肢が無くて怒られる (空の配列)', async () => { - const res = await api('/notes/create', { - poll: { - choices: [], - }, - }, alice); - assert.strictEqual(res.status, 400); - }); - - test('投票の選択肢が1つで怒られる', async () => { - const res = await api('/notes/create', { - poll: { - choices: ['Strawberry Pasta'], - }, - }, alice); - assert.strictEqual(res.status, 400); - }); - - test('投票できる', async () => { - const { body } = await api('/notes/create', { - text: 'test', - poll: { - choices: ['sakura', 'izumi', 'ako'], - }, - }, alice); - - const res = await api('/notes/polls/vote', { - noteId: body.createdNote.id, - choice: 1, - }, alice); - - assert.strictEqual(res.status, 204); - }); - - test('複数投票できない', async () => { - const { body } = await api('/notes/create', { - text: 'test', - poll: { - choices: ['sakura', 'izumi', 'ako'], - }, - }, alice); - - await api('/notes/polls/vote', { - noteId: body.createdNote.id, - choice: 0, - }, alice); - - const res = await api('/notes/polls/vote', { - noteId: body.createdNote.id, - choice: 2, - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('許可されている場合は複数投票できる', async () => { - const { body } = await api('/notes/create', { - text: 'test', - poll: { - choices: ['sakura', 'izumi', 'ako'], - multiple: true, - }, - }, alice); - - await api('/notes/polls/vote', { - noteId: body.createdNote.id, - choice: 0, - }, alice); - - await api('/notes/polls/vote', { - noteId: body.createdNote.id, - choice: 1, - }, alice); - - const res = await api('/notes/polls/vote', { - noteId: body.createdNote.id, - choice: 2, - }, alice); - - assert.strictEqual(res.status, 204); - }); - - test('締め切られている場合は投票できない', async () => { - const { body } = await api('/notes/create', { - text: 'test', - poll: { - choices: ['sakura', 'izumi', 'ako'], - expiredAfter: 1, - }, - }, alice); - - await new Promise(x => setTimeout(x, 2)); - - const res = await api('/notes/polls/vote', { - noteId: body.createdNote.id, - choice: 1, - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('センシティブな投稿はhomeになる (単語指定)', async () => { - const sensitive = await api('admin/update-meta', { - sensitiveWords: [ - 'test', - ], - }, alice); - - assert.strictEqual(sensitive.status, 204); - - await new Promise(x => setTimeout(x, 2)); - - const note1 = await api('/notes/create', { - text: 'hogetesthuge', - }, alice); - - assert.strictEqual(note1.status, 200); - assert.strictEqual(note1.body.createdNote.visibility, 'home'); - }); - - test('センシティブな投稿はhomeになる (正規表現)', async () => { - const sensitive = await api('admin/update-meta', { - sensitiveWords: [ - '/Test/i', - ], - }, alice); - - assert.strictEqual(sensitive.status, 204); - - const note2 = await api('/notes/create', { - text: 'hogetesthuge', - }, alice); - - assert.strictEqual(note2.status, 200); - assert.strictEqual(note2.body.createdNote.visibility, 'home'); - }); - - test('センシティブな投稿はhomeになる (スペースアンド)', async () => { - const sensitive = await api('admin/update-meta', { - sensitiveWords: [ - 'Test hoge', - ], - }, alice); - - assert.strictEqual(sensitive.status, 204); - - const note2 = await api('/notes/create', { - text: 'hogeTesthuge', - }, alice); - - assert.strictEqual(note2.status, 200); - assert.strictEqual(note2.body.createdNote.visibility, 'home'); - }); - }); - - describe('notes/delete', () => { - test('delete a reply', async () => { - const mainNoteRes = await api('notes/create', { - text: 'main post', - }, alice); - const replyOneRes = await api('notes/create', { - text: 'reply one', - replyId: mainNoteRes.body.createdNote.id, - }, alice); - const replyTwoRes = await api('notes/create', { - text: 'reply two', - replyId: mainNoteRes.body.createdNote.id, - }, alice); - - const deleteOneRes = await api('notes/delete', { - noteId: replyOneRes.body.createdNote.id, - }, alice); - - assert.strictEqual(deleteOneRes.status, 204); - let mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); - assert.strictEqual(mainNote.repliesCount, 1); - - const deleteTwoRes = await api('notes/delete', { - noteId: replyTwoRes.body.createdNote.id, - }, alice); - - assert.strictEqual(deleteTwoRes.status, 204); - mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); - assert.strictEqual(mainNote.repliesCount, 0); - }); - }); -}); diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts deleted file mode 100644 index 3a5e4ebdae..0000000000 --- a/packages/backend/test/e2e/oauth.ts +++ /dev/null @@ -1,964 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -/** - * Basic OAuth tests to make sure the library is correctly integrated to Misskey - * and not regressed by version updates or potential migration to another library. - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { AuthorizationCode, ResourceOwnerPassword, type AuthorizationTokenConfig, ClientCredentials, ModuleOptions } from 'simple-oauth2'; -import pkceChallenge from 'pkce-challenge'; -import { JSDOM } from 'jsdom'; -import Fastify, { type FastifyReply, type FastifyInstance } from 'fastify'; -import { api, port, signup, startServer } from '../utils.js'; -import type * as misskey from 'misskey-js'; -import type { INestApplicationContext } from '@nestjs/common'; - -const host = `http://127.0.0.1:${port}`; - -const clientPort = port + 1; -const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`; - -const basicAuthParams: AuthorizationParamsExtended = { - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', -}; - -interface AuthorizationParamsExtended { - redirect_uri: string; - scope: string | string[]; - state: string; - code_challenge?: string; - code_challenge_method?: string; -} - -interface AuthorizationTokenConfigExtended extends AuthorizationTokenConfig { - code_verifier: string | undefined; -} - -interface GetTokenError { - data: { - payload: { - error: string; - } - } -} - -const clientConfig: ModuleOptions<'client_id'> = { - client: { - id: `http://127.0.0.1:${clientPort}/`, - secret: '', - }, - auth: { - tokenHost: host, - tokenPath: '/oauth/token', - authorizePath: '/oauth/authorize', - }, - options: { - authorizationMethod: 'body', - }, -}; - -function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined } { - const fragment = JSDOM.fragment(html); - return { - transactionId: fragment.querySelector('meta[name="misskey:oauth:transaction-id"]')?.content, - clientName: fragment.querySelector('meta[name="misskey:oauth:client-name"]')?.content, - }; -} - -function fetchDecision(transactionId: string, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise { - return fetch(new URL('/oauth/decision', host), { - method: 'post', - body: new URLSearchParams({ - transaction_id: transactionId, - login_token: user.token, - cancel: cancel ? 'cancel' : '', - }), - redirect: 'manual', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - }, - }); -} - -async function fetchDecisionFromResponse(response: Response, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise { - const { transactionId } = getMeta(await response.text()); - assert.ok(transactionId); - - return await fetchDecision(transactionId, user, { cancel }); -} - -async function fetchAuthorizationCode(user: misskey.entities.MeSignup, scope: string, code_challenge: string): Promise<{ client: AuthorizationCode, code: string }> { - const client = new AuthorizationCode(clientConfig); - - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope, - state: 'state', - code_challenge, - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - - const decisionResponse = await fetchDecisionFromResponse(response, user); - assert.strictEqual(decisionResponse.status, 302); - - const locationHeader = decisionResponse.headers.get('location'); - assert.ok(locationHeader); - - const location = new URL(locationHeader); - assert.ok(location.searchParams.has('code')); - - const code = new URL(location).searchParams.get('code'); - assert.ok(code); - - return { client, code }; -} - -function assertIndirectError(response: Response, error: string): void { - assert.strictEqual(response.status, 302); - - const locationHeader = response.headers.get('location'); - assert.ok(locationHeader); - - const location = new URL(locationHeader); - assert.strictEqual(location.searchParams.get('error'), error); - - // https://datatracker.ietf.org/doc/html/rfc9207#name-response-parameter-iss - assert.strictEqual(location.searchParams.get('iss'), 'http://misskey.local'); - // https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2.1 - assert.ok(location.searchParams.has('state')); -} - -async function assertDirectError(response: Response, status: number, error: string): Promise { - assert.strictEqual(response.status, status); - - const data = await response.json(); - assert.strictEqual(data.error, error); -} - -describe('OAuth', () => { - let app: INestApplicationContext; - let fastify: FastifyInstance; - - let alice: misskey.entities.MeSignup; - let bob: misskey.entities.MeSignup; - - let sender: (reply: FastifyReply) => void; - - beforeAll(async () => { - app = await startServer(); - alice = await signup({ username: 'alice' }); - bob = await signup({ username: 'bob' }); - - fastify = Fastify(); - fastify.get('/', async (request, reply) => { - sender(reply); - }); - await fastify.listen({ port: clientPort }); - }, 1000 * 60 * 2); - - beforeEach(async () => { - process.env.MISSKEY_TEST_CHECK_IP_RANGE = ''; - sender = (reply): void => { - reply.send(` - - -
Misklient - `); - }; - }); - - afterAll(async () => { - await fastify.close(); - await app.close(); - }); - - test('Full flow', async () => { - const { code_challenge, code_verifier } = await pkceChallenge(128); - - const client = new AuthorizationCode(clientConfig); - - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge, - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - - const meta = getMeta(await response.text()); - assert.strictEqual(typeof meta.transactionId, 'string'); - assert.ok(meta.transactionId); - assert.strictEqual(meta.clientName, 'Misklient'); - - const decisionResponse = await fetchDecision(meta.transactionId, alice); - assert.strictEqual(decisionResponse.status, 302); - assert.ok(decisionResponse.headers.has('location')); - - const locationHeader = decisionResponse.headers.get('location'); - assert.ok(locationHeader); - - const location = new URL(locationHeader); - assert.strictEqual(location.origin + location.pathname, redirect_uri); - assert.ok(location.searchParams.has('code')); - assert.strictEqual(location.searchParams.get('state'), 'state'); - // https://datatracker.ietf.org/doc/html/rfc9207#name-response-parameter-iss - assert.strictEqual(location.searchParams.get('iss'), 'http://misskey.local'); - - const code = new URL(location).searchParams.get('code'); - assert.ok(code); - - const token = await client.getToken({ - code, - redirect_uri, - code_verifier, - } as AuthorizationTokenConfigExtended); - assert.strictEqual(typeof token.token.access_token, 'string'); - assert.strictEqual(token.token.token_type, 'Bearer'); - assert.strictEqual(token.token.scope, 'write:notes'); - - const createResult = await api('notes/create', { text: 'test' }, { - token: token.token.access_token as string, - bearer: true, - }); - assert.strictEqual(createResult.status, 200); - - const createResultBody = createResult.body as misskey.Endpoints['notes/create']['res']; - assert.strictEqual(createResultBody.createdNote.text, 'test'); - }); - - test('Two concurrent flows', async () => { - const client = new AuthorizationCode(clientConfig); - - const pkceAlice = await pkceChallenge(128); - const pkceBob = await pkceChallenge(128); - - const responseAlice = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: pkceAlice.code_challenge, - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(responseAlice.status, 200); - - const responseBob = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: pkceBob.code_challenge, - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(responseBob.status, 200); - - const decisionResponseAlice = await fetchDecisionFromResponse(responseAlice, alice); - assert.strictEqual(decisionResponseAlice.status, 302); - - const decisionResponseBob = await fetchDecisionFromResponse(responseBob, bob); - assert.strictEqual(decisionResponseBob.status, 302); - - const locationHeaderAlice = decisionResponseAlice.headers.get('location'); - assert.ok(locationHeaderAlice); - const locationAlice = new URL(locationHeaderAlice); - - const locationHeaderBob = decisionResponseBob.headers.get('location'); - assert.ok(locationHeaderBob); - const locationBob = new URL(locationHeaderBob); - - const codeAlice = locationAlice.searchParams.get('code'); - assert.ok(codeAlice); - const codeBob = locationBob.searchParams.get('code'); - assert.ok(codeBob); - - const tokenAlice = await client.getToken({ - code: codeAlice, - redirect_uri, - code_verifier: pkceAlice.code_verifier, - } as AuthorizationTokenConfigExtended); - - const tokenBob = await client.getToken({ - code: codeBob, - redirect_uri, - code_verifier: pkceBob.code_verifier, - } as AuthorizationTokenConfigExtended); - - const createResultAlice = await api('notes/create', { text: 'test' }, { - token: tokenAlice.token.access_token as string, - bearer: true, - }); - assert.strictEqual(createResultAlice.status, 200); - - const createResultBob = await api('notes/create', { text: 'test' }, { - token: tokenBob.token.access_token as string, - bearer: true, - }); - assert.strictEqual(createResultAlice.status, 200); - - const createResultBodyAlice = await createResultAlice.body as misskey.Endpoints['notes/create']['res']; - assert.strictEqual(createResultBodyAlice.createdNote.user.username, 'alice'); - - const createResultBodyBob = await createResultBob.body as misskey.Endpoints['notes/create']['res']; - assert.strictEqual(createResultBodyBob.createdNote.user.username, 'bob'); - }); - - // https://datatracker.ietf.org/doc/html/rfc7636.html - describe('PKCE', () => { - // https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.4.1 - // '... the authorization endpoint MUST return the authorization - // error response with the "error" value set to "invalid_request".' - test('Require PKCE', async () => { - const client = new AuthorizationCode(clientConfig); - - // Pattern 1: No PKCE fields at all - let response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - }), { redirect: 'manual' }); - assertIndirectError(response, 'invalid_request'); - - // Pattern 2: Only code_challenge - response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - } as AuthorizationParamsExtended), { redirect: 'manual' }); - assertIndirectError(response, 'invalid_request'); - - // Pattern 3: Only code_challenge_method - response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended), { redirect: 'manual' }); - assertIndirectError(response, 'invalid_request'); - - // Pattern 4: Unsupported code_challenge_method - response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'SSSS', - } as AuthorizationParamsExtended), { redirect: 'manual' }); - assertIndirectError(response, 'invalid_request'); - }); - - // Use precomputed challenge/verifier set here for deterministic test - const code_challenge = '4w2GDuvaxXlw2l46k5PFIoIcTGHdzw2i3hrn-C_Q6f7u0-nTYKd-beVEYy9XinYsGtAix.Nnvr.GByD3lAii2ibPRsSDrZgIN0YQb.kfevcfR9aDKoTLyOUm4hW4ABhs'; - const code_verifier = 'Ew8VSBiH59JirLlg7ocFpLQ6NXuFC1W_rn8gmRzBKc8'; - - const tests: Record = { - 'Code followed by some junk code': code_verifier + 'x', - 'Clipped code': code_verifier.slice(0, 80), - 'Some part of code is replaced': code_verifier.slice(0, -10) + 'x'.repeat(10), - 'No verifier': undefined, - }; - - describe('Verify PKCE', () => { - for (const [title, wrong_verifier] of Object.entries(tests)) { - test(title, async () => { - const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); - - await assert.rejects(client.getToken({ - code, - redirect_uri, - code_verifier: wrong_verifier, - } as AuthorizationTokenConfigExtended), (err: GetTokenError) => { - assert.strictEqual(err.data.payload.error, 'invalid_grant'); - return true; - }); - }); - } - }); - }); - - // https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2 - // "If an authorization code is used more than once, the authorization server - // MUST deny the request and SHOULD revoke (when possible) all tokens - // previously issued based on that authorization code." - describe('Revoking authorization code', () => { - test('On success', async () => { - const { code_challenge, code_verifier } = await pkceChallenge(128); - const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); - - await client.getToken({ - code, - redirect_uri, - code_verifier, - } as AuthorizationTokenConfigExtended); - - await assert.rejects(client.getToken({ - code, - redirect_uri, - code_verifier, - } as AuthorizationTokenConfigExtended), (err: GetTokenError) => { - assert.strictEqual(err.data.payload.error, 'invalid_grant'); - return true; - }); - }); - - test('On failure', async () => { - const { code_challenge, code_verifier } = await pkceChallenge(128); - const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); - - await assert.rejects(client.getToken({ code, redirect_uri }), (err: GetTokenError) => { - assert.strictEqual(err.data.payload.error, 'invalid_grant'); - return true; - }); - - await assert.rejects(client.getToken({ - code, - redirect_uri, - code_verifier, - } as AuthorizationTokenConfigExtended), (err: GetTokenError) => { - assert.strictEqual(err.data.payload.error, 'invalid_grant'); - return true; - }); - }); - - test('Revoke the already granted access token', async () => { - const { code_challenge, code_verifier } = await pkceChallenge(128); - const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); - - const token = await client.getToken({ - code, - redirect_uri, - code_verifier, - } as AuthorizationTokenConfigExtended); - - const createResult = await api('notes/create', { text: 'test' }, { - token: token.token.access_token as string, - bearer: true, - }); - assert.strictEqual(createResult.status, 200); - - await assert.rejects(client.getToken({ - code, - redirect_uri, - code_verifier, - } as AuthorizationTokenConfigExtended), (err: GetTokenError) => { - assert.strictEqual(err.data.payload.error, 'invalid_grant'); - return true; - }); - - const createResult2 = await api('notes/create', { text: 'test' }, { - token: token.token.access_token as string, - bearer: true, - }); - assert.strictEqual(createResult2.status, 401); - }); - }); - - test('Cancellation', async () => { - const client = new AuthorizationCode(clientConfig); - - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - - const decisionResponse = await fetchDecisionFromResponse(response, alice, { cancel: true }); - assert.strictEqual(decisionResponse.status, 302); - - const locationHeader = decisionResponse.headers.get('location'); - assert.ok(locationHeader); - - const location = new URL(locationHeader); - assert.ok(!location.searchParams.has('code')); - assert.ok(location.searchParams.has('error')); - }); - - // https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.3 - describe('Scope', () => { - // "If the client omits the scope parameter when requesting - // authorization, the authorization server MUST either process the - // request using a pre-defined default value or fail the request - // indicating an invalid scope." - // (And Misskey does the latter) - test('Missing scope', async () => { - const client = new AuthorizationCode(clientConfig); - - const response = await fetch(client.authorizeURL({ - redirect_uri, - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended), { redirect: 'manual' }); - assertIndirectError(response, 'invalid_scope'); - }); - - test('Empty scope', async () => { - const client = new AuthorizationCode(clientConfig); - - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: '', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended), { redirect: 'manual' }); - assertIndirectError(response, 'invalid_scope'); - }); - - test('Unknown scopes', async () => { - const client = new AuthorizationCode(clientConfig); - - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'test:unknown test:unknown2', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended), { redirect: 'manual' }); - assertIndirectError(response, 'invalid_scope'); - }); - - // "If the issued access token scope - // is different from the one requested by the client, the authorization - // server MUST include the "scope" response parameter to inform the - // client of the actual scope granted." - // (Although Misskey always return scope, which is also fine) - test('Partially known scopes', async () => { - const { code_challenge, code_verifier } = await pkceChallenge(128); - - // Just get the known scope for this case for backward compatibility - const { client, code } = await fetchAuthorizationCode( - alice, - 'write:notes test:unknown test:unknown2', - code_challenge, - ); - - const token = await client.getToken({ - code, - redirect_uri, - code_verifier, - } as AuthorizationTokenConfigExtended); - - assert.strictEqual(token.token.scope, 'write:notes'); - }); - - test('Known scopes', async () => { - const client = new AuthorizationCode(clientConfig); - - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes read:account', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - - assert.strictEqual(response.status, 200); - }); - - test('Duplicated scopes', async () => { - const { code_challenge, code_verifier } = await pkceChallenge(128); - - const { client, code } = await fetchAuthorizationCode( - alice, - 'write:notes write:notes read:account read:account', - code_challenge, - ); - - const token = await client.getToken({ - code, - redirect_uri, - code_verifier, - } as AuthorizationTokenConfigExtended); - assert.strictEqual(token.token.scope, 'write:notes read:account'); - }); - - test('Scope check by API', async () => { - const { code_challenge, code_verifier } = await pkceChallenge(128); - - const { client, code } = await fetchAuthorizationCode(alice, 'read:account', code_challenge); - - const token = await client.getToken({ - code, - redirect_uri, - code_verifier, - } as AuthorizationTokenConfigExtended); - assert.strictEqual(typeof token.token.access_token, 'string'); - - const createResult = await api('notes/create', { text: 'test' }, { - token: token.token.access_token as string, - bearer: true, - }); - assert.strictEqual(createResult.status, 403); - assert.ok(createResult.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="insufficient_scope", error_description')); - }); - }); - - // https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.1.2.4 - // "If an authorization request fails validation due to a missing, - // invalid, or mismatching redirection URI, the authorization server - // SHOULD inform the resource owner of the error and MUST NOT - // automatically redirect the user-agent to the invalid redirection URI." - describe('Redirection', () => { - test('Invalid redirect_uri at authorization endpoint', async () => { - const client = new AuthorizationCode(clientConfig); - - const response = await fetch(client.authorizeURL({ - redirect_uri: 'http://127.0.0.2/', - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - await assertDirectError(response, 400, 'invalid_request'); - }); - - test('Invalid redirect_uri including the valid one at authorization endpoint', async () => { - const client = new AuthorizationCode(clientConfig); - - const response = await fetch(client.authorizeURL({ - redirect_uri: 'http://127.0.0.1/redirection', - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - await assertDirectError(response, 400, 'invalid_request'); - }); - - test('No redirect_uri at authorization endpoint', async () => { - const client = new AuthorizationCode(clientConfig); - - const response = await fetch(client.authorizeURL({ - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - await assertDirectError(response, 400, 'invalid_request'); - }); - - test('Invalid redirect_uri at token endpoint', async () => { - const { code_challenge, code_verifier } = await pkceChallenge(128); - - const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); - - await assert.rejects(client.getToken({ - code, - redirect_uri: 'http://127.0.0.2/', - code_verifier, - } as AuthorizationTokenConfigExtended), (err: GetTokenError) => { - assert.strictEqual(err.data.payload.error, 'invalid_grant'); - return true; - }); - }); - - test('Invalid redirect_uri including the valid one at token endpoint', async () => { - const { code_challenge, code_verifier } = await pkceChallenge(128); - - const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); - - await assert.rejects(client.getToken({ - code, - redirect_uri: 'http://127.0.0.1/redirection', - code_verifier, - } as AuthorizationTokenConfigExtended), (err: GetTokenError) => { - assert.strictEqual(err.data.payload.error, 'invalid_grant'); - return true; - }); - }); - - test('No redirect_uri at token endpoint', async () => { - const { code_challenge, code_verifier } = await pkceChallenge(128); - - const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge); - - await assert.rejects(client.getToken({ - code, - code_verifier, - } as AuthorizationTokenConfigExtended), (err: GetTokenError) => { - assert.strictEqual(err.data.payload.error, 'invalid_grant'); - return true; - }); - }); - }); - - // https://datatracker.ietf.org/doc/html/rfc8414 - test('Server metadata', async () => { - const response = await fetch(new URL('.well-known/oauth-authorization-server', host)); - assert.strictEqual(response.status, 200); - - const body = await response.json(); - assert.strictEqual(body.issuer, 'http://misskey.local'); - assert.ok(body.scopes_supported.includes('write:notes')); - }); - - // Any error on decision endpoint is solely on Misskey side and nothing to do with the client. - // Do not use indirect error here. - describe('Decision endpoint', () => { - test('No login token', async () => { - const client = new AuthorizationCode(clientConfig); - - const response = await fetch(client.authorizeURL(basicAuthParams)); - assert.strictEqual(response.status, 200); - - const { transactionId } = getMeta(await response.text()); - assert.ok(transactionId); - - const decisionResponse = await fetch(new URL('/oauth/decision', host), { - method: 'post', - body: new URLSearchParams({ - transaction_id: transactionId, - }), - redirect: 'manual', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - }, - }); - await assertDirectError(decisionResponse, 400, 'invalid_request'); - }); - - test('No transaction ID', async () => { - const decisionResponse = await fetch(new URL('/oauth/decision', host), { - method: 'post', - body: new URLSearchParams({ - login_token: alice.token, - }), - redirect: 'manual', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - }, - }); - await assertDirectError(decisionResponse, 400, 'invalid_request'); - }); - - test('Invalid transaction ID', async () => { - const decisionResponse = await fetch(new URL('/oauth/decision', host), { - method: 'post', - body: new URLSearchParams({ - login_token: alice.token, - transaction_id: 'invalid_id', - }), - redirect: 'manual', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - }, - }); - await assertDirectError(decisionResponse, 403, 'access_denied'); - }); - }); - - // Only authorization code grant is supported - describe('Grant type', () => { - test('Implicit grant is not supported', async () => { - const url = new URL('/oauth/authorize', host); - url.searchParams.append('response_type', 'token'); - const response = await fetch(url); - assertDirectError(response, 501, 'unsupported_response_type'); - }); - - test('Resource owner grant is not supported', async () => { - const client = new ResourceOwnerPassword({ - ...clientConfig, - auth: { - tokenHost: host, - tokenPath: '/oauth/token', - }, - }); - - await assert.rejects(client.getToken({ - username: 'alice', - password: 'test', - }), (err: GetTokenError) => { - assert.strictEqual(err.data.payload.error, 'unsupported_grant_type'); - return true; - }); - }); - - test('Client credential grant is not supported', async () => { - const client = new ClientCredentials({ - ...clientConfig, - auth: { - tokenHost: host, - tokenPath: '/oauth/token', - }, - }); - - await assert.rejects(client.getToken({}), (err: GetTokenError) => { - assert.strictEqual(err.data.payload.error, 'unsupported_grant_type'); - return true; - }); - }); - }); - - // https://indieauth.spec.indieweb.org/#client-information-discovery - describe('Client Information Discovery', () => { - describe('Redirection', () => { - const tests: Record void> = { - 'Read HTTP header': reply => { - reply.header('Link', '; rel="redirect_uri"'); - reply.send(` - -
Misklient - `); - }, - 'Mixed links': reply => { - reply.header('Link', '; rel="redirect_uri"'); - reply.send(` - - -
Misklient - `); - }, - 'Multiple items in Link header': reply => { - reply.header('Link', '; rel="redirect_uri",; rel="redirect_uri"'); - reply.send(` - -
Misklient - `); - }, - 'Multiple items in HTML': reply => { - reply.send(` - - - -
Misklient - `); - }, - }; - - for (const [title, replyFunc] of Object.entries(tests)) { - test(title, async () => { - sender = replyFunc; - - const client = new AuthorizationCode(clientConfig); - - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - }); - } - - test('No item', async () => { - sender = (reply): void => { - reply.send(` - -
Misklient - `); - }; - - const client = new AuthorizationCode(clientConfig); - - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - - // direct error because there's no redirect URI to ping - await assertDirectError(response, 400, 'invalid_request'); - }); - }); - - test('Disallow loopback', async () => { - process.env.MISSKEY_TEST_CHECK_IP_RANGE = '1'; - - const client = new AuthorizationCode(clientConfig); - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - await assertDirectError(response, 400, 'invalid_request'); - }); - - test('Missing name', async () => { - sender = (reply): void => { - reply.header('Link', '; rel="redirect_uri"'); - reply.send(); - }; - - const client = new AuthorizationCode(clientConfig); - - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`); - }); - - test('Mismatching URL in h-app', async () => { - sender = (reply): void => { - reply.header('Link', '; rel="redirect_uri"'); - reply.send(` - -
Misklient - `); - reply.send(); - }; - - const client = new AuthorizationCode(clientConfig); - - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`); - }); - }); - - test('Unknown OAuth endpoint', async () => { - const response = await fetch(new URL('/oauth/foo', host)); - assert.strictEqual(response.status, 404); - }); - - describe('CORS', () => { - test('Token endpoint should support CORS', async () => { - const response = await fetch(new URL('/oauth/token', host), { method: 'POST' }); - assert.ok(!response.ok); - assert.strictEqual(response.headers.get('Access-Control-Allow-Origin'), '*'); - }); - - test('Authorize endpoint should not support CORS', async () => { - const response = await fetch(new URL('/oauth/authorize', host), { method: 'GET' }); - assert.ok(!response.ok); - assert.ok(!response.headers.has('Access-Control-Allow-Origin')); - }); - - test('Decision endpoint should not support CORS', async () => { - const response = await fetch(new URL('/oauth/decision', host), { method: 'POST' }); - assert.ok(!response.ok); - assert.ok(!response.headers.has('Access-Control-Allow-Origin')); - }); - }); -}); diff --git a/packages/backend/test/e2e/renote-mute.ts b/packages/backend/test/e2e/renote-mute.ts deleted file mode 100644 index 7d57ba17b6..0000000000 --- a/packages/backend/test/e2e/renote-mute.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { signup, api, post, react, startServer, waitFire, sleep } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; -import type * as misskey from 'misskey-js'; - -describe('Renote Mute', () => { - let app: INestApplicationContext; - - // alice mutes carol - let alice: misskey.entities.MeSignup; - let bob: misskey.entities.MeSignup; - let carol: misskey.entities.MeSignup; - - beforeAll(async () => { - app = await startServer(); - alice = await signup({ username: 'alice' }); - bob = await signup({ username: 'bob' }); - carol = await signup({ username: 'carol' }); - }, 1000 * 60 * 2); - - afterAll(async () => { - await app.close(); - }); - - test('ミュート作成', async () => { - const res = await api('/renote-mute/create', { - userId: carol.id, - }, alice); - - assert.strictEqual(res.status, 204); - }); - - test('タイムラインにリノートミュートしているユーザーのリノートが含まれない', async () => { - const bobNote = await post(bob, { text: 'hi' }); - const carolRenote = await post(carol, { renoteId: bobNote.id }); - const carolNote = await post(carol, { text: 'hi' }); - - // redisに追加されるのを待つ - await sleep(100); - - const res = await api('/notes/local-timeline', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); - }); - - test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => { - const bobNote = await post(bob, { text: 'hi' }); - const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' }); - const carolNote = await post(carol, { text: 'hi' }); - - // redisに追加されるのを待つ - await sleep(100); - - const res = await api('/notes/local-timeline', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); - }); - - test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => { - const bobNote = await post(bob, { text: 'hi' }); - - const fired = await waitFire( - alice, 'localTimeline', - () => api('notes/create', { renoteId: bobNote.id }, carol), - msg => msg.type === 'note' && msg.body.userId === carol.id, - ); - - assert.strictEqual(fired, false); - }); - - test('ストリームにリノートミュートしているユーザーの引用が流れる', async () => { - const bobNote = await post(bob, { text: 'hi' }); - - const fired = await waitFire( - alice, 'localTimeline', - () => api('notes/create', { renoteId: bobNote.id, text: 'kore' }, carol), - msg => msg.type === 'note' && msg.body.userId === carol.id, - ); - - assert.strictEqual(fired, true); - }); -}); diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts deleted file mode 100644 index 288c54bdbc..0000000000 --- a/packages/backend/test/e2e/streaming.ts +++ /dev/null @@ -1,746 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { WebSocket } from 'ws'; -import { MiFollowing } from '@/models/Following.js'; -import { signup, api, post, startServer, initTestDb, waitFire, createAppToken, port } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; -import type * as misskey from 'misskey-js'; - -describe('Streaming', () => { - let app: INestApplicationContext; - let Followings: any; - - const follow = async (follower: any, followee: any) => { - await Followings.save({ - id: 'a', - followerId: follower.id, - followeeId: followee.id, - followerHost: follower.host, - followerInbox: null, - followerSharedInbox: null, - followeeHost: followee.host, - followeeInbox: null, - followeeSharedInbox: null, - }); - }; - - describe('Streaming', () => { - // Local users - let ayano: misskey.entities.MeSignup; - let kyoko: misskey.entities.MeSignup; - let chitose: misskey.entities.MeSignup; - let kanako: misskey.entities.MeSignup; - - // Remote users - let akari: misskey.entities.MeSignup; - let chinatsu: misskey.entities.MeSignup; - let takumi: misskey.entities.MeSignup; - - let kyokoNote: any; - let kanakoNote: any; - let takumiNote: any; - let list: any; - - beforeAll(async () => { - app = await startServer(); - const connection = await initTestDb(true); - Followings = connection.getRepository(MiFollowing); - - ayano = await signup({ username: 'ayano' }); - kyoko = await signup({ username: 'kyoko' }); - chitose = await signup({ username: 'chitose' }); - kanako = await signup({ username: 'kanako' }); - - akari = await signup({ username: 'akari', host: 'example.com' }); - chinatsu = await signup({ username: 'chinatsu', host: 'example.com' }); - takumi = await signup({ username: 'takumi', host: 'example.com' }); - - kyokoNote = await post(kyoko, { text: 'foo' }); - kanakoNote = await post(kanako, { text: 'hoge' }); - takumiNote = await post(takumi, { text: 'piyo' }); - - // Follow: ayano => kyoko - await api('following/create', { userId: kyoko.id }, ayano); - - // Follow: ayano => akari - await follow(ayano, akari); - - // Mute: chitose => kanako - await api('mute/create', { userId: kanako.id }, chitose); - - // List: chitose => ayano, kyoko - list = await api('users/lists/create', { - name: 'my list', - }, chitose).then(x => x.body); - - await api('users/lists/push', { - listId: list.id, - userId: ayano.id, - }, chitose); - - await api('users/lists/push', { - listId: list.id, - userId: kyoko.id, - }, chitose); - - await api('users/lists/push', { - listId: list.id, - userId: takumi.id, - }, chitose); - }, 1000 * 60 * 2); - - afterAll(async () => { - await app.close(); - }); - - describe('Events', () => { - test('mention event', async () => { - const fired = await waitFire( - kyoko, 'main', // kyoko:main - () => post(ayano, { text: 'foo @kyoko bar' }), // ayano mention => kyoko - msg => msg.type === 'mention' && msg.body.userId === ayano.id, // wait ayano - ); - - assert.strictEqual(fired, true); - }); - - test('renote event', async () => { - const fired = await waitFire( - kyoko, 'main', // kyoko:main - () => post(ayano, { renoteId: kyokoNote.id }), // ayano renote - msg => msg.type === 'renote' && msg.body.renoteId === kyokoNote.id, // wait renote - ); - - assert.strictEqual(fired, true); - }); - }); - - describe('Home Timeline', () => { - test('自分の投稿が流れる', async () => { - const fired = await waitFire( - ayano, 'homeTimeline', // ayano:Home - () => api('notes/create', { text: 'foo' }, ayano), // ayano posts - msg => msg.type === 'note' && msg.body.text === 'foo', - ); - - assert.strictEqual(fired, true); - }); - - test('自分の visibility: followers な投稿が流れる', async () => { - const fired = await waitFire( - ayano, 'homeTimeline', // ayano:Home - () => api('notes/create', { text: 'foo', visibility: 'followers' }, ayano), // ayano posts - msg => msg.type === 'note' && msg.body.text === 'foo', - ); - - assert.strictEqual(fired, true); - }); - - test('フォローしているユーザーの投稿が流れる', async () => { - const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home - () => api('notes/create', { text: 'foo' }, kyoko), // kyoko posts - msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko - ); - - assert.strictEqual(fired, true); - }); - - test('フォローしているユーザーの visibility: followers な投稿が流れる', async () => { - const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home - () => api('notes/create', { text: 'foo', visibility: 'followers' }, kyoko), // kyoko posts - msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko - ); - - assert.strictEqual(fired, true); - }); - - /* なんか失敗する - test('フォローしているユーザーの visibility: followers な投稿への返信が流れる', async () => { - const note = await api('notes/create', { text: 'foo', visibility: 'followers' }, kyoko); - - const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home - () => api('notes/create', { text: 'bar', visibility: 'followers', replyId: note.body.id }, kyoko), // kyoko posts - msg => msg.type === 'note' && msg.body.userId === kyoko.id && msg.body.reply.text === 'foo', - ); - - assert.strictEqual(fired, true); - }); - */ - - test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信が流れない', async () => { - // TODO - }); - - test('フォローしていないユーザーの投稿は流れない', async () => { - const fired = await waitFire( - kyoko, 'homeTimeline', // kyoko:home - () => api('notes/create', { text: 'foo' }, ayano), // ayano posts - msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano - ); - - assert.strictEqual(fired, false); - }); - - test('フォローしているユーザーのダイレクト投稿が流れる', async () => { - const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home - () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [ayano.id] }, kyoko), // kyoko dm => ayano - msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko - ); - - assert.strictEqual(fired, true); - }); - - test('フォローしているユーザーでも自分が指定されていないダイレクト投稿は流れない', async () => { - const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home - () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [chitose.id] }, kyoko), // kyoko dm => chitose - msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko - ); - - assert.strictEqual(fired, false); - }); - }); // Home - - describe('Local Timeline', () => { - test('自分の投稿が流れる', async () => { - const fired = await waitFire( - ayano, 'localTimeline', // ayano:Local - () => api('notes/create', { text: 'foo' }, ayano), // ayano posts - msg => msg.type === 'note' && msg.body.text === 'foo', - ); - - assert.strictEqual(fired, true); - }); - - test('フォローしていないローカルユーザーの投稿が流れる', async () => { - const fired = await waitFire( - ayano, 'localTimeline', // ayano:Local - () => api('notes/create', { text: 'foo' }, chitose), // chitose posts - msg => msg.type === 'note' && msg.body.userId === chitose.id, // wait chitose - ); - - assert.strictEqual(fired, true); - }); - - /* TODO - test('リモートユーザーの投稿は流れない', async () => { - const fired = await waitFire( - ayano, 'localTimeline', // ayano:Local - () => api('notes/create', { text: 'foo' }, chinatsu), // chinatsu posts - msg => msg.type === 'note' && msg.body.userId === chinatsu.id, // wait chinatsu - ); - - assert.strictEqual(fired, false); - }); - - test('フォローしてたとしてもリモートユーザーの投稿は流れない', async () => { - const fired = await waitFire( - ayano, 'localTimeline', // ayano:Local - () => api('notes/create', { text: 'foo' }, akari), // akari posts - msg => msg.type === 'note' && msg.body.userId === akari.id, // wait akari - ); - - assert.strictEqual(fired, false); - }); - */ - - test('ホーム指定の投稿は流れない', async () => { - const fired = await waitFire( - ayano, 'localTimeline', // ayano:Local - () => api('notes/create', { text: 'foo', visibility: 'home' }, kyoko), // kyoko home posts - msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko - ); - - assert.strictEqual(fired, false); - }); - - test('フォローしているローカルユーザーのダイレクト投稿は流れない', async () => { - const fired = await waitFire( - ayano, 'localTimeline', // ayano:Local - () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [ayano.id] }, kyoko), // kyoko DM => ayano - msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko - ); - - assert.strictEqual(fired, false); - }); - - test('フォローしていないローカルユーザーのフォロワー宛て投稿は流れない', async () => { - const fired = await waitFire( - ayano, 'localTimeline', // ayano:Local - () => api('notes/create', { text: 'foo', visibility: 'followers' }, chitose), - msg => msg.type === 'note' && msg.body.userId === chitose.id, // wait chitose - ); - - assert.strictEqual(fired, false); - }); - }); - - describe('Hybrid Timeline', () => { - test('自分の投稿が流れる', async () => { - const fired = await waitFire( - ayano, 'hybridTimeline', // ayano:Hybrid - () => api('notes/create', { text: 'foo' }, ayano), // ayano posts - msg => msg.type === 'note' && msg.body.text === 'foo', - ); - - assert.strictEqual(fired, true); - }); - - test('自分の visibility: followers な投稿が流れる', async () => { - const fired = await waitFire( - ayano, 'hybridTimeline', - () => api('notes/create', { text: 'foo', visibility: 'followers' }, ayano), // ayano posts - msg => msg.type === 'note' && msg.body.text === 'foo', - ); - - assert.strictEqual(fired, true); - }); - - test('フォローしていないローカルユーザーの投稿が流れる', async () => { - const fired = await waitFire( - ayano, 'hybridTimeline', // ayano:Hybrid - () => api('notes/create', { text: 'foo' }, chitose), // chitose posts - msg => msg.type === 'note' && msg.body.userId === chitose.id, // wait chitose - ); - - assert.strictEqual(fired, true); - }); - - /* TODO - test('フォローしているリモートユーザーの投稿が流れる', async () => { - const fired = await waitFire( - ayano, 'hybridTimeline', // ayano:Hybrid - () => api('notes/create', { text: 'foo' }, akari), // akari posts - msg => msg.type === 'note' && msg.body.userId === akari.id, // wait akari - ); - - assert.strictEqual(fired, true); - }); - - test('フォローしていないリモートユーザーの投稿は流れない', async () => { - const fired = await waitFire( - ayano, 'hybridTimeline', // ayano:Hybrid - () => api('notes/create', { text: 'foo' }, chinatsu), // chinatsu posts - msg => msg.type === 'note' && msg.body.userId === chinatsu.id, // wait chinatsu - ); - - assert.strictEqual(fired, false); - }); - */ - - test('フォローしているユーザーのダイレクト投稿が流れる', async () => { - const fired = await waitFire( - ayano, 'hybridTimeline', // ayano:Hybrid - () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [ayano.id] }, kyoko), - msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko - ); - - assert.strictEqual(fired, true); - }); - - test('フォローしているユーザーのホーム投稿が流れる', async () => { - const fired = await waitFire( - ayano, 'hybridTimeline', // ayano:Hybrid - () => api('notes/create', { text: 'foo', visibility: 'home' }, kyoko), - msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko - ); - - assert.strictEqual(fired, true); - }); - - test('フォローしているユーザーの visibility: followers な投稿が流れる', async () => { - const fired = await waitFire( - ayano, 'hybridTimeline', // ayano:Hybrid - () => api('notes/create', { text: 'foo', visibility: 'followers' }, kyoko), - msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko - ); - - assert.strictEqual(fired, true); - }); - - test('フォローしていないローカルユーザーのホーム投稿は流れない', async () => { - const fired = await waitFire( - ayano, 'hybridTimeline', // ayano:Hybrid - () => api('notes/create', { text: 'foo', visibility: 'home' }, chitose), - msg => msg.type === 'note' && msg.body.userId === chitose.id, - ); - - assert.strictEqual(fired, false); - }); - - test('フォローしていないローカルユーザーのフォロワー宛て投稿は流れない', async () => { - const fired = await waitFire( - ayano, 'hybridTimeline', // ayano:Hybrid - () => api('notes/create', { text: 'foo', visibility: 'followers' }, chitose), - msg => msg.type === 'note' && msg.body.userId === chitose.id, - ); - - assert.strictEqual(fired, false); - }); - }); - - describe('Global Timeline', () => { - test('フォローしていないローカルユーザーの投稿が流れる', async () => { - const fired = await waitFire( - ayano, 'globalTimeline', // ayano:Global - () => api('notes/create', { text: 'foo' }, chitose), // chitose posts - msg => msg.type === 'note' && msg.body.userId === chitose.id, // wait chitose - ); - - assert.strictEqual(fired, true); - }); - - /* TODO - test('フォローしていないリモートユーザーの投稿が流れる', async () => { - const fired = await waitFire( - ayano, 'globalTimeline', // ayano:Global - () => api('notes/create', { text: 'foo' }, chinatsu), // chinatsu posts - msg => msg.type === 'note' && msg.body.userId === chinatsu.id, // wait chinatsu - ); - - assert.strictEqual(fired, true); - }); - */ - - test('ホーム投稿は流れない', async () => { - const fired = await waitFire( - ayano, 'globalTimeline', // ayano:Global - () => api('notes/create', { text: 'foo', visibility: 'home' }, kyoko), // kyoko posts - msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko - ); - - assert.strictEqual(fired, false); - }); - }); - - describe('UserList Timeline', () => { - test('リストに入れているユーザーの投稿が流れる', async () => { - const fired = await waitFire( - chitose, 'userList', - () => api('notes/create', { text: 'foo' }, ayano), - msg => msg.type === 'note' && msg.body.userId === ayano.id, - { listId: list.id }, - ); - - assert.strictEqual(fired, true); - }); - - test('リストに入れていないユーザーの投稿は流れない', async () => { - const fired = await waitFire( - chitose, 'userList', - () => api('notes/create', { text: 'foo' }, chinatsu), - msg => msg.type === 'note' && msg.body.userId === chinatsu.id, - { listId: list.id }, - ); - - assert.strictEqual(fired, false); - }); - - // #4471 - test('リストに入れているユーザーのダイレクト投稿が流れる', async () => { - const fired = await waitFire( - chitose, 'userList', - () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [chitose.id] }, ayano), - msg => msg.type === 'note' && msg.body.userId === ayano.id, - { listId: list.id }, - ); - - assert.strictEqual(fired, true); - }); - - // #4335 - test('リストに入れているがフォローはしてないユーザーのフォロワー宛て投稿は流れない', async () => { - const fired = await waitFire( - chitose, 'userList', - () => api('notes/create', { text: 'foo', visibility: 'followers' }, kyoko), - msg => msg.type === 'note' && msg.body.userId === kyoko.id, - { listId: list.id }, - ); - - assert.strictEqual(fired, false); - }); - - // #10443 - test('チャンネル投稿は流れない', async () => { - // リスインしている kyoko が 任意のチャンネルに投降した時の動きを見たい - const fired = await waitFire( - chitose, 'userList', - () => api('notes/create', { text: 'foo', channelId: 'dummy' }, kyoko), - msg => msg.type === 'note' && msg.body.userId === kyoko.id, - { listId: list.id }, - ); - - assert.strictEqual(fired, false); - }); - - // #10443 - test('ミュートしているユーザへのリプライがリストTLに流れない', async () => { - // chitose が kanako をミュートしている状態で、リスインしている kyoko が kanako にリプライした時の動きを見たい - const fired = await waitFire( - chitose, 'userList', - () => api('notes/create', { text: 'foo', replyId: kanakoNote.id }, kyoko), - msg => msg.type === 'note' && msg.body.userId === kyoko.id, - { listId: list.id }, - ); - - assert.strictEqual(fired, false); - }); - - // #10443 - test('ミュートしているユーザの投稿をリノートしたときリストTLに流れない', async () => { - // chitose が kanako をミュートしている状態で、リスインしている kyoko が kanako のノートをリノートした時の動きを見たい - const fired = await waitFire( - chitose, 'userList', - () => api('notes/create', { renoteId: kanakoNote.id }, kyoko), - msg => msg.type === 'note' && msg.body.userId === kyoko.id, - { listId: list.id }, - ); - - assert.strictEqual(fired, false); - }); - - // #10443 - test('ミュートしているサーバのノートがリストTLに流れない', async () => { - await api('/i/update', { - mutedInstances: ['example.com'], - }, chitose); - - // chitose が example.com をミュートしている状態で、リスインしている takumi が ノートした時の動きを見たい - const fired = await waitFire( - chitose, 'userList', - () => api('notes/create', { text: 'foo' }, takumi), - msg => msg.type === 'note' && msg.body.userId === kyoko.id, - { listId: list.id }, - ); - - assert.strictEqual(fired, false); - }); - - // #10443 - test('ミュートしているサーバのノートに対するリプライがリストTLに流れない', async () => { - await api('/i/update', { - mutedInstances: ['example.com'], - }, chitose); - - // chitose が example.com をミュートしている状態で、リスインしている kyoko が takumi のノートにリプライした時の動きを見たい - const fired = await waitFire( - chitose, 'userList', - () => api('notes/create', { text: 'foo', replyId: takumiNote.id }, kyoko), - msg => msg.type === 'note' && msg.body.userId === kyoko.id, - { listId: list.id }, - ); - - assert.strictEqual(fired, false); - }); - - // #10443 - test('ミュートしているサーバのノートに対するリノートがリストTLに流れない', async () => { - await api('/i/update', { - mutedInstances: ['example.com'], - }, chitose); - - // chitose が example.com をミュートしている状態で、リスインしている kyoko が takumi のノートをリノートした時の動きを見たい - const fired = await waitFire( - chitose, 'userList', - () => api('notes/create', { renoteId: takumiNote.id }, kyoko), - msg => msg.type === 'note' && msg.body.userId === kyoko.id, - { listId: list.id }, - ); - - assert.strictEqual(fired, false); - }); - }); - - test('Authentication', async () => { - const application = await createAppToken(ayano, []); - const application2 = await createAppToken(ayano, ['read:account']); - const socket = new WebSocket(`ws://127.0.0.1:${port}/streaming?i=${application}`); - const established = await new Promise((resolve, reject) => { - socket.on('error', () => resolve(false)); - socket.on('unexpected-response', () => resolve(false)); - setTimeout(() => resolve(true), 3000); - }); - - socket.close(); - assert.strictEqual(established, false); - - const fired = await waitFire( - { token: application2 }, 'hybridTimeline', - () => api('notes/create', { text: 'Hello, world!' }, ayano), - msg => msg.type === 'note' && msg.body.userId === ayano.id, - ); - - assert.strictEqual(fired, true); - }); - - // XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac" - /* - describe('Hashtag Timeline', () => { - test('指定したハッシュタグの投稿が流れる', () => new Promise(async done => { - const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { - if (type === 'note') { - assert.deepStrictEqual(body.text, '#foo'); - ws.close(); - done(); - } - }, { - q: [ - ['foo'], - ], - }); - - post(chitose, { - text: '#foo', - }); - })); - - test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise(async done => { - let fooCount = 0; - let barCount = 0; - let fooBarCount = 0; - - const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { - if (type === 'note') { - if (body.text === '#foo') fooCount++; - if (body.text === '#bar') barCount++; - if (body.text === '#foo #bar') fooBarCount++; - } - }, { - q: [ - ['foo', 'bar'], - ], - }); - - post(chitose, { - text: '#foo', - }); - - post(chitose, { - text: '#bar', - }); - - post(chitose, { - text: '#foo #bar', - }); - - setTimeout(() => { - assert.strictEqual(fooCount, 0); - assert.strictEqual(barCount, 0); - assert.strictEqual(fooBarCount, 1); - ws.close(); - done(); - }, 3000); - })); - - test('指定したハッシュタグの投稿が流れる (OR)', () => new Promise(async done => { - let fooCount = 0; - let barCount = 0; - let fooBarCount = 0; - let piyoCount = 0; - - const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { - if (type === 'note') { - if (body.text === '#foo') fooCount++; - if (body.text === '#bar') barCount++; - if (body.text === '#foo #bar') fooBarCount++; - if (body.text === '#piyo') piyoCount++; - } - }, { - q: [ - ['foo'], - ['bar'], - ], - }); - - post(chitose, { - text: '#foo', - }); - - post(chitose, { - text: '#bar', - }); - - post(chitose, { - text: '#foo #bar', - }); - - post(chitose, { - text: '#piyo', - }); - - setTimeout(() => { - assert.strictEqual(fooCount, 1); - assert.strictEqual(barCount, 1); - assert.strictEqual(fooBarCount, 1); - assert.strictEqual(piyoCount, 0); - ws.close(); - done(); - }, 3000); - })); - - test('指定したハッシュタグの投稿が流れる (AND + OR)', () => new Promise(async done => { - let fooCount = 0; - let barCount = 0; - let fooBarCount = 0; - let piyoCount = 0; - let waaaCount = 0; - - const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { - if (type === 'note') { - if (body.text === '#foo') fooCount++; - if (body.text === '#bar') barCount++; - if (body.text === '#foo #bar') fooBarCount++; - if (body.text === '#piyo') piyoCount++; - if (body.text === '#waaa') waaaCount++; - } - }, { - q: [ - ['foo', 'bar'], - ['piyo'], - ], - }); - - post(chitose, { - text: '#foo', - }); - - post(chitose, { - text: '#bar', - }); - - post(chitose, { - text: '#foo #bar', - }); - - post(chitose, { - text: '#piyo', - }); - - post(chitose, { - text: '#waaa', - }); - - setTimeout(() => { - assert.strictEqual(fooCount, 0); - assert.strictEqual(barCount, 0); - assert.strictEqual(fooBarCount, 1); - assert.strictEqual(piyoCount, 1); - assert.strictEqual(waaaCount, 0); - ws.close(); - done(); - }, 3000); - })); - }); - */ - }); -}); diff --git a/packages/backend/test/e2e/thread-mute.ts b/packages/backend/test/e2e/thread-mute.ts deleted file mode 100644 index 0e487976dc..0000000000 --- a/packages/backend/test/e2e/thread-mute.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { signup, api, post, connectStream, startServer } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; -import type * as misskey from 'misskey-js'; - -describe('Note thread mute', () => { - let app: INestApplicationContext; - - let alice: misskey.entities.MeSignup; - let bob: misskey.entities.MeSignup; - let carol: misskey.entities.MeSignup; - - beforeAll(async () => { - app = await startServer(); - alice = await signup({ username: 'alice' }); - bob = await signup({ username: 'bob' }); - carol = await signup({ username: 'carol' }); - }, 1000 * 60 * 2); - - afterAll(async () => { - await app.close(); - }); - - test('notes/mentions にミュートしているスレッドの投稿が含まれない', async () => { - const bobNote = await post(bob, { text: '@alice @carol root note' }); - const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); - - await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice); - - const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); - const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' }); - - const res = await api('/notes/mentions', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolReply.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolReplyWithoutMention.id), false); - }); - - test('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async () => { - // 状態リセット - await api('/i/read-all-unread-notes', {}, alice); - - const bobNote = await post(bob, { text: '@alice @carol root note' }); - - await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice); - - const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); - - const res = await api('/i', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(res.body.hasUnreadMentions, false); - }); - - test('ミュートしているスレッドからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise(async done => { - // 状態リセット - await api('/i/read-all-unread-notes', {}, alice); - - const bobNote = await post(bob, { text: '@alice @carol root note' }); - - await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice); - - let fired = false; - - const ws = await connectStream(alice, 'main', async ({ type, body }) => { - if (type === 'unreadMention') { - if (body === bobNote.id) return; - fired = true; - } - }); - - const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); - - setTimeout(() => { - assert.strictEqual(fired, false); - ws.close(); - done(); - }, 5000); - })); - - test('i/notifications にミュートしているスレッドの通知が含まれない', async () => { - const bobNote = await post(bob, { text: '@alice @carol root note' }); - const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); - - await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice); - - const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); - const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' }); - - const res = await api('/i/notifications', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.note.id === carolReply.id), false); - assert.strictEqual(res.body.some((notification: any) => notification.note.id === carolReplyWithoutMention.id), false); - - // NOTE: bobの投稿はスレッドミュート前に行われたため通知に含まれていてもよい - }); -}); diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts deleted file mode 100644 index cb9558b416..0000000000 --- a/packages/backend/test/e2e/timelines.ts +++ /dev/null @@ -1,1271 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// How to run: -// pnpm jest -- e2e/timelines.ts - -process.env.NODE_ENV = 'test'; -process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING = 'true'; - -import * as assert from 'assert'; -import { api, post, randomString, signup, sleep, startServer, uploadUrl } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; - -function genHost() { - return randomString() + '.example.com'; -} - -function waitForPushToTl() { - return sleep(500); -} - -let app: INestApplicationContext; - -beforeAll(async () => { - app = await startServer(); -}, 1000 * 60 * 2); - -afterAll(async () => { - await app.close(); -}); - -describe('Timelines', () => { - describe('Home TL', () => { - test.concurrent('自分の visibility: followers なノートが含まれる', async () => { - const [alice] = await Promise.all([signup()]); - - const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); - }); - - test.concurrent('フォローしているユーザーのノートが含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi' }); - const carolNote = await post(carol, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); - const carolNote = await post(carol, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: false でフォローしているユーザーの他人への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの他人への返信が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await api('/following/update', { userId: bob.id, withReplies: true }, alice); - await sleep(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの他人へのDM返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await api('/following/update', { userId: bob.id, withReplies: true }, alice); - await sleep(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await api('/following/update', { userId: bob.id, withReplies: true }, alice); - await sleep(1000); - const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await api('/following/create', { userId: carol.id }, alice); - await api('/following/create', { userId: carol.id }, bob); - await api('/following/update', { userId: bob.id, withReplies: true }, alice); - await sleep(1000); - const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === carolNote.id).text, 'hi'); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの投稿への visibility: specified な返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await api('/following/create', { userId: carol.id }, alice); - await api('/following/update', { userId: bob.id, withReplies: true }, alice); - await sleep(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); - }); - - test.concurrent('withReplies: false でフォローしているユーザーのそのユーザー自身への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - }); - - test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('自分の他人への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi' }); - const aliceNote = await post(alice, { text: 'hi', replyId: bobNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - }); - - test.concurrent('フォローしているユーザーの他人の投稿のリノートが含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { renoteId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿のリノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { renoteId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { - withRenotes: false, - }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿の引用が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { - withRenotes: false, - }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('フォローしているユーザーの他人への visibility: specified なノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await api('/mute/create', { userId: carol.id }, alice); - await sleep(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await api('/following/update', { userId: bob.id, withReplies: true }, alice); - await api('/mute/create', { userId: carol.id }, alice); - await sleep(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('[withFiles: true] フォローしているユーザーのファイル付きノートのみ含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const [bobFile, carolFile] = await Promise.all([ - uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'), - uploadUrl(carol, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'), - ]); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { fileIds: [bobFile.id] }); - const carolNote1 = await post(carol, { text: 'hi' }); - const carolNote2 = await post(carol, { fileIds: [carolFile.id] }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100, withFiles: true }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote2.id), false); - }, 1000 * 10); - - test.concurrent('フォローしているユーザーのチャンネル投稿が含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const channel = await api('/channels/create', { name: 'channel' }, bob).then(x => x.body); - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - test.concurrent('自分の visibility: specified なノートが含まれる', async () => { - const [alice] = await Promise.all([signup()]); - - const aliceNote = await post(alice, { text: 'hi', visibility: 'specified' }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); - }); - - test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); - }); - - test.concurrent('フォローしていないユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定していない visibility: specified なノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - test.concurrent('フォローしていないユーザーからの visibility: specified なノートに返信したときの自身のノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); - const aliceNote = await post(alice, { text: 'ok', visibility: 'specified', visibleUserIds: [bob.id], replyId: bobNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'ok'); - }); - - /* TODO - test.concurrent('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const aliceNote = await post(alice, { text: 'hi', visibility: 'specified', visibleUserIds: [bob.id] }); - const bobNote = await post(bob, { text: 'ok', visibility: 'specified', visibleUserIds: [alice.id], replyId: aliceNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'ok'); - }); - */ - - // ↑の挙動が理想だけど実装が面倒かも - test.concurrent('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const aliceNote = await post(alice, { text: 'hi', visibility: 'specified', visibleUserIds: [bob.id] }); - const bobNote = await post(bob, { text: 'ok', visibility: 'specified', visibleUserIds: [alice.id], replyId: aliceNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - }); - - describe('Local TL', () => { - test.concurrent('visibility: home なノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const carolNote = await post(carol, { text: 'hi', visibility: 'home' }); - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('/notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('他人の他人への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); - }); - - test.concurrent('他人のその人自身への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); - - await waitForPushToTl(); - - const res = await api('/notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - }); - - test.concurrent('チャンネル投稿が含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const channel = await api('/channels/create', { name: 'channel' }, bob).then(x => x.body); - const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); - - await waitForPushToTl(); - - const res = await api('/notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - test.concurrent('リモートユーザーのノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); - - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('/notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - // 含まれても良いと思うけど実装が面倒なので含まれない - test.concurrent('フォローしているユーザーの visibility: home なノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: carol.id }, alice); - await sleep(1000); - const carolNote = await post(carol, { text: 'hi', visibility: 'home' }); - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('/notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('ミュートしているユーザーのノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/mute/create', { userId: carol.id }, alice); - await sleep(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('/notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await api('/mute/create', { userId: carol.id }, alice); - await sleep(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await api('/following/update', { userId: bob.id, withReplies: true }, alice); - await api('/mute/create', { userId: carol.id }, alice); - await sleep(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('[withReplies: true] 他人の他人への返信が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/local-timeline', { limit: 100, withReplies: true }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { fileIds: [file.id] }); - - await waitForPushToTl(); - - const res = await api('/notes/local-timeline', { limit: 100, withFiles: true }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - }, 1000 * 10); - }); - - describe('Social TL', () => { - test.concurrent('ローカルユーザーのノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('/notes/hybrid-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('ローカルユーザーの visibility: home なノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); - - await waitForPushToTl(); - - const res = await api('/notes/hybrid-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - test.concurrent('フォローしているローカルユーザーの visibility: home なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); - - await waitForPushToTl(); - - const res = await api('/notes/hybrid-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/hybrid-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('他人の他人への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/hybrid-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); - }); - - test.concurrent('リモートユーザーのノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); - - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('/notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('/notes/hybrid-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); - - await waitForPushToTl(); - - const res = await api('/notes/hybrid-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('[withReplies: true] 他人の他人への返信が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/hybrid-timeline', { limit: 100, withReplies: true }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { fileIds: [file.id] }); - - await waitForPushToTl(); - - const res = await api('/notes/hybrid-timeline', { limit: 100, withFiles: true }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - }, 1000 * 10); - }); - - describe('User List TL', () => { - test.concurrent('リスインしているフォローしていないユーザーのノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('リスインしているフォローしていないユーザーの visibility: home なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); - - await waitForPushToTl(); - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); - - await waitForPushToTl(); - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - test.concurrent('リスインしているフォローしていないユーザーの他人への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - test.concurrent('リスインしているフォローしていないユーザーのユーザー自身への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); - - await waitForPushToTl(); - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - }); - - test.concurrent('withReplies: false でリスインしているフォローしていないユーザーからの自分への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/user-list-timeline', { listId: list.id, withReplies: false }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('withReplies: true でリスインしているフォローしていないユーザーの他人への返信が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - await api('/users/lists/update-membership', { listId: list.id, userId: bob.id, withReplies: true }, alice); - await sleep(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('リスインしているフォローしているユーザーの visibility: home なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); - - await waitForPushToTl(); - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('リスインしているフォローしているユーザーの visibility: followers なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); - - await waitForPushToTl(); - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); - }); - - test.concurrent('リスインしている自分の visibility: followers なノートが含まれる', async () => { - const [alice] = await Promise.all([signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: alice.id }, alice); - await sleep(1000); - const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); - - await waitForPushToTl(); - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); - }); - - test.concurrent('リスインしているユーザーのチャンネルノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const channel = await api('/channels/create', { name: 'channel' }, bob).then(x => x.body); - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); - - await waitForPushToTl(); - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - test.concurrent('[withFiles: true] リスインしているユーザーのファイル付きノートのみ含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { fileIds: [file.id] }); - - await waitForPushToTl(); - - const res = await api('/notes/user-list-timeline', { listId: list.id, withFiles: true }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - }, 1000 * 10); - - test.concurrent('リスインしているユーザーの自身宛ての visibility: specified なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); - - await waitForPushToTl(); - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); - }); - - test.concurrent('リスインしているユーザーの自身宛てではない visibility: specified なノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); - await api('/users/lists/push', { listId: list.id, userId: carol.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] }); - - await waitForPushToTl(); - - const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - }); - - describe('User TL', () => { - test.concurrent('ノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('/users/notes', { userId: bob.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('フォローしていないユーザーの visibility: followers なノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); - - await waitForPushToTl(); - - const res = await api('/users/notes', { userId: bob.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); - - await waitForPushToTl(); - - const res = await api('/users/notes', { userId: bob.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); - }); - - test.concurrent('自身の visibility: followers なノートが含まれる', async () => { - const [alice] = await Promise.all([signup()]); - - const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); - - await waitForPushToTl(); - - const res = await api('/users/notes', { userId: alice.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); - }); - - test.concurrent('チャンネル投稿が含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const channel = await api('/channels/create', { name: 'channel' }, bob).then(x => x.body); - const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); - - await waitForPushToTl(); - - const res = await api('/users/notes', { userId: bob.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - test.concurrent('[withReplies: false] 他人への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const carolNote = await post(carol, { text: 'hi' }); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/users/notes', { userId: bob.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), false); - }); - - test.concurrent('[withReplies: true] 他人への返信が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const carolNote = await post(carol, { text: 'hi' }); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/users/notes', { userId: bob.id, withReplies: true }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - }); - - test.concurrent('[withReplies: true] 他人への visibility: specified な返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const carolNote = await post(carol, { text: 'hi' }); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified' }); - - await waitForPushToTl(); - - const res = await api('/users/notes', { userId: bob.id, withReplies: true }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), false); - }); - - test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { fileIds: [file.id] }); - - await waitForPushToTl(); - - const res = await api('/users/notes', { userId: bob.id, withFiles: true }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - }, 1000 * 10); - - test.concurrent('[withChannelNotes: true] チャンネル投稿が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const channel = await api('/channels/create', { name: 'channel' }, bob).then(x => x.body); - const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); - - await waitForPushToTl(); - - const res = await api('/users/notes', { userId: bob.id, withChannelNotes: true }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('[withChannelNotes: true] 他人が取得した場合センシティブチャンネル投稿が含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const channel = await api('/channels/create', { name: 'channel', isSensitive: true }, bob).then(x => x.body); - const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); - - await waitForPushToTl(); - - const res = await api('/users/notes', { userId: bob.id, withChannelNotes: true }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - test.concurrent('[withChannelNotes: true] 自分が取得した場合センシティブチャンネル投稿が含まれる', async () => { - const [bob] = await Promise.all([signup()]); - - const channel = await api('/channels/create', { name: 'channel', isSensitive: true }, bob).then(x => x.body); - const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); - - await waitForPushToTl(); - - const res = await api('/users/notes', { userId: bob.id, withChannelNotes: true }, bob); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - }); - - test.concurrent('ミュートしているユーザーに関連する投稿が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('/mute/create', { userId: carol.id }, alice); - await sleep(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('/users/notes', { userId: bob.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - - test.concurrent('ミュートしていても userId に指定したユーザーの投稿が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('/mute/create', { userId: bob.id }, alice); - await sleep(1000); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); - const bobNote3 = await post(bob, { text: 'hi', renoteId: bobNote1.id }); - - await waitForPushToTl(); - - const res = await api('/users/notes', { userId: bob.id }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote3.id), true); - }); - - test.concurrent('自身の visibility: specified なノートが含まれる', async () => { - const [alice] = await Promise.all([signup()]); - - const aliceNote = await post(alice, { text: 'hi', visibility: 'specified' }); - - await waitForPushToTl(); - - const res = await api('/users/notes', { userId: alice.id, withReplies: true }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - }); - - test.concurrent('visibleUserIds に指定されてない visibility: specified なノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi', visibility: 'specified' }); - - await waitForPushToTl(); - - const res = await api('/users/notes', { userId: bob.id, withReplies: true }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - }); - }); - - // TODO: リノートミュート済みユーザーのテスト - // TODO: ページネーションのテスト -}); diff --git a/packages/backend/test/e2e/user-notes.ts b/packages/backend/test/e2e/user-notes.ts deleted file mode 100644 index b5f00a6327..0000000000 --- a/packages/backend/test/e2e/user-notes.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { signup, api, post, uploadUrl, startServer } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; -import type * as misskey from 'misskey-js'; - -describe('users/notes', () => { - let app: INestApplicationContext; - - let alice: misskey.entities.MeSignup; - let jpgNote: any; - let pngNote: any; - let jpgPngNote: any; - - beforeAll(async () => { - app = await startServer(); - alice = await signup({ username: 'alice' }); - const jpg = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); - const png = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.png'); - jpgNote = await post(alice, { - fileIds: [jpg.id], - }); - pngNote = await post(alice, { - fileIds: [png.id], - }); - jpgPngNote = await post(alice, { - fileIds: [jpg.id, png.id], - }); - }, 1000 * 60 * 2); - - afterAll(async() => { - await app.close(); - }); - - test('withFiles', async () => { - const res = await api('/users/notes', { - userId: alice.id, - withFiles: true, - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.length, 3); - assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === pngNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true); - }); -}); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts deleted file mode 100644 index be6f0ec855..0000000000 --- a/packages/backend/test/e2e/users.ts +++ /dev/null @@ -1,944 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { inspect } from 'node:util'; -import { DEFAULT_POLICIES } from '@/core/RoleService.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { - signup, - post, - page, - role, - startServer, - api, - successfulApiCall, - failedApiCall, - uploadFile, -} from '../utils.js'; -import type * as misskey from 'misskey-js'; -import type { INestApplicationContext } from '@nestjs/common'; - -describe('ユーザー', () => { - // エンティティとしてのユーザーを主眼においたテストを記述する - // (Userを返すエンドポイントとUserエンティティを書き換えるエンドポイントをテストする) - - const stripUndefined = (orig: T): Partial => { - return Object.entries({ ...orig }) - .filter(([, value]) => value !== undefined) - .reduce((obj: Partial, [key, value]) => { - obj[key as keyof T] = value; - return obj; - }, {}); - }; - - // BUG misskey-jsとjson-schemaと実際に返ってくるデータが全部違う - type UserLite = misskey.entities.UserLite & { - badgeRoles: any[], - }; - - type UserDetailedNotMe = UserLite & - misskey.entities.UserDetailed & { - roles: any[], - }; - - type MeDetailed = UserDetailedNotMe & - misskey.entities.MeDetailed & { - achievements: object[], - loggedInDays: number, - policies: object, - }; - - type User = MeDetailed & { token: string }; - - const show = async (id: string, me = root): Promise => { - return successfulApiCall({ endpoint: 'users/show', parameters: { userId: id }, user: me }) as any; - }; - - // UserLiteのキーが過不足なく入っている? - const userLite = (user: User): Partial => { - return stripUndefined({ - id: user.id, - name: user.name, - username: user.username, - host: user.host, - avatarUrl: user.avatarUrl, - avatarBlurhash: user.avatarBlurhash, - avatarDecorations: user.avatarDecorations, - isBot: user.isBot, - isCat: user.isCat, - speakAsCat: user.speakAsCat, - instance: user.instance, - emojis: user.emojis, - onlineStatus: user.onlineStatus, - badgeRoles: user.badgeRoles, - - // BUG isAdmin/isModeratorはUserLiteではなくMeDetailedOnlyに含まれる。 - isAdmin: undefined, - isModerator: undefined, - }); - }; - - // UserDetailedNotMeのキーが過不足なく入っている? - const userDetailedNotMe = (user: User): Partial => { - return stripUndefined({ - ...userLite(user), - url: user.url, - uri: user.uri, - movedTo: user.movedTo, - alsoKnownAs: user.alsoKnownAs, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - lastFetchedAt: user.lastFetchedAt, - bannerUrl: user.bannerUrl, - bannerBlurhash: user.bannerBlurhash, - backgroundUrl: user.backgroundUrl, - backgroundBlurhash: user.backgroundBlurhash, - isLocked: user.isLocked, - isSilenced: user.isSilenced, - isSuspended: user.isSuspended, - description: user.description, - location: user.location, - birthday: user.birthday, - lang: user.lang, - fields: user.fields, - verifiedLinks: user.verifiedLinks, - followersCount: user.followersCount, - followingCount: user.followingCount, - notesCount: user.notesCount, - pinnedNoteIds: user.pinnedNoteIds, - pinnedNotes: user.pinnedNotes, - pinnedPageId: user.pinnedPageId, - pinnedPage: user.pinnedPage, - publicReactions: user.publicReactions, - followingVisibility: user.followingVisibility, - followersVisibility: user.followersVisibility, - twoFactorEnabled: user.twoFactorEnabled, - usePasswordLessLogin: user.usePasswordLessLogin, - securityKeys: user.securityKeys, - roles: user.roles, - memo: user.memo, - }); - }; - - // Relations関連のキーが過不足なく入っている? - const userDetailedNotMeWithRelations = (user: User): Partial => { - return stripUndefined({ - ...userDetailedNotMe(user), - isFollowing: user.isFollowing ?? false, - isFollowed: user.isFollowed ?? false, - hasPendingFollowRequestFromYou: user.hasPendingFollowRequestFromYou ?? false, - hasPendingFollowRequestToYou: user.hasPendingFollowRequestToYou ?? false, - isBlocking: user.isBlocking ?? false, - isBlocked: user.isBlocked ?? false, - isMuted: user.isMuted ?? false, - isRenoteMuted: user.isRenoteMuted ?? false, - notify: user.notify ?? 'none', - withReplies: user.withReplies ?? false, - }); - }; - - // MeDetailedのキーが過不足なく入っている? - const meDetailed = (user: User, security = false): Partial => { - return stripUndefined({ - ...userDetailedNotMe(user), - avatarId: user.avatarId, - bannerId: user.bannerId, - isModerator: user.isModerator, - isAdmin: user.isAdmin, - injectFeaturedNote: user.injectFeaturedNote, - receiveAnnouncementEmail: user.receiveAnnouncementEmail, - alwaysMarkNsfw: user.alwaysMarkNsfw, - autoSensitive: user.autoSensitive, - carefulBot: user.carefulBot, - autoAcceptFollowed: user.autoAcceptFollowed, - noCrawle: user.noCrawle, - preventAiLearning: user.preventAiLearning, - isExplorable: user.isExplorable, - isDeleted: user.isDeleted, - twoFactorBackupCodesStock: user.twoFactorBackupCodesStock, - hideOnlineStatus: user.hideOnlineStatus, - hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes, - hasUnreadMentions: user.hasUnreadMentions, - hasUnreadAnnouncement: user.hasUnreadAnnouncement, - hasUnreadAntenna: user.hasUnreadAntenna, - hasUnreadChannel: user.hasUnreadChannel, - hasUnreadNotification: user.hasUnreadNotification, - unreadNotificationsCount: user.unreadNotificationsCount, - hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest, - unreadAnnouncements: user.unreadAnnouncements, - mutedWords: user.mutedWords, - hardMutedWords: user.hardMutedWords, - mutedInstances: user.mutedInstances, - mutingNotificationTypes: user.mutingNotificationTypes, - notificationRecieveConfig: user.notificationRecieveConfig, - emailNotificationTypes: user.emailNotificationTypes, - achievements: user.achievements, - loggedInDays: user.loggedInDays, - policies: user.policies, - ...(security ? { - email: user.email, - emailVerified: user.emailVerified, - securityKeysList: user.securityKeysList, - } : {}), - }); - }; - - let app: INestApplicationContext; - - let root: User; - let alice: User; - let aliceNote: misskey.entities.Note; - let alicePage: misskey.entities.Page; - let aliceList: misskey.entities.UserList; - - let bob: User; - let bobNote: misskey.entities.Note; - - let carol: User; - let dave: User; - let ellen: User; - let frank: User; - - let usersReplying: User[]; - - let userNoNote: User; - let userNotExplorable: User; - let userLocking: User; - let userAdmin: User; - let roleAdmin: any; - let userModerator: User; - let roleModerator: any; - let userRolePublic: User; - let rolePublic: any; - let userRoleBadge: User; - let roleBadge: any; - let userSilenced: User; - let roleSilenced: any; - let userSuspended: User; - let userDeletedBySelf: User; - let userDeletedByAdmin: User; - let userFollowingAlice: User; - let userFollowedByAlice: User; - let userBlockingAlice: User; - let userBlockedByAlice: User; - let userMutingAlice: User; - let userMutedByAlice: User; - let userRnMutingAlice: User; - let userRnMutedByAlice: User; - let userFollowRequesting: User; - let userFollowRequested: User; - - beforeAll(async () => { - app = await startServer(); - }, 1000 * 60 * 2); - - beforeAll(async () => { - root = await signup({ username: 'root' }); - alice = await signup({ username: 'alice' }); - aliceNote = await post(alice, { text: 'test' }) as any; - alicePage = await page(alice); - aliceList = (await api('users/list/create', { name: 'aliceList' }, alice)).body; - bob = await signup({ username: 'bob' }); - bobNote = await post(bob, { text: 'test' }) as any; - carol = await signup({ username: 'carol' }); - dave = await signup({ username: 'dave' }); - ellen = await signup({ username: 'ellen' }); - frank = await signup({ username: 'frank' }); - - // @alice -> @replyingへのリプライ。Promise.allで一気に作るとtimeoutしてしまうのでreduceで一つ一つawaitする - usersReplying = await [...Array(10)].map((_, i) => i).reduce(async (acc, i) => { - const u = await signup({ username: `replying${i}` }); - for (let j = 0; j < 10 - i; j++) { - const p = await post(u, { text: `test${j}` }); - await post(alice, { text: `@${u.username} test${j}`, replyId: p.id }); - } - - return (await acc).concat(u); - }, Promise.resolve([] as User[])); - - userNoNote = await signup({ username: 'userNoNote' }); - userNotExplorable = await signup({ username: 'userNotExplorable' }); - await post(userNotExplorable, { text: 'test' }); - await api('i/update', { isExplorable: false }, userNotExplorable); - userLocking = await signup({ username: 'userLocking' }); - await post(userLocking, { text: 'test' }); - await api('i/update', { isLocked: true }, userLocking); - userAdmin = await signup({ username: 'userAdmin' }); - roleAdmin = await role(root, { isAdministrator: true, name: 'Admin Role' }); - await api('admin/roles/assign', { userId: userAdmin.id, roleId: roleAdmin.id }, root); - userModerator = await signup({ username: 'userModerator' }); - roleModerator = await role(root, { isModerator: true, name: 'Moderator Role' }); - await api('admin/roles/assign', { userId: userModerator.id, roleId: roleModerator.id }, root); - userRolePublic = await signup({ username: 'userRolePublic' }); - rolePublic = await role(root, { isPublic: true, name: 'Public Role' }); - await api('admin/roles/assign', { userId: userRolePublic.id, roleId: rolePublic.id }, root); - userRoleBadge = await signup({ username: 'userRoleBadge' }); - roleBadge = await role(root, { asBadge: true, name: 'Badge Role' }); - await api('admin/roles/assign', { userId: userRoleBadge.id, roleId: roleBadge.id }, root); - userSilenced = await signup({ username: 'userSilenced' }); - await post(userSilenced, { text: 'test' }); - roleSilenced = await role(root, {}, { canPublicNote: { priority: 0, useDefault: false, value: false } }); - await api('admin/roles/assign', { userId: userSilenced.id, roleId: roleSilenced.id }, root); - userSuspended = await signup({ username: 'userSuspended' }); - await post(userSuspended, { text: 'test' }); - await successfulApiCall({ endpoint: 'i/update', parameters: { description: '#user_testuserSuspended' }, user: userSuspended }); - await api('admin/suspend-user', { userId: userSuspended.id }, root); - userDeletedBySelf = await signup({ username: 'userDeletedBySelf', password: 'userDeletedBySelf' }); - await post(userDeletedBySelf, { text: 'test' }); - await api('i/delete-account', { password: 'userDeletedBySelf' }, userDeletedBySelf); - userDeletedByAdmin = await signup({ username: 'userDeletedByAdmin' }); - await post(userDeletedByAdmin, { text: 'test' }); - await api('admin/delete-account', { userId: userDeletedByAdmin.id }, root); - userFollowingAlice = await signup({ username: 'userFollowingAlice' }); - await post(userFollowingAlice, { text: 'test' }); - await api('following/create', { userId: alice.id }, userFollowingAlice); - userFollowedByAlice = await signup({ username: 'userFollowedByAlice' }); - await post(userFollowedByAlice, { text: 'test' }); - await api('following/create', { userId: userFollowedByAlice.id }, alice); - userBlockingAlice = await signup({ username: 'userBlockingAlice' }); - await post(userBlockingAlice, { text: 'test' }); - await api('blocking/create', { userId: alice.id }, userBlockingAlice); - userBlockedByAlice = await signup({ username: 'userBlockedByAlice' }); - await post(userBlockedByAlice, { text: 'test' }); - await api('blocking/create', { userId: userBlockedByAlice.id }, alice); - userMutingAlice = await signup({ username: 'userMutingAlice' }); - await post(userMutingAlice, { text: 'test' }); - await api('mute/create', { userId: alice.id }, userMutingAlice); - userMutedByAlice = await signup({ username: 'userMutedByAlice' }); - await post(userMutedByAlice, { text: 'test' }); - await api('mute/create', { userId: userMutedByAlice.id }, alice); - userRnMutingAlice = await signup({ username: 'userRnMutingAlice' }); - await post(userRnMutingAlice, { text: 'test' }); - await api('renote-mute/create', { userId: alice.id }, userRnMutingAlice); - userRnMutedByAlice = await signup({ username: 'userRnMutedByAlice' }); - await post(userRnMutedByAlice, { text: 'test' }); - await api('renote-mute/create', { userId: userRnMutedByAlice.id }, alice); - userFollowRequesting = await signup({ username: 'userFollowRequesting' }); - await post(userFollowRequesting, { text: 'test' }); - userFollowRequested = userLocking; - await api('following/create', { userId: userFollowRequested.id }, userFollowRequesting); - }, 1000 * 60 * 10); - - afterAll(async () => { - await app.close(); - }); - - beforeEach(async () => { - alice = { - ...alice, - ...await successfulApiCall({ endpoint: 'i', parameters: {}, user: alice }) as any, - }; - aliceNote = await successfulApiCall({ endpoint: 'notes/show', parameters: { noteId: aliceNote.id }, user: alice }); - }); - - //#region サインアップ(signup) - - test('が作れる。(作りたての状態で自分のユーザー情報が取れる)', async () => { - // SignupApiService.ts - const response = await successfulApiCall({ - endpoint: 'signup', - parameters: { username: 'zoe', password: 'password' }, - user: undefined, - }) as unknown as User; // BUG MeDetailedに足りないキーがある - - // signupの時はtokenが含まれる特別なMeDetailedが返ってくる - assert.match(response.token, /[a-zA-Z0-9]{16}/); - - // UserLite - assert.match(response.id, /[0-9a-z]{10}/); - assert.strictEqual(response.name, null); - assert.strictEqual(response.username, 'zoe'); - assert.strictEqual(response.host, null); - assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); - assert.strictEqual(response.avatarBlurhash, null); - assert.deepStrictEqual(response.avatarDecorations, []); - assert.strictEqual(response.isBot, false); - assert.strictEqual(response.isCat, false); - assert.strictEqual(response.speakAsCat, false); - assert.strictEqual(response.instance, undefined); - assert.deepStrictEqual(response.emojis, {}); - assert.strictEqual(response.onlineStatus, 'unknown'); - assert.deepStrictEqual(response.badgeRoles, []); - // UserDetailedNotMeOnly - assert.strictEqual(response.url, null); - assert.strictEqual(response.uri, null); - assert.strictEqual(response.movedTo, null); - assert.strictEqual(response.alsoKnownAs, null); - assert.strictEqual(response.createdAt, new Date(response.createdAt).toISOString()); - assert.strictEqual(response.updatedAt, null); - assert.strictEqual(response.lastFetchedAt, null); - assert.strictEqual(response.bannerUrl, null); - assert.strictEqual(response.bannerBlurhash, null); - assert.strictEqual(response.backgroundUrl, null); - assert.strictEqual(response.backgroundBlurhash, null); - assert.strictEqual(response.isLocked, false); - assert.strictEqual(response.isSilenced, false); - assert.strictEqual(response.isSuspended, false); - assert.strictEqual(response.description, null); - assert.strictEqual(response.location, null); - assert.strictEqual(response.birthday, null); - assert.strictEqual(response.lang, null); - assert.deepStrictEqual(response.fields, []); - assert.deepStrictEqual(response.verifiedLinks, []); - assert.strictEqual(response.followersCount, 0); - assert.strictEqual(response.followingCount, 0); - assert.strictEqual(response.notesCount, 0); - assert.deepStrictEqual(response.pinnedNoteIds, []); - assert.deepStrictEqual(response.pinnedNotes, []); - assert.strictEqual(response.pinnedPageId, null); - assert.strictEqual(response.pinnedPage, null); - assert.strictEqual(response.publicReactions, true); - assert.strictEqual(response.followingVisibility, 'public'); - assert.strictEqual(response.followersVisibility, 'public'); - assert.strictEqual(response.twoFactorEnabled, false); - assert.strictEqual(response.usePasswordLessLogin, false); - assert.strictEqual(response.securityKeys, false); - assert.deepStrictEqual(response.roles, []); - assert.strictEqual(response.memo, null); - - // MeDetailedOnly - assert.strictEqual(response.avatarId, null); - assert.strictEqual(response.bannerId, null); - assert.strictEqual(response.isModerator, false); - assert.strictEqual(response.isAdmin, false); - assert.strictEqual(response.injectFeaturedNote, true); - assert.strictEqual(response.receiveAnnouncementEmail, true); - assert.strictEqual(response.alwaysMarkNsfw, false); - assert.strictEqual(response.autoSensitive, false); - assert.strictEqual(response.carefulBot, false); - assert.strictEqual(response.autoAcceptFollowed, true); - assert.strictEqual(response.noCrawle, false); - assert.strictEqual(response.preventAiLearning, true); - assert.strictEqual(response.isExplorable, true); - assert.strictEqual(response.isDeleted, false); - assert.strictEqual(response.twoFactorBackupCodesStock, 'none'); - assert.strictEqual(response.hideOnlineStatus, false); - assert.strictEqual(response.hasUnreadSpecifiedNotes, false); - assert.strictEqual(response.hasUnreadMentions, false); - assert.strictEqual(response.hasUnreadAnnouncement, false); - assert.strictEqual(response.hasUnreadAntenna, false); - assert.strictEqual(response.hasUnreadChannel, false); - assert.strictEqual(response.hasUnreadNotification, false); - assert.strictEqual(response.unreadNotificationsCount, 0); - assert.strictEqual(response.hasPendingReceivedFollowRequest, false); - assert.deepStrictEqual(response.unreadAnnouncements, []); - assert.deepStrictEqual(response.mutedWords, []); - assert.deepStrictEqual(response.mutedInstances, []); - assert.deepStrictEqual(response.mutingNotificationTypes, []); - assert.deepStrictEqual(response.notificationRecieveConfig, {}); - assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']); - assert.deepStrictEqual(response.achievements, []); - assert.deepStrictEqual(response.loggedInDays, 0); - assert.deepStrictEqual(response.policies, DEFAULT_POLICIES); - assert.notStrictEqual(response.email, undefined); - assert.strictEqual(response.emailVerified, false); - assert.deepStrictEqual(response.securityKeysList, []); - }); - - //#endregion - //#region 自分の情報(i) - - test('を読み取ることができること(自分)、キーが過不足なく入っていること。', async () => { - const response = await successfulApiCall({ - endpoint: 'i', - parameters: {}, - user: userNoNote, - }); - const expected = meDetailed(userNoNote, true); - expected.loggedInDays = 1; // iはloggedInDaysを更新する - assert.deepStrictEqual(response, expected); - }); - - //#endregion - //#region 自分の情報の更新(i/update) - - test.each([ - { parameters: (): object => ({ name: null }) }, - { parameters: (): object => ({ name: 'x'.repeat(50) }) }, - { parameters: (): object => ({ name: 'x' }) }, - { parameters: (): object => ({ name: 'My name' }) }, - { parameters: (): object => ({ description: null }) }, - { parameters: (): object => ({ description: 'x'.repeat(1500) }) }, - { parameters: (): object => ({ description: 'x' }) }, - { parameters: (): object => ({ description: 'My description' }) }, - { parameters: (): object => ({ location: null }) }, - { parameters: (): object => ({ location: 'x'.repeat(50) }) }, - { parameters: (): object => ({ location: 'x' }) }, - { parameters: (): object => ({ location: 'My location' }) }, - { parameters: (): object => ({ birthday: '0000-00-00' }) }, - { parameters: (): object => ({ birthday: '9999-99-99' }) }, - { parameters: (): object => ({ lang: 'en-US' }) }, - { parameters: (): object => ({ fields: [] }) }, - { parameters: (): object => ({ fields: [{ name: 'x', value: 'x' }] }) }, - { parameters: (): object => ({ fields: [{ name: 'x'.repeat(3000), value: 'x'.repeat(3000) }] }) }, // BUG? fieldには制限がない - { parameters: (): object => ({ fields: Array(16).fill({ name: 'x', value: 'y' }) }) }, - { parameters: (): object => ({ isLocked: true }) }, - { parameters: (): object => ({ isLocked: false }) }, - { parameters: (): object => ({ isExplorable: false }) }, - { parameters: (): object => ({ isExplorable: true }) }, - { parameters: (): object => ({ hideOnlineStatus: true }) }, - { parameters: (): object => ({ hideOnlineStatus: false }) }, - { parameters: (): object => ({ publicReactions: false }) }, - { parameters: (): object => ({ publicReactions: true }) }, - { parameters: (): object => ({ autoAcceptFollowed: true }) }, - { parameters: (): object => ({ autoAcceptFollowed: false }) }, - { parameters: (): object => ({ noCrawle: true }) }, - { parameters: (): object => ({ noCrawle: false }) }, - { parameters: (): object => ({ preventAiLearning: false }) }, - { parameters: (): object => ({ preventAiLearning: true }) }, - { parameters: (): object => ({ isBot: true }) }, - { parameters: (): object => ({ isBot: false }) }, - { parameters: (): object => ({ isCat: true }) }, - { parameters: (): object => ({ isCat: false }) }, - { parameters: (): object => ({ speakAsCat: true }) }, - { parameters: (): object => ({ speakAsCat: false }) }, - { parameters: (): object => ({ injectFeaturedNote: true }) }, - { parameters: (): object => ({ injectFeaturedNote: false }) }, - { parameters: (): object => ({ receiveAnnouncementEmail: true }) }, - { parameters: (): object => ({ receiveAnnouncementEmail: false }) }, - { parameters: (): object => ({ alwaysMarkNsfw: true }) }, - { parameters: (): object => ({ alwaysMarkNsfw: false }) }, - { parameters: (): object => ({ autoSensitive: true }) }, - { parameters: (): object => ({ autoSensitive: false }) }, - { parameters: (): object => ({ followingVisibility: 'private' }) }, - { parameters: (): object => ({ followingVisibility: 'followers' }) }, - { parameters: (): object => ({ followingVisibility: 'public' }) }, - { parameters: (): object => ({ followersVisibility: 'private' }) }, - { parameters: (): object => ({ followersVisibility: 'followers' }) }, - { parameters: (): object => ({ followersVisibility: 'public' }) }, - { parameters: (): object => ({ mutedWords: Array(19).fill(['xxxxx']) }) }, - { parameters: (): object => ({ mutedWords: [['x'.repeat(194)]] }) }, - { parameters: (): object => ({ mutedWords: [] }) }, - { parameters: (): object => ({ mutedInstances: ['xxxx.xxxxx'] }) }, - { parameters: (): object => ({ mutedInstances: [] }) }, - { parameters: (): object => ({ notificationRecieveConfig: { mention: { type: 'following' } } }) }, - { parameters: (): object => ({ notificationRecieveConfig: {} }) }, - { parameters: (): object => ({ emailNotificationTypes: ['mention', 'reply', 'quote', 'follow', 'receiveFollowRequest'] }) }, - { parameters: (): object => ({ emailNotificationTypes: [] }) }, - ] as const)('を書き換えることができる($#)', async ({ parameters }) => { - const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters(), user: alice }); - const expected = { ...meDetailed(alice, true), ...parameters() }; - assert.deepStrictEqual(response, expected, inspect(parameters())); - }); - - test('を書き換えることができる(Avatar)', async () => { - const aliceFile = (await uploadFile(alice)).body; - const parameters = { avatarId: aliceFile.id }; - const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice }); - assert.match(response.avatarUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); - assert.match(response.avatarBlurhash ?? '.', /[ -~]{54}/); - const expected = { - ...meDetailed(alice, true), - avatarId: aliceFile.id, - avatarBlurhash: response.avatarBlurhash, - avatarUrl: response.avatarUrl, - }; - assert.deepStrictEqual(response, expected, inspect(parameters)); - - const parameters2 = { avatarId: null }; - const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice }); - const expected2 = { - ...meDetailed(alice, true), - avatarId: null, - avatarBlurhash: null, - avatarUrl: alice.avatarUrl, // 解除した場合、identiconになる - }; - assert.deepStrictEqual(response2, expected2, inspect(parameters)); - }); - - test('を書き換えることができる(Banner)', async () => { - const aliceFile = (await uploadFile(alice)).body; - const parameters = { bannerId: aliceFile.id }; - const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice }); - assert.match(response.bannerUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); - assert.match(response.bannerBlurhash ?? '.', /[ -~]{54}/); - const expected = { - ...meDetailed(alice, true), - bannerId: aliceFile.id, - bannerBlurhash: response.bannerBlurhash, - bannerUrl: response.bannerUrl, - }; - assert.deepStrictEqual(response, expected, inspect(parameters)); - - const parameters2 = { bannerId: null }; - const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice }); - const expected2 = { - ...meDetailed(alice, true), - bannerId: null, - bannerBlurhash: null, - bannerUrl: null, - }; - assert.deepStrictEqual(response2, expected2, inspect(parameters)); - }); - - test('を書き換えることができる(Background)', async () => { - const aliceFile = (await uploadFile(alice)).body; - const parameters = { bannerId: aliceFile.id }; - const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice }); - assert.match(response.backgroundUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); - assert.match(response.backgroundBlurhash ?? '.', /[ -~]{54}/); - const expected = { - ...meDetailed(alice, true), - backgroundId: aliceFile.id, - backgroundBlurhash: response.baackgroundBlurhash, - backgroundUrl: response.backgroundUrl, - }; - assert.deepStrictEqual(response, expected, inspect(parameters)); - - const parameters2 = { backgroundId: null }; - const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice }); - const expected2 = { - ...meDetailed(alice, true), - backgroundId: null, - backgroundBlurhash: null, - backgroundUrl: null, - }; - assert.deepStrictEqual(response2, expected2, inspect(parameters)); - }); - - //#endregion - //#region 自分の情報の更新(i/pin, i/unpin) - - test('を書き換えることができる(ピン止めノート)', async () => { - const parameters = { noteId: aliceNote.id }; - const response = await successfulApiCall({ endpoint: 'i/pin', parameters, user: alice }); - const expected = { ...meDetailed(alice, false), pinnedNoteIds: [aliceNote.id], pinnedNotes: [aliceNote] }; - assert.deepStrictEqual(response, expected); - - const response2 = await successfulApiCall({ endpoint: 'i/unpin', parameters, user: alice }); - const expected2 = meDetailed(alice, false); - assert.deepStrictEqual(response2, expected2); - }); - - //#endregion - //#region メモの更新(users/update-memo) - - test.each([ - { label: '最大長', memo: 'x'.repeat(2048) }, - { label: '空文字', memo: '', expects: null }, - { label: 'null', memo: null }, - ])('を書き換えることができる(メモを$labelに)', async ({ memo, expects }) => { - const expected = { ...await show(bob.id, alice), memo: expects === undefined ? memo : expects }; - const parameters = { userId: bob.id, memo }; - await successfulApiCall({ endpoint: 'users/update-memo', parameters, user: alice }); - const response = await show(bob.id, alice); - assert.deepStrictEqual(response, expected); - }); - - //#endregion - //#region ユーザー(users) - - test.each([ - { label: 'ID昇順', parameters: { limit: 5 }, selector: (u: UserLite): string => u.id }, - { label: 'フォロワー昇順', parameters: { sort: '+follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, - { label: 'フォロワー降順', parameters: { sort: '-follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, - { label: '登録日時昇順', parameters: { sort: '+createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, - { label: '登録日時降順', parameters: { sort: '-createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, - { label: '投稿日時昇順', parameters: { sort: '+updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, - { label: '投稿日時降順', parameters: { sort: '-updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, - ] as const)('をリスト形式で取得することができる($label)', async ({ parameters, selector }) => { - const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice }); - - // 結果の並びを事前にアサートするのは困難なので返ってきたidに対応するユーザーが返っており、ソート順が正しいことだけを検証する - const users = await Promise.all(response.map(u => show(u.id, alice))); - const expected = users.sort((x, y) => { - const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0; - return index * (parameters.sort?.startsWith('+') ? -1 : 1); - }); - assert.deepStrictEqual(response, expected); - }); - test.each([ - { label: '「見つけやすくする」がOFFのユーザーが含まれない', user: (): User => userNotExplorable, excluded: true }, - { label: 'ミュートユーザーが含まれない', user: (): User => userMutedByAlice, excluded: true }, - { label: 'ブロックされているユーザーが含まれない', user: (): User => userBlockedByAlice, excluded: true }, - { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice, excluded: true }, - { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, - { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, - { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, - { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, - { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, - ] as const)('をリスト形式で取得することができ、結果に$label', async ({ user, excluded }) => { - const parameters = { limit: 100 }; - const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice }); - const expected = (excluded ?? false) ? [] : [await show(user().id, alice)]; - assert.deepStrictEqual(response.filter((u) => u.id === user().id), expected); - }); - test.todo('をリスト形式で取得することができる(リモート, hostname指定)'); - test.todo('をリスト形式で取得することができる(pagenation)'); - - //#endregion - //#region ユーザー情報(users/show) - - test.each([ - { label: 'ID指定で自分自身を', parameters: (): object => ({ userId: alice.id }), user: (): User => alice, type: meDetailed }, - { label: 'ID指定で他人を', parameters: (): object => ({ userId: alice.id }), user: (): User => bob, type: userDetailedNotMeWithRelations }, - { label: 'ID指定かつ未認証', parameters: (): object => ({ userId: alice.id }), user: undefined, type: userDetailedNotMe }, - { label: '@指定で自分自身を', parameters: (): object => ({ username: alice.username }), user: (): User => alice, type: meDetailed }, - { label: '@指定で他人を', parameters: (): object => ({ username: alice.username }), user: (): User => bob, type: userDetailedNotMeWithRelations }, - { label: '@指定かつ未認証', parameters: (): object => ({ username: alice.username }), user: undefined, type: userDetailedNotMe }, - ] as const)('を取得することができる($label)', async ({ parameters, user, type }) => { - const response = await successfulApiCall({ endpoint: 'users/show', parameters: parameters(), user: user?.() }); - const expected = type(alice); - assert.deepStrictEqual(response, expected); - }); - test.each([ - { label: 'Administratorになっている', user: (): User => userAdmin, me: (): User => userAdmin, selector: (user: User): unknown => user.isAdmin }, - { label: '自分以外から見たときはAdministratorか判定できない', user: (): User => userAdmin, selector: (user: User): unknown => user.isAdmin, expected: (): undefined => undefined }, - { label: 'Moderatorになっている', user: (): User => userModerator, me: (): User => userModerator, selector: (user: User): unknown => user.isModerator }, - { label: '自分以外から見たときはModeratorか判定できない', user: (): User => userModerator, selector: (user: User): unknown => user.isModerator, expected: (): undefined => undefined }, - { label: 'サイレンスになっている', user: (): User => userSilenced, selector: (user: User): unknown => user.isSilenced }, - //{ label: 'サスペンドになっている', user: (): User => userSuspended, selector: (user: User): unknown => user.isSuspended }, - { label: '削除済みになっている', user: (): User => userDeletedBySelf, me: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted }, - { label: '自分以外から見たときは削除済みか判定できない', user: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined }, - { label: '削除済み(byAdmin)になっている', user: (): User => userDeletedByAdmin, me: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted }, - { label: '自分以外から見たときは削除済み(byAdmin)か判定できない', user: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined }, - { label: 'フォロー中になっている', user: (): User => userFollowedByAlice, selector: (user: User): unknown => user.isFollowing }, - { label: 'フォローされている', user: (): User => userFollowingAlice, selector: (user: User): unknown => user.isFollowed }, - { label: 'ブロック中になっている', user: (): User => userBlockedByAlice, selector: (user: User): unknown => user.isBlocking }, - { label: 'ブロックされている', user: (): User => userBlockingAlice, selector: (user: User): unknown => user.isBlocked }, - { label: 'ミュート中になっている', user: (): User => userMutedByAlice, selector: (user: User): unknown => user.isMuted }, - { label: 'リノートミュート中になっている', user: (): User => userRnMutedByAlice, selector: (user: User): unknown => user.isRenoteMuted }, - { label: 'フォローリクエスト中になっている', user: (): User => userFollowRequested, me: (): User => userFollowRequesting, selector: (user: User): unknown => user.hasPendingFollowRequestFromYou }, - { label: 'フォローリクエストされている', user: (): User => userFollowRequesting, me: (): User => userFollowRequested, selector: (user: User): unknown => user.hasPendingFollowRequestToYou }, - ] as const)('を取得することができ、$labelこと', async ({ user, me, selector, expected }) => { - const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: user().id }, user: me?.() ?? alice }); - assert.strictEqual(selector(response), (expected ?? ((): true => true))()); - }); - test('を取得することができ、Publicなロールがセットされていること', async () => { - const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRolePublic.id }, user: alice }); - assert.deepStrictEqual(response.badgeRoles, []); - assert.deepStrictEqual(response.roles, [{ - id: rolePublic.id, - name: rolePublic.name, - color: rolePublic.color, - iconUrl: rolePublic.iconUrl, - description: rolePublic.description, - isModerator: rolePublic.isModerator, - isAdministrator: rolePublic.isAdministrator, - displayOrder: rolePublic.displayOrder, - }]); - }); - test('を取得することができ、バッヂロールがセットされていること', async () => { - const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice }); - assert.deepStrictEqual(response.badgeRoles, [{ - name: roleBadge.name, - iconUrl: roleBadge.iconUrl, - displayOrder: roleBadge.displayOrder, - }]); - assert.deepStrictEqual(response.roles, []); // バッヂだからといってrolesが取れるとは限らない - }); - test('をID指定のリスト形式で取得することができる(空)', async () => { - const parameters = { userIds: [] }; - const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice }); - const expected: [] = []; - assert.deepStrictEqual(response, expected); - }); - test('をID指定のリスト形式で取得することができる', async() => { - const parameters = { userIds: [bob.id, alice.id, carol.id] }; - const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice }); - const expected = [ - await successfulApiCall({ endpoint: 'users/show', parameters: { userId: bob.id }, user: alice }), - await successfulApiCall({ endpoint: 'users/show', parameters: { userId: alice.id }, user: alice }), - await successfulApiCall({ endpoint: 'users/show', parameters: { userId: carol.id }, user: alice }), - ]; - assert.deepStrictEqual(response, expected); - }); - test.each([ - { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, - { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, - { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, - { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, - { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, - { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, - { label: 'サスペンドユーザーが(モデレーターが見るときは)含まれる', user: (): User => userSuspended, me: (): User => root }, - // BUG サスペンドユーザーを一般ユーザーから見るとrootユーザーが返ってくる - //{ label: 'サスペンドユーザーが(一般ユーザーが見るときは)含まれない', user: (): User => userSuspended, me: (): User => bob, excluded: true }, - { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, - { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, - ] as const)('をID指定のリスト形式で取得することができ、結果に$label', async ({ user, me, excluded }) => { - const parameters = { userIds: [user().id] }; - const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: me?.() ?? alice }); - const expected = (excluded ?? false) ? [] : [await show(user().id, me?.() ?? alice)]; - assert.deepStrictEqual(response, expected); - }); - test.todo('をID指定のリスト形式で取得することができる(リモート)'); - - //#endregion - //#region 検索(users/search) - - test('を検索することができる', async () => { - const parameters = { query: 'carol', limit: 10 }; - const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); - const expected = [await show(carol.id, alice)]; - assert.deepStrictEqual(response, expected); - }); - test('を検索することができる(UserLite)', async () => { - const parameters = { query: 'carol', detail: false, limit: 10 }; - const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); - const expected = [userLite(await show(carol.id, alice))]; - assert.deepStrictEqual(response, expected); - }); - test.each([ - { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, - { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, - { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, - { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, - { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, - { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, - { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, - { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, - { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, - ] as const)('を検索することができ、結果に$labelが含まれる', async ({ user, excluded }) => { - const parameters = { query: user().username, limit: 1 }; - const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); - const expected = (excluded ?? false) ? [] : [await show(user().id, alice)]; - assert.deepStrictEqual(response, expected); - }); - test.todo('を検索することができる(リモート)'); - test.todo('を検索することができる(pagenation)'); - - //#endregion - //#region ID指定検索(users/search-by-username-and-host) - - test.each([ - { label: '自分', parameters: { username: 'alice' }, user: (): User[] => [alice] }, - { label: '自分かつusernameが大文字', parameters: { username: 'ALICE' }, user: (): User[] => [alice] }, - { label: 'ローカルのフォロイーでノートなし', parameters: { username: 'userFollowedByAlice' }, user: (): User[] => [userFollowedByAlice] }, - { label: 'ローカルでノートなしは検索に載らない', parameters: { username: 'userNoNote' }, user: (): User[] => [] }, - { label: 'ローカルの他人1', parameters: { username: 'bob' }, user: (): User[] => [bob] }, - { label: 'ローカルの他人2', parameters: { username: 'bob', host: null }, user: (): User[] => [bob] }, - { label: 'ローカルの他人3', parameters: { username: 'bob', host: '.' }, user: (): User[] => [bob] }, - { label: 'ローカル', parameters: { host: null, limit: 1 }, user: (): User[] => [userFollowedByAlice] }, - { label: 'ローカル', parameters: { host: '.', limit: 1 }, user: (): User[] => [userFollowedByAlice] }, - ])('をID&ホスト指定で検索できる($label)', async ({ parameters, user }) => { - const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice }); - const expected = await Promise.all(user().map(u => show(u.id, alice))); - assert.deepStrictEqual(response, expected); - }); - test.each([ - { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, - { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, - { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, - { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, - { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, - { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, - { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, - { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, - { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, - ] as const)('をID&ホスト指定で検索でき、結果に$label', async ({ user, excluded }) => { - const parameters = { username: user().username }; - const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice }); - const expected = (excluded ?? false) ? [] : [await show(user().id, alice)]; - assert.deepStrictEqual(response, expected); - }); - test.todo('をID&ホスト指定で検索できる(リモート)'); - - //#endregion - //#region ID指定検索(users/get-frequently-replied-users) - - test('がよくリプライをするユーザーのリストを取得できる', async () => { - const parameters = { userId: alice.id, limit: 5 }; - const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice }); - const expected = await Promise.all(usersReplying.slice(0, parameters.limit).map(async (s, i) => ({ - user: await show(s.id, alice), - weight: (usersReplying.length - i) / usersReplying.length, - }))); - assert.deepStrictEqual(response, expected); - }); - test.each([ - { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, - { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, - { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, - { label: 'ブロックしてきているユーザーが含まれない', user: (): User => userBlockingAlice, excluded: true }, - { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, - { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, - //{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, - { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, - { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, - ] as const)('がよくリプライをするユーザーのリストを取得でき、結果に$label', async ({ user, excluded }) => { - const replyTo = (await successfulApiCall({ endpoint: 'users/notes', parameters: { userId: user().id }, user: undefined }))[0]; - await post(alice, { text: `@${user().username} test`, replyId: replyTo.id }); - const parameters = { userId: alice.id, limit: 100 }; - const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice }); - const expected = (excluded ?? false) ? [] : [await show(user().id, alice)]; - assert.deepStrictEqual(response.map(s => s.user).filter((u) => u.id === user().id), expected); - }); - - //#endregion - //#region ハッシュタグ(hashtags/users) - - test.each([ - { label: 'フォロワー昇順', sort: { sort: '+follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, - { label: 'フォロワー降順', sort: { sort: '-follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, - { label: '登録日時昇順', sort: { sort: '+createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, - { label: '登録日時降順', sort: { sort: '-createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, - { label: '投稿日時昇順', sort: { sort: '+updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, - { label: '投稿日時降順', sort: { sort: '-updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, - ] as const)('をハッシュタグ指定で取得することができる($label)', async ({ sort, selector }) => { - const hashtag = 'test_hashtag'; - await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: alice }); - const parameters = { tag: hashtag, limit: 5, ...sort }; - const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice }); - const users = await Promise.all(response.map(u => show(u.id, alice))); - const expected = users.sort((x, y) => { - const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0; - return index * (parameters.sort.startsWith('+') ? -1 : 1); - }); - assert.deepStrictEqual(response, expected); - }); - test.each([ - { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, - { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, - { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, - { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, - { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, - { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, - { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, - { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, - { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, - ] as const)('をハッシュタグ指定で取得することができ、結果に$label', async ({ user, excluded }) => { - const hashtag = `user_test${user().username}`; - if (user() !== userSuspended) { - // サスペンドユーザーはupdateできない。 - await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: user() }); - } - const parameters = { tag: hashtag, limit: 100, sort: '-follower' } as const; - const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice }); - const expected = (excluded ?? false) ? [] : [await show(user().id, alice)]; - assert.deepStrictEqual(response, expected); - }); - test.todo('をハッシュタグ指定で取得することができる(リモート)'); - - //#endregion - //#region オススメユーザー(users/recommendation) - - // BUG users/recommendationは壊れている? > QueryFailedError: missing FROM-clause entry for table "note" - test.skip('のオススメを取得することができる', async () => { - const parameters = {}; - const response = await successfulApiCall({ endpoint: 'users/recommendation', parameters, user: alice }); - const expected = await Promise.all(response.map(u => show(u.id))); - assert.deepStrictEqual(response, expected); - }); - - //#endregion - //#region ピン止めユーザー(pinned-users) - - test('のピン止めユーザーを取得することができる', async () => { - await successfulApiCall({ endpoint: 'admin/update-meta', parameters: { pinnedUsers: [bob.username, `@${carol.username}`] }, user: root }); - const parameters = {} as const; - const response = await successfulApiCall({ endpoint: 'pinned-users', parameters, user: alice }); - const expected = await Promise.all([bob, carol].map(u => show(u.id, alice))); - assert.deepStrictEqual(response, expected); - }); - - //#endregion - - test.todo('を管理人として確認することができる(admin/show-user)'); - test.todo('を管理人として確認することができる(admin/show-users)'); - test.todo('をサーバー向けに取得することができる(federation/users)'); -}); diff --git a/packages/backend/test/e2e/well-known.ts b/packages/backend/test/e2e/well-known.ts deleted file mode 100644 index 14e32e1627..0000000000 --- a/packages/backend/test/e2e/well-known.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { host, origin, relativeFetch, signup, startServer } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; -import type * as misskey from 'misskey-js'; - -describe('.well-known', () => { - let app: INestApplicationContext; - let alice: misskey.entities.User; - - beforeAll(async () => { - app = await startServer(); - - alice = await signup({ username: 'alice' }); - }, 1000 * 60 * 2); - - afterAll(async () => { - await app.close(); - }); - - test('nodeinfo', async () => { - const res = await relativeFetch('.well-known/nodeinfo'); - assert.ok(res.ok); - assert.strictEqual(res.headers.get('Access-Control-Allow-Origin'), '*'); - - const nodeInfo = await res.json(); - assert.deepStrictEqual(nodeInfo, { - links: [{ - rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', - href: `${origin}/nodeinfo/2.1`, - }, { - rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', - href: `${origin}/nodeinfo/2.0`, - }], - }); - }); - - test('webfinger', async () => { - const preflight = await relativeFetch(`.well-known/webfinger?resource=acct:alice@${host}`, { - method: 'options', - headers: { - 'Access-Control-Request-Method': 'GET', - Origin: 'http://example.com', - }, - }); - assert.ok(preflight.ok); - assert.strictEqual(preflight.headers.get('Access-Control-Allow-Headers'), 'Accept'); - - const res = await relativeFetch(`.well-known/webfinger?resource=acct:alice@${host}`); - assert.ok(res.ok); - assert.strictEqual(res.headers.get('Access-Control-Allow-Origin'), '*'); - assert.strictEqual(res.headers.get('Access-Control-Expose-Headers'), 'Vary'); - assert.strictEqual(res.headers.get('Vary'), 'Accept'); - - const webfinger = await res.json(); - - assert.deepStrictEqual(webfinger, { - subject: `acct:alice@${host}`, - links: [{ - rel: 'self', - type: 'application/activity+json', - href: `${origin}/users/${alice.id}`, - }, { - rel: 'http://webfinger.net/rel/profile-page', - type: 'text/html', - href: `${origin}/@alice`, - }, { - rel: 'http://ostatus.org/schema/1.0/subscribe', - template: `${origin}/authorize-follow?acct={uri}`, - }], - }); - }); - - test('host-meta', async () => { - const res = await relativeFetch('.well-known/host-meta'); - assert.ok(res.ok); - assert.strictEqual(res.headers.get('Access-Control-Allow-Origin'), '*'); - }); - - test('host-meta.json', async () => { - const res = await relativeFetch('.well-known/host-meta.json'); - assert.ok(res.ok); - assert.strictEqual(res.headers.get('Access-Control-Allow-Origin'), '*'); - - const hostMeta = await res.json(); - assert.deepStrictEqual(hostMeta, { - links: [{ - rel: 'lrdd', - type: 'application/jrd+json', - template: `${origin}/.well-known/webfinger?resource={uri}`, - }], - }); - }); - - test('oauth-authorization-server', async () => { - const res = await relativeFetch('.well-known/oauth-authorization-server'); - assert.ok(res.ok); - assert.strictEqual(res.headers.get('Access-Control-Allow-Origin'), '*'); - - const serverInfo = await res.json() as any; - assert.strictEqual(serverInfo.issuer, origin); - assert.strictEqual(serverInfo.authorization_endpoint, `${origin}/oauth/authorize`); - assert.strictEqual(serverInfo.token_endpoint, `${origin}/oauth/token`); - }); -}); diff --git a/packages/backend/test/endpoints.ts b/packages/backend/test/endpoints.ts new file mode 100644 index 0000000000..2aedc25f2c --- /dev/null +++ b/packages/backend/test/endpoints.ts @@ -0,0 +1,865 @@ +/* +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import * as childProcess from 'child_process'; +import { async, signup, request, post, react, uploadFile, startServer, shutdownServer } from './utils.js'; + +describe('API: Endpoints', () => { + let p: childProcess.ChildProcess; + let alice: any; + let bob: any; + let carol: any; + + before(async () => { + p = await startServer(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + carol = await signup({ username: 'carol' }); + }); + + after(async () => { + await shutdownServer(p); + }); + + describe('signup', () => { + it('不正なユーザー名でアカウントが作成できない', async(async () => { + const res = await request('/signup', { + username: 'test.', + password: 'test' + }); + assert.strictEqual(res.status, 400); + })); + + it('空のパスワードでアカウントが作成できない', async(async () => { + const res = await request('/signup', { + username: 'test', + password: '' + }); + assert.strictEqual(res.status, 400); + })); + + it('正しくアカウントが作成できる', async(async () => { + const me = { + username: 'test1', + password: 'test1' + }; + + const res = await request('/signup', me); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.username, me.username); + })); + + it('同じユーザー名のアカウントは作成できない', async(async () => { + await signup({ + username: 'test2' + }); + + const res = await request('/signup', { + username: 'test2', + password: 'test2' + }); + + assert.strictEqual(res.status, 400); + })); + }); + + describe('signin', () => { + it('間違ったパスワードでサインインできない', async(async () => { + await signup({ + username: 'test3', + password: 'foo' + }); + + const res = await request('/signin', { + username: 'test3', + password: 'bar' + }); + + assert.strictEqual(res.status, 403); + })); + + it('クエリをインジェクションできない', async(async () => { + await signup({ + username: 'test4' + }); + + const res = await request('/signin', { + username: 'test4', + password: { + $gt: '' + } + }); + + assert.strictEqual(res.status, 400); + })); + + it('正しい情報でサインインできる', async(async () => { + await signup({ + username: 'test5', + password: 'foo' + }); + + const res = await request('/signin', { + username: 'test5', + password: 'foo' + }); + + assert.strictEqual(res.status, 200); + })); + }); + + describe('i/update', () => { + it('アカウント設定を更新できる', async(async () => { + const myName = '大室櫻子'; + const myLocation = '七森中'; + const myBirthday = '2000-09-07'; + + const res = await request('/i/update', { + name: myName, + location: myLocation, + birthday: myBirthday + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.name, myName); + assert.strictEqual(res.body.location, myLocation); + assert.strictEqual(res.body.birthday, myBirthday); + })); + + it('名前を空白にできない', async(async () => { + const res = await request('/i/update', { + name: ' ' + }, alice); + assert.strictEqual(res.status, 400); + })); + + it('誕生日の設定を削除できる', async(async () => { + await request('/i/update', { + birthday: '2000-09-07' + }, alice); + + const res = await request('/i/update', { + birthday: null + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.birthday, null); + })); + + it('不正な誕生日の形式で怒られる', async(async () => { + const res = await request('/i/update', { + birthday: '2000/09/07' + }, alice); + assert.strictEqual(res.status, 400); + })); + }); + + describe('users/show', () => { + it('ユーザーが取得できる', async(async () => { + const res = await request('/users/show', { + userId: alice.id + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.id, alice.id); + })); + + it('ユーザーが存在しなかったら怒る', async(async () => { + const res = await request('/users/show', { + userId: '000000000000000000000000' + }); + assert.strictEqual(res.status, 400); + })); + + it('間違ったIDで怒られる', async(async () => { + const res = await request('/users/show', { + userId: 'kyoppie' + }); + assert.strictEqual(res.status, 400); + })); + }); + + describe('notes/show', () => { + it('投稿が取得できる', async(async () => { + const myPost = await post(alice, { + text: 'test' + }); + + const res = await request('/notes/show', { + noteId: myPost.id + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.id, myPost.id); + assert.strictEqual(res.body.text, myPost.text); + })); + + it('投稿が存在しなかったら怒る', async(async () => { + const res = await request('/notes/show', { + noteId: '000000000000000000000000' + }); + assert.strictEqual(res.status, 400); + })); + + it('間違ったIDで怒られる', async(async () => { + const res = await request('/notes/show', { + noteId: 'kyoppie' + }); + assert.strictEqual(res.status, 400); + })); + }); + + describe('notes/reactions/create', () => { + it('リアクションできる', async(async () => { + const bobPost = await post(bob); + + const alice = await signup({ username: 'alice' }); + const res = await request('/notes/reactions/create', { + noteId: bobPost.id, + reaction: '🚀', + }, alice); + + assert.strictEqual(res.status, 204); + + const resNote = await request('/notes/show', { + noteId: bobPost.id, + }, alice); + + assert.strictEqual(resNote.status, 200); + assert.strictEqual(resNote.body.reactions['🚀'], [alice.id]); + })); + + it('自分の投稿にもリアクションできる', async(async () => { + const myPost = await post(alice); + + const res = await request('/notes/reactions/create', { + noteId: myPost.id, + reaction: '🚀', + }, alice); + + assert.strictEqual(res.status, 204); + })); + + it('二重にリアクションできない', async(async () => { + const bobPost = await post(bob); + + await react(alice, bobPost, 'like'); + + const res = await request('/notes/reactions/create', { + noteId: bobPost.id, + reaction: '🚀', + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('存在しない投稿にはリアクションできない', async(async () => { + const res = await request('/notes/reactions/create', { + noteId: '000000000000000000000000', + reaction: '🚀', + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('空のパラメータで怒られる', async(async () => { + const res = await request('/notes/reactions/create', {}, alice); + + assert.strictEqual(res.status, 400); + })); + + it('間違ったIDで怒られる', async(async () => { + const res = await request('/notes/reactions/create', { + noteId: 'kyoppie', + reaction: '🚀', + }, alice); + + assert.strictEqual(res.status, 400); + })); + }); + + describe('following/create', () => { + it('フォローできる', async(async () => { + const res = await request('/following/create', { + userId: alice.id + }, bob); + + assert.strictEqual(res.status, 200); + })); + + it('既にフォローしている場合は怒る', async(async () => { + const res = await request('/following/create', { + userId: alice.id + }, bob); + + assert.strictEqual(res.status, 400); + })); + + it('存在しないユーザーはフォローできない', async(async () => { + const res = await request('/following/create', { + userId: '000000000000000000000000' + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('自分自身はフォローできない', async(async () => { + const res = await request('/following/create', { + userId: alice.id + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('空のパラメータで怒られる', async(async () => { + const res = await request('/following/create', {}, alice); + + assert.strictEqual(res.status, 400); + })); + + it('間違ったIDで怒られる', async(async () => { + const res = await request('/following/create', { + userId: 'foo' + }, alice); + + assert.strictEqual(res.status, 400); + })); + }); + + describe('following/delete', () => { + it('フォロー解除できる', async(async () => { + await request('/following/create', { + userId: alice.id + }, bob); + + const res = await request('/following/delete', { + userId: alice.id + }, bob); + + assert.strictEqual(res.status, 200); + })); + + it('フォローしていない場合は怒る', async(async () => { + const res = await request('/following/delete', { + userId: alice.id + }, bob); + + assert.strictEqual(res.status, 400); + })); + + it('存在しないユーザーはフォロー解除できない', async(async () => { + const res = await request('/following/delete', { + userId: '000000000000000000000000' + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('自分自身はフォロー解除できない', async(async () => { + const res = await request('/following/delete', { + userId: alice.id + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('空のパラメータで怒られる', async(async () => { + const res = await request('/following/delete', {}, alice); + + assert.strictEqual(res.status, 400); + })); + + it('間違ったIDで怒られる', async(async () => { + const res = await request('/following/delete', { + userId: 'kyoppie' + }, alice); + + assert.strictEqual(res.status, 400); + })); + }); + + describe('drive', () => { + it('ドライブ情報を取得できる', async(async () => { + await uploadFile({ + userId: alice.id, + size: 256 + }); + await uploadFile({ + userId: alice.id, + size: 512 + }); + await uploadFile({ + userId: alice.id, + size: 1024 + }); + const res = await request('/drive', {}, alice); + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + expect(res.body).have.property('usage').eql(1792); + })); + }); + + describe('drive/files/create', () => { + it('ファイルを作成できる', async(async () => { + const res = await uploadFile(alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.name, 'Lenna.png'); + })); + + it('ファイルに名前を付けられる', async(async () => { + const res = await assert.request(server) + .post('/drive/files/create') + .field('i', alice.token) + .field('name', 'Belmond.png') + .attach('file', fs.readFileSync(__dirname + '/resources/Lenna.png'), 'Lenna.png'); + + expect(res).have.status(200); + expect(res.body).be.a('object'); + expect(res.body).have.property('name').eql('Belmond.png'); + })); + + it('ファイル無しで怒られる', async(async () => { + const res = await request('/drive/files/create', {}, alice); + + assert.strictEqual(res.status, 400); + })); + + it('SVGファイルを作成できる', async(async () => { + const res = await uploadFile(alice, __dirname + '/resources/image.svg'); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.name, 'image.svg'); + assert.strictEqual(res.body.type, 'image/svg+xml'); + })); + }); + + describe('drive/files/update', () => { + it('名前を更新できる', async(async () => { + const file = await uploadFile(alice); + const newName = 'いちごパスタ.png'; + + const res = await request('/drive/files/update', { + fileId: file.id, + name: newName + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.name, newName); + })); + + it('他人のファイルは更新できない', async(async () => { + const file = await uploadFile(bob); + + const res = await request('/drive/files/update', { + fileId: file.id, + name: 'いちごパスタ.png' + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('親フォルダを更新できる', async(async () => { + const file = await uploadFile(alice); + const folder = (await request('/drive/folders/create', { + name: 'test' + }, alice)).body; + + const res = await request('/drive/files/update', { + fileId: file.id, + folderId: folder.id + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.folderId, folder.id); + })); + + it('親フォルダを無しにできる', async(async () => { + const file = await uploadFile(alice); + + const folder = (await request('/drive/folders/create', { + name: 'test' + }, alice)).body; + + await request('/drive/files/update', { + fileId: file.id, + folderId: folder.id + }, alice); + + const res = await request('/drive/files/update', { + fileId: file.id, + folderId: null + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.folderId, null); + })); + + it('他人のフォルダには入れられない', async(async () => { + const file = await uploadFile(alice); + const folder = (await request('/drive/folders/create', { + name: 'test' + }, bob)).body; + + const res = await request('/drive/files/update', { + fileId: file.id, + folderId: folder.id + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('存在しないフォルダで怒られる', async(async () => { + const file = await uploadFile(alice); + + const res = await request('/drive/files/update', { + fileId: file.id, + folderId: '000000000000000000000000' + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('不正なフォルダIDで怒られる', async(async () => { + const file = await uploadFile(alice); + + const res = await request('/drive/files/update', { + fileId: file.id, + folderId: 'foo' + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('ファイルが存在しなかったら怒る', async(async () => { + const res = await request('/drive/files/update', { + fileId: '000000000000000000000000', + name: 'いちごパスタ.png' + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('間違ったIDで怒られる', async(async () => { + const res = await request('/drive/files/update', { + fileId: 'kyoppie', + name: 'いちごパスタ.png' + }, alice); + + assert.strictEqual(res.status, 400); + })); + }); + + describe('drive/folders/create', () => { + it('フォルダを作成できる', async(async () => { + const res = await request('/drive/folders/create', { + name: 'test' + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.name, 'test'); + })); + }); + + describe('drive/folders/update', () => { + it('名前を更新できる', async(async () => { + const folder = (await request('/drive/folders/create', { + name: 'test' + }, alice)).body; + + const res = await request('/drive/folders/update', { + folderId: folder.id, + name: 'new name' + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.name, 'new name'); + })); + + it('他人のフォルダを更新できない', async(async () => { + const folder = (await request('/drive/folders/create', { + name: 'test' + }, bob)).body; + + const res = await request('/drive/folders/update', { + folderId: folder.id, + name: 'new name' + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('親フォルダを更新できる', async(async () => { + const folder = (await request('/drive/folders/create', { + name: 'test' + }, alice)).body; + const parentFolder = (await request('/drive/folders/create', { + name: 'parent' + }, alice)).body; + + const res = await request('/drive/folders/update', { + folderId: folder.id, + parentId: parentFolder.id + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.parentId, parentFolder.id); + })); + + it('親フォルダを無しに更新できる', async(async () => { + const folder = (await request('/drive/folders/create', { + name: 'test' + }, alice)).body; + const parentFolder = (await request('/drive/folders/create', { + name: 'parent' + }, alice)).body; + await request('/drive/folders/update', { + folderId: folder.id, + parentId: parentFolder.id + }, alice); + + const res = await request('/drive/folders/update', { + folderId: folder.id, + parentId: null + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.parentId, null); + })); + + it('他人のフォルダを親フォルダに設定できない', async(async () => { + const folder = (await request('/drive/folders/create', { + name: 'test' + }, alice)).body; + const parentFolder = (await request('/drive/folders/create', { + name: 'parent' + }, bob)).body; + + const res = await request('/drive/folders/update', { + folderId: folder.id, + parentId: parentFolder.id + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('フォルダが循環するような構造にできない', async(async () => { + const folder = (await request('/drive/folders/create', { + name: 'test' + }, alice)).body; + const parentFolder = (await request('/drive/folders/create', { + name: 'parent' + }, alice)).body; + await request('/drive/folders/update', { + folderId: parentFolder.id, + parentId: folder.id + }, alice); + + const res = await request('/drive/folders/update', { + folderId: folder.id, + parentId: parentFolder.id + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('フォルダが循環するような構造にできない(再帰的)', async(async () => { + const folderA = (await request('/drive/folders/create', { + name: 'test' + }, alice)).body; + const folderB = (await request('/drive/folders/create', { + name: 'test' + }, alice)).body; + const folderC = (await request('/drive/folders/create', { + name: 'test' + }, alice)).body; + await request('/drive/folders/update', { + folderId: folderB.id, + parentId: folderA.id + }, alice); + await request('/drive/folders/update', { + folderId: folderC.id, + parentId: folderB.id + }, alice); + + const res = await request('/drive/folders/update', { + folderId: folderA.id, + parentId: folderC.id + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('フォルダが循環するような構造にできない(自身)', async(async () => { + const folderA = (await request('/drive/folders/create', { + name: 'test' + }, alice)).body; + + const res = await request('/drive/folders/update', { + folderId: folderA.id, + parentId: folderA.id + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('存在しない親フォルダを設定できない', async(async () => { + const folder = (await request('/drive/folders/create', { + name: 'test' + }, alice)).body; + + const res = await request('/drive/folders/update', { + folderId: folder.id, + parentId: '000000000000000000000000' + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('不正な親フォルダIDで怒られる', async(async () => { + const folder = (await request('/drive/folders/create', { + name: 'test' + }, alice)).body; + + const res = await request('/drive/folders/update', { + folderId: folder.id, + parentId: 'foo' + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('存在しないフォルダを更新できない', async(async () => { + const res = await request('/drive/folders/update', { + folderId: '000000000000000000000000' + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('不正なフォルダIDで怒られる', async(async () => { + const res = await request('/drive/folders/update', { + folderId: 'foo' + }, alice); + + assert.strictEqual(res.status, 400); + })); + }); + + describe('messaging/messages/create', () => { + it('メッセージを送信できる', async(async () => { + const res = await request('/messaging/messages/create', { + userId: bob.id, + text: 'test' + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.text, 'test'); + })); + + it('自分自身にはメッセージを送信できない', async(async () => { + const res = await request('/messaging/messages/create', { + userId: alice.id, + text: 'Yo' + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('存在しないユーザーにはメッセージを送信できない', async(async () => { + const res = await request('/messaging/messages/create', { + userId: '000000000000000000000000', + text: 'test' + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('不正なユーザーIDで怒られる', async(async () => { + const res = await request('/messaging/messages/create', { + userId: 'foo', + text: 'test' + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('テキストが無くて怒られる', async(async () => { + const res = await request('/messaging/messages/create', { + userId: bob.id + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('文字数オーバーで怒られる', async(async () => { + const res = await request('/messaging/messages/create', { + userId: bob.id, + text: '!'.repeat(1001) + }, alice); + + assert.strictEqual(res.status, 400); + })); + }); + + describe('notes/replies', () => { + it('自分に閲覧権限のない投稿は含まれない', async(async () => { + const alicePost = await post(alice, { + text: 'foo' + }); + + await post(bob, { + replyId: alicePost.id, + text: 'bar', + visibility: 'specified', + visibleUserIds: [alice.id] + }); + + const res = await request('/notes/replies', { + noteId: alicePost.id + }, carol); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 0); + })); + }); + + describe('notes/timeline', () => { + it('フォロワー限定投稿が含まれる', async(async () => { + await request('/following/create', { + userId: alice.id + }, bob); + + const alicePost = await post(alice, { + text: 'foo', + visibility: 'followers' + }); + + const res = await request('/notes/timeline', {}, bob); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 1); + assert.strictEqual(res.body[0].id, alicePost.id); + })); + }); +}); +*/ diff --git a/packages/backend/test/extract-mentions.ts b/packages/backend/test/extract-mentions.ts new file mode 100644 index 0000000000..85afb098d8 --- /dev/null +++ b/packages/backend/test/extract-mentions.ts @@ -0,0 +1,42 @@ +import * as assert from 'assert'; + +import { parse } from 'mfm-js'; +import { extractMentions } from '../src/misc/extract-mentions.js'; + +describe('Extract mentions', () => { + it('simple', () => { + const ast = parse('@foo @bar @baz')!; + const mentions = extractMentions(ast); + assert.deepStrictEqual(mentions, [{ + username: 'foo', + acct: '@foo', + host: null, + }, { + username: 'bar', + acct: '@bar', + host: null, + }, { + username: 'baz', + acct: '@baz', + host: null, + }]); + }); + + it('nested', () => { + const ast = parse('@foo **@bar** @baz')!; + const mentions = extractMentions(ast); + assert.deepStrictEqual(mentions, [{ + username: 'foo', + acct: '@foo', + host: null, + }, { + username: 'bar', + acct: '@bar', + host: null, + }, { + username: 'baz', + acct: '@baz', + host: null, + }]); + }); +}); diff --git a/packages/backend/test/fetch-resource.ts b/packages/backend/test/fetch-resource.ts new file mode 100644 index 0000000000..ddb0e94b86 --- /dev/null +++ b/packages/backend/test/fetch-resource.ts @@ -0,0 +1,205 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import * as childProcess from 'child_process'; +import * as openapi from '@redocly/openapi-core'; +import { async, startServer, signup, post, request, simpleGet, port, shutdownServer } from './utils.js'; + +// Request Accept +const ONLY_AP = 'application/activity+json'; +const PREFER_AP = 'application/activity+json, */*'; +const PREFER_HTML = 'text/html, */*'; +const UNSPECIFIED = '*/*'; + +// Response Contet-Type +const AP = 'application/activity+json; charset=utf-8'; +const JSON = 'application/json; charset=utf-8'; +const HTML = 'text/html; charset=utf-8'; + +describe('Fetch resource', () => { + let p: childProcess.ChildProcess; + + let alice: any; + let alicesPost: any; + + before(async () => { + p = await startServer(); + alice = await signup({ username: 'alice' }); + alicesPost = await post(alice, { + text: 'test', + }); + }); + + after(async () => { + await shutdownServer(p); + }); + + describe('Common', () => { + it('meta', async(async () => { + const res = await request('/meta', { + }); + + assert.strictEqual(res.status, 200); + })); + + it('GET root', async(async () => { + const res = await simpleGet('/'); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, HTML); + })); + + it('GET docs', async(async () => { + const res = await simpleGet('/docs/ja-JP/about'); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, HTML); + })); + + it('GET api-doc', async(async () => { + const res = await simpleGet('/api-doc'); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, HTML); + })); + + it('GET api.json', async(async () => { + const res = await simpleGet('/api.json'); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, JSON); + })); + + it('Validate api.json', async(async () => { + const config = await openapi.loadConfig(); + const result = await openapi.bundle({ + config, + ref: `http://localhost:${port}/api.json`, + }); + + for (const problem of result.problems) { + console.log(`${problem.message} - ${problem.location[0]?.pointer}`); + } + + assert.strictEqual(result.problems.length, 0); + })); + + it('GET favicon.ico', async(async () => { + const res = await simpleGet('/favicon.ico'); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, 'image/x-icon'); + })); + + it('GET apple-touch-icon.png', async(async () => { + const res = await simpleGet('/apple-touch-icon.png'); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, 'image/png'); + })); + + it('GET twemoji svg', async(async () => { + const res = await simpleGet('/twemoji/2764.svg'); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, 'image/svg+xml'); + })); + + it('GET twemoji svg with hyphen', async(async () => { + const res = await simpleGet('/twemoji/2764-fe0f-200d-1f525.svg'); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, 'image/svg+xml'); + })); + }); + + describe('/@:username', () => { + it('Only AP => AP', async(async () => { + const res = await simpleGet(`/@${alice.username}`, ONLY_AP); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, AP); + })); + + it('Prefer AP => AP', async(async () => { + const res = await simpleGet(`/@${alice.username}`, PREFER_AP); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, AP); + })); + + it('Prefer HTML => HTML', async(async () => { + const res = await simpleGet(`/@${alice.username}`, PREFER_HTML); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, HTML); + })); + + it('Unspecified => HTML', async(async () => { + const res = await simpleGet(`/@${alice.username}`, UNSPECIFIED); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, HTML); + })); + }); + + describe('/users/:id', () => { + it('Only AP => AP', async(async () => { + const res = await simpleGet(`/users/${alice.id}`, ONLY_AP); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, AP); + })); + + it('Prefer AP => AP', async(async () => { + const res = await simpleGet(`/users/${alice.id}`, PREFER_AP); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, AP); + })); + + it('Prefer HTML => Redirect to /@:username', async(async () => { + const res = await simpleGet(`/users/${alice.id}`, PREFER_HTML); + assert.strictEqual(res.status, 302); + assert.strictEqual(res.location, `/@${alice.username}`); + })); + + it('Undecided => HTML', async(async () => { + const res = await simpleGet(`/users/${alice.id}`, UNSPECIFIED); + assert.strictEqual(res.status, 302); + assert.strictEqual(res.location, `/@${alice.username}`); + })); + }); + + describe('/notes/:id', () => { + it('Only AP => AP', async(async () => { + const res = await simpleGet(`/notes/${alicesPost.id}`, ONLY_AP); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, AP); + })); + + it('Prefer AP => AP', async(async () => { + const res = await simpleGet(`/notes/${alicesPost.id}`, PREFER_AP); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, AP); + })); + + it('Prefer HTML => HTML', async(async () => { + const res = await simpleGet(`/notes/${alicesPost.id}`, PREFER_HTML); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, HTML); + })); + + it('Unspecified => HTML', async(async () => { + const res = await simpleGet(`/notes/${alicesPost.id}`, UNSPECIFIED); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, HTML); + })); + }); + + describe('Feeds', () => { + it('RSS', async(async () => { + const res = await simpleGet(`/@${alice.username}.rss`, UNSPECIFIED); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, 'application/rss+xml; charset=utf-8'); + })); + + it('ATOM', async(async () => { + const res = await simpleGet(`/@${alice.username}.atom`, UNSPECIFIED); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, 'application/atom+xml; charset=utf-8'); + })); + + it('JSON', async(async () => { + const res = await simpleGet(`/@${alice.username}.json`, UNSPECIFIED); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, 'application/json; charset=utf-8'); + })); + }); +}); diff --git a/packages/backend/test/ff-visibility.ts b/packages/backend/test/ff-visibility.ts new file mode 100644 index 0000000000..4f6847be6d --- /dev/null +++ b/packages/backend/test/ff-visibility.ts @@ -0,0 +1,167 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import * as childProcess from 'child_process'; +import { async, signup, request, post, react, connectStream, startServer, shutdownServer, simpleGet } from './utils.js'; + +describe('FF visibility', () => { + let p: childProcess.ChildProcess; + + let alice: any; + let bob: any; + let carol: any; + + before(async () => { + p = await startServer(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + carol = await signup({ username: 'carol' }); + }); + + after(async () => { + await shutdownServer(p); + }); + + it('ffVisibility が public なユーザーのフォロー/フォロワーを誰でも見れる', async(async () => { + await request('/i/update', { + ffVisibility: 'public', + }, alice); + + const followingRes = await request('/users/following', { + userId: alice.id, + }, bob); + const followersRes = await request('/users/followers', { + userId: alice.id, + }, bob); + + assert.strictEqual(followingRes.status, 200); + assert.strictEqual(Array.isArray(followingRes.body), true); + assert.strictEqual(followersRes.status, 200); + assert.strictEqual(Array.isArray(followersRes.body), true); + })); + + it('ffVisibility が followers なユーザーのフォロー/フォロワーを自分で見れる', async(async () => { + await request('/i/update', { + ffVisibility: 'followers', + }, alice); + + const followingRes = await request('/users/following', { + userId: alice.id, + }, alice); + const followersRes = await request('/users/followers', { + userId: alice.id, + }, alice); + + assert.strictEqual(followingRes.status, 200); + assert.strictEqual(Array.isArray(followingRes.body), true); + assert.strictEqual(followersRes.status, 200); + assert.strictEqual(Array.isArray(followersRes.body), true); + })); + + it('ffVisibility が followers なユーザーのフォロー/フォロワーを非フォロワーが見れない', async(async () => { + await request('/i/update', { + ffVisibility: 'followers', + }, alice); + + const followingRes = await request('/users/following', { + userId: alice.id, + }, bob); + const followersRes = await request('/users/followers', { + userId: alice.id, + }, bob); + + assert.strictEqual(followingRes.status, 400); + assert.strictEqual(followersRes.status, 400); + })); + + it('ffVisibility が followers なユーザーのフォロー/フォロワーをフォロワーが見れる', async(async () => { + await request('/i/update', { + ffVisibility: 'followers', + }, alice); + + await request('/following/create', { + userId: alice.id, + }, bob); + + const followingRes = await request('/users/following', { + userId: alice.id, + }, bob); + const followersRes = await request('/users/followers', { + userId: alice.id, + }, bob); + + assert.strictEqual(followingRes.status, 200); + assert.strictEqual(Array.isArray(followingRes.body), true); + assert.strictEqual(followersRes.status, 200); + assert.strictEqual(Array.isArray(followersRes.body), true); + })); + + it('ffVisibility が private なユーザーのフォロー/フォロワーを自分で見れる', async(async () => { + await request('/i/update', { + ffVisibility: 'private', + }, alice); + + const followingRes = await request('/users/following', { + userId: alice.id, + }, alice); + const followersRes = await request('/users/followers', { + userId: alice.id, + }, alice); + + assert.strictEqual(followingRes.status, 200); + assert.strictEqual(Array.isArray(followingRes.body), true); + assert.strictEqual(followersRes.status, 200); + assert.strictEqual(Array.isArray(followersRes.body), true); + })); + + it('ffVisibility が private なユーザーのフォロー/フォロワーを他人が見れない', async(async () => { + await request('/i/update', { + ffVisibility: 'private', + }, alice); + + const followingRes = await request('/users/following', { + userId: alice.id, + }, bob); + const followersRes = await request('/users/followers', { + userId: alice.id, + }, bob); + + assert.strictEqual(followingRes.status, 400); + assert.strictEqual(followersRes.status, 400); + })); + + describe('AP', () => { + it('ffVisibility が public 以外ならばAPからは取得できない', async(async () => { + { + await request('/i/update', { + ffVisibility: 'public', + }, alice); + + const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json'); + const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json'); + assert.strictEqual(followingRes.status, 200); + assert.strictEqual(followersRes.status, 200); + } + { + await request('/i/update', { + ffVisibility: 'followers', + }, alice); + + const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json').catch(res => ({ status: res.statusCode })); + const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json').catch(res => ({ status: res.statusCode })); + assert.strictEqual(followingRes.status, 403); + assert.strictEqual(followersRes.status, 403); + } + { + await request('/i/update', { + ffVisibility: 'private', + }, alice); + + const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json').catch(res => ({ status: res.statusCode })); + const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json').catch(res => ({ status: res.statusCode })); + assert.strictEqual(followingRes.status, 403); + assert.strictEqual(followersRes.status, 403); + } + })); + }); +}); diff --git a/packages/backend/test/get-file-info.ts b/packages/backend/test/get-file-info.ts new file mode 100644 index 0000000000..09378fec88 --- /dev/null +++ b/packages/backend/test/get-file-info.ts @@ -0,0 +1,191 @@ +import * as assert from 'assert'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import { getFileInfo } from '../src/misc/get-file-info.js'; +import { async } from './utils.js'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +describe('Get file info', () => { + it('Empty file', async (async () => { + const path = `${_dirname}/resources/emptyfile`; + const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 0, + md5: 'd41d8cd98f00b204e9800998ecf8427e', + type: { + mime: 'application/octet-stream', + ext: null, + }, + width: undefined, + height: undefined, + orientation: undefined, + }); + })); + + it('Generic JPEG', async (async () => { + const path = `${_dirname}/resources/Lenna.jpg`; + const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 25360, + md5: '091b3f259662aa31e2ffef4519951168', + type: { + mime: 'image/jpeg', + ext: 'jpg', + }, + width: 512, + height: 512, + orientation: undefined, + }); + })); + + it('Generic APNG', async (async () => { + const path = `${_dirname}/resources/anime.png`; + const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 1868, + md5: '08189c607bea3b952704676bb3c979e0', + type: { + mime: 'image/apng', + ext: 'apng', + }, + width: 256, + height: 256, + orientation: undefined, + }); + })); + + it('Generic AGIF', async (async () => { + const path = `${_dirname}/resources/anime.gif`; + const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 2248, + md5: '32c47a11555675d9267aee1a86571e7e', + type: { + mime: 'image/gif', + ext: 'gif', + }, + width: 256, + height: 256, + orientation: undefined, + }); + })); + + it('PNG with alpha', async (async () => { + const path = `${_dirname}/resources/with-alpha.png`; + const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 3772, + md5: 'f73535c3e1e27508885b69b10cf6e991', + type: { + mime: 'image/png', + ext: 'png', + }, + width: 256, + height: 256, + orientation: undefined, + }); + })); + + it('Generic SVG', async (async () => { + const path = `${_dirname}/resources/image.svg`; + const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 505, + md5: 'b6f52b4b021e7b92cdd04509c7267965', + type: { + mime: 'image/svg+xml', + ext: 'svg', + }, + width: 256, + height: 256, + orientation: undefined, + }); + })); + + it('SVG with XML definition', async (async () => { + // https://github.com/misskey-dev/misskey/issues/4413 + const path = `${_dirname}/resources/with-xml-def.svg`; + const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 544, + md5: '4b7a346cde9ccbeb267e812567e33397', + type: { + mime: 'image/svg+xml', + ext: 'svg', + }, + width: 256, + height: 256, + orientation: undefined, + }); + })); + + it('Dimension limit', async (async () => { + const path = `${_dirname}/resources/25000x25000.png`; + const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 75933, + md5: '268c5dde99e17cf8fe09f1ab3f97df56', + type: { + mime: 'application/octet-stream', // do not treat as image + ext: null, + }, + width: 25000, + height: 25000, + orientation: undefined, + }); + })); + + it('Rotate JPEG', async (async () => { + const path = `${_dirname}/resources/rotate.jpg`; + const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 12624, + md5: '68d5b2d8d1d1acbbce99203e3ec3857e', + type: { + mime: 'image/jpeg', + ext: 'jpg', + }, + width: 512, + height: 256, + orientation: 8, + }); + })); +}); diff --git a/packages/backend/test/loader.js b/packages/backend/test/loader.js new file mode 100644 index 0000000000..6b21587e32 --- /dev/null +++ b/packages/backend/test/loader.js @@ -0,0 +1,34 @@ +/** + * ts-node/esmローダーに投げる前にpath mappingを解決する + * 参考 + * - https://github.com/TypeStrong/ts-node/discussions/1450#discussioncomment-1806115 + * - https://nodejs.org/api/esm.html#loaders + * ※ https://github.com/TypeStrong/ts-node/pull/1585 が取り込まれたらこのカスタムローダーは必要なくなる + */ + +import { resolve as resolveTs, load } from 'ts-node/esm'; +import { loadConfig, createMatchPath } from 'tsconfig-paths'; +import { pathToFileURL } from 'url'; + +const tsconfig = loadConfig(); +const matchPath = createMatchPath(tsconfig.absoluteBaseUrl, tsconfig.paths); + +export function resolve(specifier, ctx, defaultResolve) { + let resolvedSpecifier; + if (specifier.endsWith('.js')) { + // maybe transpiled + const specifierWithoutExtension = specifier.substring(0, specifier.length - '.js'.length); + const matchedSpecifier = matchPath(specifierWithoutExtension); + if (matchedSpecifier) { + resolvedSpecifier = pathToFileURL(`${matchedSpecifier}.js`).href; + } + } else { + const matchedSpecifier = matchPath(specifier); + if (matchedSpecifier) { + resolvedSpecifier = pathToFileURL(matchedSpecifier).href; + } + } + return resolveTs(resolvedSpecifier ?? specifier, ctx, defaultResolve); +} + +export { load }; diff --git a/packages/backend/test/mfm.ts b/packages/backend/test/mfm.ts new file mode 100644 index 0000000000..5218942a5a --- /dev/null +++ b/packages/backend/test/mfm.ts @@ -0,0 +1,89 @@ +import * as assert from 'assert'; +import * as mfm from 'mfm-js'; + +import { toHtml } from '../src/mfm/to-html.js'; +import { fromHtml } from '../src/mfm/from-html.js'; + +describe('toHtml', () => { + it('br', () => { + const input = 'foo\nbar\nbaz'; + const output = '

foo
bar
baz

'; + assert.equal(toHtml(mfm.parse(input)), output); + }); + + it('br alt', () => { + const input = 'foo\r\nbar\rbaz'; + const output = '

foo
bar
baz

'; + assert.equal(toHtml(mfm.parse(input)), output); + }); +}); + +describe('fromHtml', () => { + it('p', () => { + assert.deepStrictEqual(fromHtml('

a

b

'), 'a\n\nb'); + }); + + it('block element', () => { + assert.deepStrictEqual(fromHtml('
a
b
'), 'a\nb'); + }); + + it('inline element', () => { + assert.deepStrictEqual(fromHtml('
  • a
  • b
'), 'a\nb'); + }); + + it('block code', () => { + assert.deepStrictEqual(fromHtml('
a\nb
'), '```\na\nb\n```'); + }); + + it('inline code', () => { + assert.deepStrictEqual(fromHtml('a'), '`a`'); + }); + + it('quote', () => { + assert.deepStrictEqual(fromHtml('
a\nb
'), '> a\n> b'); + }); + + it('br', () => { + assert.deepStrictEqual(fromHtml('

abc

d

'), 'abc\n\nd'); + }); + + it('link with different text', () => { + assert.deepStrictEqual(fromHtml('

a c d

'), 'a [c](https://example.com/b) d'); + }); + + it('link with different text, but not encoded', () => { + assert.deepStrictEqual(fromHtml('

a c d

'), 'a [c]() d'); + }); + + it('link with same text', () => { + assert.deepStrictEqual(fromHtml('

a https://example.com/b d

'), 'a https://example.com/b d'); + }); + + it('link with same text, but not encoded', () => { + assert.deepStrictEqual(fromHtml('

a https://example.com/ä d

'), 'a d'); + }); + + it('link with no url', () => { + assert.deepStrictEqual(fromHtml('

a c d

'), 'a [c](b) d'); + }); + + it('link without href', () => { + assert.deepStrictEqual(fromHtml('

a c d

'), 'a c d'); + }); + + it('link without text', () => { + assert.deepStrictEqual(fromHtml('

a d

'), 'a https://example.com/b d'); + }); + + it('link without both', () => { + assert.deepStrictEqual(fromHtml('

a d

'), 'a d'); + }); + + it('mention', () => { + assert.deepStrictEqual(fromHtml('

a @user d

'), 'a @user@example.com d'); + }); + + it('hashtag', () => { + assert.deepStrictEqual(fromHtml('

a #a d

', ['#a']), 'a #a d'); + }); +}); diff --git a/packages/backend/test/misc/mock-resolver.ts b/packages/backend/test/misc/mock-resolver.ts index 7cba7a2aa8..ba89ac329a 100644 --- a/packages/backend/test/misc/mock-resolver.ts +++ b/packages/backend/test/misc/mock-resolver.ts @@ -1,21 +1,5 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Config } from '@/config.js'; -import type { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; -import type { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import type { ApRequestService } from '@/core/activitypub/ApRequestService.js'; -import { Resolver } from '@/core/activitypub/ApResolverService.js'; -import type { IObject } from '@/core/activitypub/type.js'; -import type { HttpRequestService } from '@/core/HttpRequestService.js'; -import type { InstanceActorService } from '@/core/InstanceActorService.js'; -import type { LoggerService } from '@/core/LoggerService.js'; -import type { MetaService } from '@/core/MetaService.js'; -import type { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; -import type { NoteReactionsRepository, NotesRepository, PollsRepository, UsersRepository, FollowRequestsRepository } from '@/models/_.js'; +import Resolver from '../../src/remote/activitypub/resolver.js'; +import { IObject } from '../../src/remote/activitypub/type.js'; type MockResponse = { type: string; @@ -23,53 +7,25 @@ type MockResponse = { }; export class MockResolver extends Resolver { - #responseMap = new Map(); - #remoteGetTrials: string[] = []; - - constructor(loggerService: LoggerService) { - super( - {} as Config, - {} as UsersRepository, - {} as NotesRepository, - {} as PollsRepository, - {} as NoteReactionsRepository, - {} as FollowRequestsRepository, - {} as UtilityService, - {} as InstanceActorService, - {} as MetaService, - {} as ApRequestService, - {} as HttpRequestService, - {} as ApRendererService, - {} as ApDbResolverService, - loggerService, - ); - } - - public register(uri: string, content: string | Record, type = 'application/activity+json'): void { - this.#responseMap.set(uri, { + private _rs = new Map(); + public async _register(uri: string, content: string | Record, type = 'application/activity+json') { + this._rs.set(uri, { type, content: typeof content === 'string' ? content : JSON.stringify(content), }); } - public clear(): void { - this.#responseMap.clear(); - this.#remoteGetTrials.length = 0; - } - - public remoteGetTrials(): string[] { - return this.#remoteGetTrials; - } - - @bindThis public async resolve(value: string | IObject): Promise { if (typeof value !== 'string') return value; - this.#remoteGetTrials.push(value); - const r = this.#responseMap.get(value); + const r = this._rs.get(value); if (!r) { - throw new Error('Not registed for mock'); + throw { + name: 'StatusError', + statusCode: 404, + message: 'Not registed for mock', + }; } const object = JSON.parse(r.content); diff --git a/packages/backend/test/mute.ts b/packages/backend/test/mute.ts new file mode 100644 index 0000000000..465633973c --- /dev/null +++ b/packages/backend/test/mute.ts @@ -0,0 +1,123 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import * as childProcess from 'child_process'; +import { async, signup, request, post, react, startServer, shutdownServer, waitFire } from './utils.js'; + +describe('Mute', () => { + let p: childProcess.ChildProcess; + + // alice mutes carol + let alice: any; + let bob: any; + let carol: any; + + before(async () => { + p = await startServer(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + carol = await signup({ username: 'carol' }); + }); + + after(async () => { + await shutdownServer(p); + }); + + it('ミュート作成', async(async () => { + const res = await request('/mute/create', { + userId: carol.id, + }, alice); + + assert.strictEqual(res.status, 204); + })); + + it('「自分宛ての投稿」にミュートしているユーザーの投稿が含まれない', async(async () => { + const bobNote = await post(bob, { text: '@alice hi' }); + const carolNote = await post(carol, { text: '@alice hi' }); + + const res = await request('/notes/mentions', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + })); + + it('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async(async () => { + // 状態リセット + await request('/i/read-all-unread-notes', {}, alice); + + await post(carol, { text: '@alice hi' }); + + const res = await request('/i', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.hasUnreadMentions, false); + })); + + it('ミュートしているユーザーからメンションされても、ストリームに unreadMention イベントが流れてこない', async () => { + // 状態リセット + await request('/i/read-all-unread-notes', {}, alice); + + const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadMention'); + + assert.strictEqual(fired, false); + }); + + it('ミュートしているユーザーからメンションされても、ストリームに unreadNotification イベントが流れてこない', async () => { + // 状態リセット + await request('/i/read-all-unread-notes', {}, alice); + await request('/notifications/mark-all-as-read', {}, alice); + + const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadNotification'); + + assert.strictEqual(fired, false); + }); + + describe('Timeline', () => { + it('タイムラインにミュートしているユーザーの投稿が含まれない', async(async () => { + const aliceNote = await post(alice); + const bobNote = await post(bob); + const carolNote = await post(carol); + + const res = await request('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + })); + + it('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async(async () => { + const aliceNote = await post(alice); + const carolNote = await post(carol); + const bobNote = await post(bob, { + renoteId: carolNote.id, + }); + + const res = await request('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + })); + }); + + describe('Notification', () => { + it('通知にミュートしているユーザーの通知が含まれない(リアクション)', async(async () => { + const aliceNote = await post(alice); + await react(bob, aliceNote, 'like'); + await react(carol, aliceNote, 'like'); + + const res = await request('/i/notifications', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); + assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + })); + }); +}); diff --git a/packages/backend/test/note.ts b/packages/backend/test/note.ts new file mode 100644 index 0000000000..b495d8b7bb --- /dev/null +++ b/packages/backend/test/note.ts @@ -0,0 +1,370 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import * as childProcess from 'child_process'; +import { Note } from '../src/models/entities/note.js'; +import { async, signup, request, post, uploadUrl, startServer, shutdownServer, initTestDb, api } from './utils.js'; + +describe('Note', () => { + let p: childProcess.ChildProcess; + let Notes: any; + + let alice: any; + let bob: any; + + before(async () => { + p = await startServer(); + const connection = await initTestDb(true); + Notes = connection.getRepository(Note); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + }); + + after(async () => { + await shutdownServer(p); + }); + + it('投稿できる', async(async () => { + const post = { + text: 'test', + }; + + const res = await request('/notes/create', post, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.createdNote.text, post.text); + })); + + it('ファイルを添付できる', async(async () => { + const file = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); + + const res = await request('/notes/create', { + fileIds: [file.id], + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.deepStrictEqual(res.body.createdNote.fileIds, [file.id]); + })); + + it('他人のファイルは無視', async(async () => { + const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); + + const res = await request('/notes/create', { + text: 'test', + fileIds: [file.id], + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.deepStrictEqual(res.body.createdNote.fileIds, []); + })); + + it('存在しないファイルは無視', async(async () => { + const res = await request('/notes/create', { + text: 'test', + fileIds: ['000000000000000000000000'], + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.deepStrictEqual(res.body.createdNote.fileIds, []); + })); + + it('不正なファイルIDは無視', async(async () => { + const res = await request('/notes/create', { + fileIds: ['kyoppie'], + }, alice); + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.deepStrictEqual(res.body.createdNote.fileIds, []); + })); + + it('返信できる', async(async () => { + const bobPost = await post(bob, { + text: 'foo', + }); + + const alicePost = { + text: 'bar', + replyId: bobPost.id, + }; + + const res = await request('/notes/create', alicePost, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.createdNote.text, alicePost.text); + assert.strictEqual(res.body.createdNote.replyId, alicePost.replyId); + assert.strictEqual(res.body.createdNote.reply.text, bobPost.text); + })); + + it('renoteできる', async(async () => { + const bobPost = await post(bob, { + text: 'test', + }); + + const alicePost = { + renoteId: bobPost.id, + }; + + const res = await request('/notes/create', alicePost, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId); + assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); + })); + + it('引用renoteできる', async(async () => { + const bobPost = await post(bob, { + text: 'test', + }); + + const alicePost = { + text: 'test', + renoteId: bobPost.id, + }; + + const res = await request('/notes/create', alicePost, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.createdNote.text, alicePost.text); + assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId); + assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); + })); + + it('文字数ぎりぎりで怒られない', async(async () => { + const post = { + text: '!'.repeat(3000), + }; + const res = await request('/notes/create', post, alice); + assert.strictEqual(res.status, 200); + })); + + it('文字数オーバーで怒られる', async(async () => { + const post = { + text: '!'.repeat(3001), + }; + const res = await request('/notes/create', post, alice); + assert.strictEqual(res.status, 400); + })); + + it('存在しないリプライ先で怒られる', async(async () => { + const post = { + text: 'test', + replyId: '000000000000000000000000', + }; + const res = await request('/notes/create', post, alice); + assert.strictEqual(res.status, 400); + })); + + it('存在しないrenote対象で怒られる', async(async () => { + const post = { + renoteId: '000000000000000000000000', + }; + const res = await request('/notes/create', post, alice); + assert.strictEqual(res.status, 400); + })); + + it('不正なリプライ先IDで怒られる', async(async () => { + const post = { + text: 'test', + replyId: 'foo', + }; + const res = await request('/notes/create', post, alice); + assert.strictEqual(res.status, 400); + })); + + it('不正なrenote対象IDで怒られる', async(async () => { + const post = { + renoteId: 'foo', + }; + const res = await request('/notes/create', post, alice); + assert.strictEqual(res.status, 400); + })); + + it('存在しないユーザーにメンションできる', async(async () => { + const post = { + text: '@ghost yo', + }; + + const res = await request('/notes/create', post, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.createdNote.text, post.text); + })); + + it('同じユーザーに複数メンションしても内部的にまとめられる', async(async () => { + const post = { + text: '@bob @bob @bob yo', + }; + + const res = await request('/notes/create', post, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.createdNote.text, post.text); + + const noteDoc = await Notes.findOneBy({ id: res.body.createdNote.id }); + assert.deepStrictEqual(noteDoc.mentions, [bob.id]); + })); + + describe('notes/create', () => { + it('投票を添付できる', async(async () => { + const res = await request('/notes/create', { + text: 'test', + poll: { + choices: ['foo', 'bar'], + }, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.createdNote.poll != null, true); + })); + + it('投票の選択肢が無くて怒られる', async(async () => { + const res = await request('/notes/create', { + poll: {}, + }, alice); + assert.strictEqual(res.status, 400); + })); + + it('投票の選択肢が無くて怒られる (空の配列)', async(async () => { + const res = await request('/notes/create', { + poll: { + choices: [], + }, + }, alice); + assert.strictEqual(res.status, 400); + })); + + it('投票の選択肢が1つで怒られる', async(async () => { + const res = await request('/notes/create', { + poll: { + choices: ['Strawberry Pasta'], + }, + }, alice); + assert.strictEqual(res.status, 400); + })); + + it('投票できる', async(async () => { + const { body } = await request('/notes/create', { + text: 'test', + poll: { + choices: ['sakura', 'izumi', 'ako'], + }, + }, alice); + + const res = await request('/notes/polls/vote', { + noteId: body.createdNote.id, + choice: 1, + }, alice); + + assert.strictEqual(res.status, 204); + })); + + it('複数投票できない', async(async () => { + const { body } = await request('/notes/create', { + text: 'test', + poll: { + choices: ['sakura', 'izumi', 'ako'], + }, + }, alice); + + await request('/notes/polls/vote', { + noteId: body.createdNote.id, + choice: 0, + }, alice); + + const res = await request('/notes/polls/vote', { + noteId: body.createdNote.id, + choice: 2, + }, alice); + + assert.strictEqual(res.status, 400); + })); + + it('許可されている場合は複数投票できる', async(async () => { + const { body } = await request('/notes/create', { + text: 'test', + poll: { + choices: ['sakura', 'izumi', 'ako'], + multiple: true, + }, + }, alice); + + await request('/notes/polls/vote', { + noteId: body.createdNote.id, + choice: 0, + }, alice); + + await request('/notes/polls/vote', { + noteId: body.createdNote.id, + choice: 1, + }, alice); + + const res = await request('/notes/polls/vote', { + noteId: body.createdNote.id, + choice: 2, + }, alice); + + assert.strictEqual(res.status, 204); + })); + + it('締め切られている場合は投票できない', async(async () => { + const { body } = await request('/notes/create', { + text: 'test', + poll: { + choices: ['sakura', 'izumi', 'ako'], + expiredAfter: 1, + }, + }, alice); + + await new Promise(x => setTimeout(x, 2)); + + const res = await request('/notes/polls/vote', { + noteId: body.createdNote.id, + choice: 1, + }, alice); + + assert.strictEqual(res.status, 400); + })); + }); + + describe('notes/delete', () => { + it('delete a reply', async(async () => { + const mainNoteRes = await api('notes/create', { + text: 'main post', + }, alice); + const replyOneRes = await api('notes/create', { + text: 'reply one', + replyId: mainNoteRes.body.createdNote.id, + }, alice); + const replyTwoRes = await api('notes/create', { + text: 'reply two', + replyId: mainNoteRes.body.createdNote.id, + }, alice); + + const deleteOneRes = await api('notes/delete', { + noteId: replyOneRes.body.createdNote.id, + }, alice); + + assert.strictEqual(deleteOneRes.status, 204); + let mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); + assert.strictEqual(mainNote.repliesCount, 1); + + const deleteTwoRes = await api('notes/delete', { + noteId: replyTwoRes.body.createdNote.id, + }, alice); + + assert.strictEqual(deleteTwoRes.status, 204); + mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); + assert.strictEqual(mainNote.repliesCount, 0); + })); + }); +}); diff --git a/packages/backend/test/prelude/get-api-validator.ts b/packages/backend/test/prelude/get-api-validator.ts deleted file mode 100644 index cccd63299a..0000000000 --- a/packages/backend/test/prelude/get-api-validator.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import Ajv from 'ajv'; -import { Schema } from '@/misc/schema'; - -export const getValidator = (paramDef: Schema) => { - const ajv = new Ajv({ - useDefaults: true, - }); - ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); - - return ajv.compile(paramDef); -}; diff --git a/packages/backend/test/prelude/maybe.ts b/packages/backend/test/prelude/maybe.ts index 37ccfbf7fe..0f4b00065f 100644 --- a/packages/backend/test/prelude/maybe.ts +++ b/packages/backend/test/prelude/maybe.ts @@ -1,23 +1,18 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import * as assert from 'assert'; -import { just, nothing } from '../../src/misc/prelude/maybe.js'; +import { just, nothing } from '../../src/prelude/maybe.js'; describe('just', () => { - test('has a value', () => { + it('has a value', () => { assert.deepStrictEqual(just(3).isJust(), true); }); - test('has the inverse called get', () => { + it('has the inverse called get', () => { assert.deepStrictEqual(just(3).get(), 3); }); }); describe('nothing', () => { - test('has no value', () => { + it('has no value', () => { assert.deepStrictEqual(nothing().isJust(), false); }); }); diff --git a/packages/backend/test/prelude/url.ts b/packages/backend/test/prelude/url.ts index 340c6451ce..df102c8dfe 100644 --- a/packages/backend/test/prelude/url.ts +++ b/packages/backend/test/prelude/url.ts @@ -1,13 +1,8 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import * as assert from 'assert'; -import { query } from '../../src/misc/prelude/url.js'; +import { query } from '../../src/prelude/url.js'; describe('url', () => { - test('query', () => { + it('query', () => { const s = query({ foo: 'ふぅ', bar: 'b a r', diff --git a/packages/backend/test/reaction-lib.ts b/packages/backend/test/reaction-lib.ts new file mode 100644 index 0000000000..7c61dc76c2 --- /dev/null +++ b/packages/backend/test/reaction-lib.ts @@ -0,0 +1,83 @@ +/* +import * as assert from 'assert'; + +import { toDbReaction } from '../src/misc/reaction-lib.js'; + +describe('toDbReaction', async () => { + it('既存の文字列リアクションはそのまま', async () => { + assert.strictEqual(await toDbReaction('like'), 'like'); + }); + + it('Unicodeプリンは寿司化不能とするため文字列化しない', async () => { + assert.strictEqual(await toDbReaction('🍮'), '🍮'); + }); + + it('プリン以外の既存のリアクションは文字列化する like', async () => { + assert.strictEqual(await toDbReaction('👍'), 'like'); + }); + + it('プリン以外の既存のリアクションは文字列化する love', async () => { + assert.strictEqual(await toDbReaction('❤️'), 'love'); + }); + + it('プリン以外の既存のリアクションは文字列化する love 異体字セレクタなし', async () => { + assert.strictEqual(await toDbReaction('❤'), 'love'); + }); + + it('プリン以外の既存のリアクションは文字列化する laugh', async () => { + assert.strictEqual(await toDbReaction('😆'), 'laugh'); + }); + + it('プリン以外の既存のリアクションは文字列化する hmm', async () => { + assert.strictEqual(await toDbReaction('🤔'), 'hmm'); + }); + + it('プリン以外の既存のリアクションは文字列化する surprise', async () => { + assert.strictEqual(await toDbReaction('😮'), 'surprise'); + }); + + it('プリン以外の既存のリアクションは文字列化する congrats', async () => { + assert.strictEqual(await toDbReaction('🎉'), 'congrats'); + }); + + it('プリン以外の既存のリアクションは文字列化する angry', async () => { + assert.strictEqual(await toDbReaction('💢'), 'angry'); + }); + + it('プリン以外の既存のリアクションは文字列化する confused', async () => { + assert.strictEqual(await toDbReaction('😥'), 'confused'); + }); + + it('プリン以外の既存のリアクションは文字列化する rip', async () => { + assert.strictEqual(await toDbReaction('😇'), 'rip'); + }); + + it('それ以外はUnicodeのまま', async () => { + assert.strictEqual(await toDbReaction('🍅'), '🍅'); + }); + + it('異体字セレクタ除去', async () => { + assert.strictEqual(await toDbReaction('㊗️'), '㊗'); + }); + + it('異体字セレクタ除去 必要なし', async () => { + assert.strictEqual(await toDbReaction('㊗'), '㊗'); + }); + + it('fallback - undefined', async () => { + assert.strictEqual(await toDbReaction(undefined), 'like'); + }); + + it('fallback - null', async () => { + assert.strictEqual(await toDbReaction(null), 'like'); + }); + + it('fallback - empty', async () => { + assert.strictEqual(await toDbReaction(''), 'like'); + }); + + it('fallback - unknown', async () => { + assert.strictEqual(await toDbReaction('unknown'), 'like'); + }); +}); +*/ diff --git a/packages/backend/test/resources/kick_gaba7.aac b/packages/backend/test/resources/kick_gaba7.aac deleted file mode 100644 index 4644542f96..0000000000 Binary files a/packages/backend/test/resources/kick_gaba7.aac and /dev/null differ diff --git a/packages/backend/test/resources/kick_gaba7.flac b/packages/backend/test/resources/kick_gaba7.flac deleted file mode 100644 index 7512812018..0000000000 Binary files a/packages/backend/test/resources/kick_gaba7.flac and /dev/null differ diff --git a/packages/backend/test/resources/kick_gaba7.mp3 b/packages/backend/test/resources/kick_gaba7.mp3 deleted file mode 100644 index 6ba317deb1..0000000000 Binary files a/packages/backend/test/resources/kick_gaba7.mp3 and /dev/null differ diff --git a/packages/backend/test/resources/kick_gaba7.wav b/packages/backend/test/resources/kick_gaba7.wav deleted file mode 100644 index 2cd280148e..0000000000 Binary files a/packages/backend/test/resources/kick_gaba7.wav and /dev/null differ diff --git a/packages/backend/test/resources/kick_gaba7.webm b/packages/backend/test/resources/kick_gaba7.webm deleted file mode 100644 index 82c5349cd4..0000000000 Binary files a/packages/backend/test/resources/kick_gaba7.webm and /dev/null differ diff --git a/packages/backend/test/resources/with-alpha.avif b/packages/backend/test/resources/with-alpha.avif deleted file mode 100644 index 05f494212e..0000000000 Binary files a/packages/backend/test/resources/with-alpha.avif and /dev/null differ diff --git a/packages/backend/test/resources/with-alpha.webp b/packages/backend/test/resources/with-alpha.webp deleted file mode 100644 index d7b0d70b7f..0000000000 Binary files a/packages/backend/test/resources/with-alpha.webp and /dev/null differ diff --git a/packages/backend/test/resources/without-alpha.avif b/packages/backend/test/resources/without-alpha.avif deleted file mode 100644 index 9ea23608b8..0000000000 Binary files a/packages/backend/test/resources/without-alpha.avif and /dev/null differ diff --git a/packages/backend/test/resources/without-alpha.webp b/packages/backend/test/resources/without-alpha.webp deleted file mode 100644 index a51091efe2..0000000000 Binary files a/packages/backend/test/resources/without-alpha.webp and /dev/null differ diff --git a/packages/backend/test/streaming.ts b/packages/backend/test/streaming.ts new file mode 100644 index 0000000000..621d07f9c2 --- /dev/null +++ b/packages/backend/test/streaming.ts @@ -0,0 +1,545 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import * as childProcess from 'child_process'; +import { Following } from '../src/models/entities/following.js'; +import { connectStream, signup, api, post, startServer, shutdownServer, initTestDb, waitFire } from './utils.js'; + +describe('Streaming', () => { + let p: childProcess.ChildProcess; + let Followings: any; + + const follow = async (follower: any, followee: any) => { + await Followings.save({ + id: 'a', + createdAt: new Date(), + followerId: follower.id, + followeeId: followee.id, + followerHost: follower.host, + followerInbox: null, + followerSharedInbox: null, + followeeHost: followee.host, + followeeInbox: null, + followeeSharedInbox: null, + }); + }; + + describe('Streaming', () => { + // Local users + let ayano: any; + let kyoko: any; + let chitose: any; + + // Remote users + let akari: any; + let chinatsu: any; + + let kyokoNote: any; + let list: any; + + before(async () => { + p = await startServer(); + const connection = await initTestDb(true); + Followings = connection.getRepository(Following); + + ayano = await signup({ username: 'ayano' }); + kyoko = await signup({ username: 'kyoko' }); + chitose = await signup({ username: 'chitose' }); + + akari = await signup({ username: 'akari', host: 'example.com' }); + chinatsu = await signup({ username: 'chinatsu', host: 'example.com' }); + + kyokoNote = await post(kyoko, { text: 'foo' }); + + // Follow: ayano => kyoko + await api('following/create', { userId: kyoko.id }, ayano); + + // Follow: ayano => akari + await follow(ayano, akari); + + // List: chitose => ayano, kyoko + list = await api('users/lists/create', { + name: 'my list', + }, chitose).then(x => x.body); + + await api('users/lists/push', { + listId: list.id, + userId: ayano.id, + }, chitose); + + await api('users/lists/push', { + listId: list.id, + userId: kyoko.id, + }, chitose); + }); + + after(async () => { + await shutdownServer(p); + }); + + describe('Events', () => { + it('mention event', async () => { + const fired = await waitFire( + kyoko, 'main', // kyoko:main + () => post(ayano, { text: 'foo @kyoko bar' }), // ayano mention => kyoko + msg => msg.type === 'mention' && msg.body.userId === ayano.id // wait ayano + ); + + assert.strictEqual(fired, true); + }); + + it('renote event', async () => { + const fired = await waitFire( + kyoko, 'main', // kyoko:main + () => post(ayano, { renoteId: kyokoNote.id }), // ayano renote + msg => msg.type === 'renote' && msg.body.renoteId === kyokoNote.id // wait renote + ); + + assert.strictEqual(fired, true); + }); + }); + + describe('Home Timeline', () => { + it('自分の投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'homeTimeline', // ayano:Home + () => api('notes/create', { text: 'foo' }, ayano), // ayano posts + msg => msg.type === 'note' && msg.body.text === 'foo' + ); + + assert.strictEqual(fired, true); + }); + + it('フォローしているユーザーの投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'homeTimeline', // ayano:home + () => api('notes/create', { text: 'foo' }, kyoko), // kyoko posts + msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + ); + + assert.strictEqual(fired, true); + }); + + it('フォローしていないユーザーの投稿は流れない', async () => { + const fired = await waitFire( + kyoko, 'homeTimeline', // kyoko:home + () => api('notes/create', { text: 'foo' }, ayano), // ayano posts + msg => msg.type === 'note' && msg.body.userId === ayano.id // wait ayano + ); + + assert.strictEqual(fired, false); + }); + + it('フォローしているユーザーのダイレクト投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'homeTimeline', // ayano:home + () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [ayano.id], }, kyoko), // kyoko dm => ayano + msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + ); + + assert.strictEqual(fired, true); + }); + + it('フォローしているユーザーでも自分が指定されていないダイレクト投稿は流れない', async () => { + const fired = await waitFire( + ayano, 'homeTimeline', // ayano:home + () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [chitose.id], }, kyoko), // kyoko dm => chitose + msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + ); + + assert.strictEqual(fired, false); + }); + }); // Home + + describe('Local Timeline', () => { + it('自分の投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'localTimeline', // ayano:Local + () => api('notes/create', { text: 'foo' }, ayano), // ayano posts + msg => msg.type === 'note' && msg.body.text === 'foo' + ); + + assert.strictEqual(fired, true); + }); + + it('フォローしていないローカルユーザーの投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'localTimeline', // ayano:Local + () => api('notes/create', { text: 'foo' }, chitose), // chitose posts + msg => msg.type === 'note' && msg.body.userId === chitose.id // wait chitose + ); + + assert.strictEqual(fired, true); + }); + + it('リモートユーザーの投稿は流れない', async () => { + const fired = await waitFire( + ayano, 'localTimeline', // ayano:Local + () => api('notes/create', { text: 'foo' }, chinatsu), // chinatsu posts + msg => msg.type === 'note' && msg.body.userId === chinatsu.id // wait chinatsu + ); + + assert.strictEqual(fired, false); + }); + + it('フォローしてたとしてもリモートユーザーの投稿は流れない', async () => { + const fired = await waitFire( + ayano, 'localTimeline', // ayano:Local + () => api('notes/create', { text: 'foo' }, akari), // akari posts + msg => msg.type === 'note' && msg.body.userId === akari.id // wait akari + ); + + assert.strictEqual(fired, false); + }); + + it('ホーム指定の投稿は流れない', async () => { + const fired = await waitFire( + ayano, 'localTimeline', // ayano:Local + () => api('notes/create', { text: 'foo', visibility: 'home' }, kyoko), // kyoko home posts + msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + ); + + assert.strictEqual(fired, false); + }); + + it('フォローしているローカルユーザーのダイレクト投稿は流れない', async () => { + const fired = await waitFire( + ayano, 'localTimeline', // ayano:Local + () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [ayano.id] }, kyoko), // kyoko DM => ayano + msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + ); + + assert.strictEqual(fired, false); + }); + + it('フォローしていないローカルユーザーのフォロワー宛て投稿は流れない', async () => { + const fired = await waitFire( + ayano, 'localTimeline', // ayano:Local + () => api('notes/create', { text: 'foo', visibility: 'followers' }, chitose), + msg => msg.type === 'note' && msg.body.userId === chitose.id // wait chitose + ); + + assert.strictEqual(fired, false); + }); + }); + + describe('Hybrid Timeline', () => { + it('自分の投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'hybridTimeline', // ayano:Hybrid + () => api('notes/create', { text: 'foo' }, ayano), // ayano posts + msg => msg.type === 'note' && msg.body.text === 'foo' + ); + + assert.strictEqual(fired, true); + }); + + it('フォローしていないローカルユーザーの投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'hybridTimeline', // ayano:Hybrid + () => api('notes/create', { text: 'foo' }, chitose), // chitose posts + msg => msg.type === 'note' && msg.body.userId === chitose.id // wait chitose + ); + + assert.strictEqual(fired, true); + }); + + it('フォローしているリモートユーザーの投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'hybridTimeline', // ayano:Hybrid + () => api('notes/create', { text: 'foo' }, akari), // akari posts + msg => msg.type === 'note' && msg.body.userId === akari.id // wait akari + ); + + assert.strictEqual(fired, true); + }); + + it('フォローしていないリモートユーザーの投稿は流れない', async () => { + const fired = await waitFire( + ayano, 'hybridTimeline', // ayano:Hybrid + () => api('notes/create', { text: 'foo' }, chinatsu), // chinatsu posts + msg => msg.type === 'note' && msg.body.userId === chinatsu.id // wait chinatsu + ); + + assert.strictEqual(fired, false); + }); + + it('フォローしているユーザーのダイレクト投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'hybridTimeline', // ayano:Hybrid + () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [ayano.id] }, kyoko), + msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + ); + + assert.strictEqual(fired, true); + }); + + it('フォローしているユーザーのホーム投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'hybridTimeline', // ayano:Hybrid + () => api('notes/create', { text: 'foo', visibility: 'home' }, kyoko), + msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + ); + + assert.strictEqual(fired, true); + }); + + it('フォローしていないローカルユーザーのホーム投稿は流れない', async () => { + const fired = await waitFire( + ayano, 'hybridTimeline', // ayano:Hybrid + () => api('notes/create', { text: 'foo', visibility: 'home' }, chitose), + msg => msg.type === 'note' && msg.body.userId === chitose.id + ); + + assert.strictEqual(fired, false); + }); + + it('フォローしていないローカルユーザーのフォロワー宛て投稿は流れない', () => async () => { + const fired = await waitFire( + ayano, 'hybridTimeline', // ayano:Hybrid + () => api('notes/create', { text: 'foo', visibility: 'followers' }, chitose), + msg => msg.type === 'note' && msg.body.userId === chitose.id + ); + + assert.strictEqual(fired, false); + }); + }); + + describe('Global Timeline', () => { + it('フォローしていないローカルユーザーの投稿が流れる', () => async () => { + const fired = await waitFire( + ayano, 'globalTimeline', // ayano:Global + () => api('notes/create', { text: 'foo' }, chitose), // chitose posts + msg => msg.type === 'note' && msg.body.userId === chitose.id // wait chitose + ); + + assert.strictEqual(fired, true); + }); + + it('フォローしていないリモートユーザーの投稿が流れる', () => async () => { + const fired = await waitFire( + ayano, 'globalTimeline', // ayano:Global + () => api('notes/create', { text: 'foo' }, chinatsu), // chinatsu posts + msg => msg.type === 'note' && msg.body.userId === chinatsu.id // wait chinatsu + ); + + assert.strictEqual(fired, true); + }); + + it('ホーム投稿は流れない', () => async () => { + const fired = await waitFire( + ayano, 'globalTimeline', // ayano:Global + () => api('notes/create', { text: 'foo', visibility: 'home' }, kyoko), // kyoko posts + msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + ); + + assert.strictEqual(fired, false); + }); + }); + + describe('UserList Timeline', () => { + it('リストに入れているユーザーの投稿が流れる', () => async () => { + const fired = await waitFire( + chitose, 'userList', + () => api('notes/create', { text: 'foo' }, ayano), + msg => msg.type === 'note' && msg.body.userId === ayano.id, + { listId: list.id, } + ); + + assert.strictEqual(fired, true); + }); + + it('リストに入れていないユーザーの投稿は流れない', () => async () => { + const fired = await waitFire( + chitose, 'userList', + () => api('notes/create', { text: 'foo' }, chinatsu), + msg => msg.type === 'note' && msg.body.userId === chinatsu.id, + { listId: list.id, } + ); + + assert.strictEqual(fired, false); + }); + + // #4471 + it('リストに入れているユーザーのダイレクト投稿が流れる', () => async () => { + const fired = await waitFire( + chitose, 'userList', + () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [chitose.id] }, ayano), + msg => msg.type === 'note' && msg.body.userId === ayano.id, + { listId: list.id, } + ); + + assert.strictEqual(fired, true); + }); + + // #4335 + it('リストに入れているがフォローはしてないユーザーのフォロワー宛て投稿は流れない', () => async () => { + const fired = await waitFire( + chitose, 'userList', + () => api('notes/create', { text: 'foo', visibility: 'followers' }, kyoko), + msg => msg.type === 'note' && msg.body.userId === kyoko.id, + { listId: list.id, } + ); + + assert.strictEqual(fired, false); + }); + }); + + describe('Hashtag Timeline', () => { + it('指定したハッシュタグの投稿が流れる', () => new Promise(async done => { + const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { + if (type == 'note') { + assert.deepStrictEqual(body.text, '#foo'); + ws.close(); + done(); + } + }, { + q: [ + ['foo'], + ], + }); + + post(chitose, { + text: '#foo', + }); + })); + + it('指定したハッシュタグの投稿が流れる (AND)', () => new Promise(async done => { + let fooCount = 0; + let barCount = 0; + let fooBarCount = 0; + + const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { + if (type == 'note') { + if (body.text === '#foo') fooCount++; + if (body.text === '#bar') barCount++; + if (body.text === '#foo #bar') fooBarCount++; + } + }, { + q: [ + ['foo', 'bar'], + ], + }); + + post(chitose, { + text: '#foo', + }); + + post(chitose, { + text: '#bar', + }); + + post(chitose, { + text: '#foo #bar', + }); + + setTimeout(() => { + assert.strictEqual(fooCount, 0); + assert.strictEqual(barCount, 0); + assert.strictEqual(fooBarCount, 1); + ws.close(); + done(); + }, 3000); + })); + + it('指定したハッシュタグの投稿が流れる (OR)', () => new Promise(async done => { + let fooCount = 0; + let barCount = 0; + let fooBarCount = 0; + let piyoCount = 0; + + const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { + if (type == 'note') { + if (body.text === '#foo') fooCount++; + if (body.text === '#bar') barCount++; + if (body.text === '#foo #bar') fooBarCount++; + if (body.text === '#piyo') piyoCount++; + } + }, { + q: [ + ['foo'], + ['bar'], + ], + }); + + post(chitose, { + text: '#foo', + }); + + post(chitose, { + text: '#bar', + }); + + post(chitose, { + text: '#foo #bar', + }); + + post(chitose, { + text: '#piyo', + }); + + setTimeout(() => { + assert.strictEqual(fooCount, 1); + assert.strictEqual(barCount, 1); + assert.strictEqual(fooBarCount, 1); + assert.strictEqual(piyoCount, 0); + ws.close(); + done(); + }, 3000); + })); + + it('指定したハッシュタグの投稿が流れる (AND + OR)', () => new Promise(async done => { + let fooCount = 0; + let barCount = 0; + let fooBarCount = 0; + let piyoCount = 0; + let waaaCount = 0; + + const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { + if (type == 'note') { + if (body.text === '#foo') fooCount++; + if (body.text === '#bar') barCount++; + if (body.text === '#foo #bar') fooBarCount++; + if (body.text === '#piyo') piyoCount++; + if (body.text === '#waaa') waaaCount++; + } + }, { + q: [ + ['foo', 'bar'], + ['piyo'], + ], + }); + + post(chitose, { + text: '#foo', + }); + + post(chitose, { + text: '#bar', + }); + + post(chitose, { + text: '#foo #bar', + }); + + post(chitose, { + text: '#piyo', + }); + + post(chitose, { + text: '#waaa', + }); + + setTimeout(() => { + assert.strictEqual(fooCount, 0); + assert.strictEqual(barCount, 0); + assert.strictEqual(fooBarCount, 1); + assert.strictEqual(piyoCount, 1); + assert.strictEqual(waaaCount, 0); + ws.close(); + done(); + }, 3000); + })); + }); + }); +}); diff --git a/packages/backend/test/thread-mute.ts b/packages/backend/test/thread-mute.ts new file mode 100644 index 0000000000..cd3e519394 --- /dev/null +++ b/packages/backend/test/thread-mute.ts @@ -0,0 +1,103 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import * as childProcess from 'child_process'; +import { async, signup, request, post, react, connectStream, startServer, shutdownServer } from './utils.js'; + +describe('Note thread mute', () => { + let p: childProcess.ChildProcess; + + let alice: any; + let bob: any; + let carol: any; + + before(async () => { + p = await startServer(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + carol = await signup({ username: 'carol' }); + }); + + after(async () => { + await shutdownServer(p); + }); + + it('notes/mentions にミュートしているスレッドの投稿が含まれない', async(async () => { + const bobNote = await post(bob, { text: '@alice @carol root note' }); + const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); + + await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); + + const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); + const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' }); + + const res = await request('/notes/mentions', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolReply.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolReplyWithoutMention.id), false); + })); + + it('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async(async () => { + // 状態リセット + await request('/i/read-all-unread-notes', {}, alice); + + const bobNote = await post(bob, { text: '@alice @carol root note' }); + + await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); + + const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); + + const res = await request('/i', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.hasUnreadMentions, false); + })); + + it('ミュートしているスレッドからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise(async done => { + // 状態リセット + await request('/i/read-all-unread-notes', {}, alice); + + const bobNote = await post(bob, { text: '@alice @carol root note' }); + + await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); + + let fired = false; + + const ws = await connectStream(alice, 'main', async ({ type, body }) => { + if (type === 'unreadMention') { + if (body === bobNote.id) return; + fired = true; + } + }); + + const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); + + setTimeout(() => { + assert.strictEqual(fired, false); + ws.close(); + done(); + }, 5000); + })); + + it('i/notifications にミュートしているスレッドの通知が含まれない', async(async () => { + const bobNote = await post(bob, { text: '@alice @carol root note' }); + const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); + + await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); + + const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); + const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' }); + + const res = await request('/i/notifications', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((notification: any) => notification.note.id === carolReply.id), false); + assert.strictEqual(res.body.some((notification: any) => notification.note.id === carolReplyWithoutMention.id), false); + + // NOTE: bobの投稿はスレッドミュート前に行われたため通知に含まれていてもよい + })); +}); diff --git a/packages/backend/test/tsconfig.json b/packages/backend/test/tsconfig.json index 4597ff8780..bc7a9968b5 100644 --- a/packages/backend/test/tsconfig.json +++ b/packages/backend/test/tsconfig.json @@ -9,9 +9,9 @@ "noFallthroughCasesInSwitch": true, "declaration": false, "sourceMap": true, - "target": "ES2022", - "module": "nodenext", - "moduleResolution": "nodenext", + "target": "es2021", + "module": "es2020", + "moduleResolution": "node", "allowSyntheticDefaultImports": true, "removeComments": false, "noLib": false, @@ -32,13 +32,10 @@ ], "lib": [ "esnext" - ], - "types": ["jest", "node"] + ] }, "compileOnSave": false, "include": [ - "./**/*.ts", - "../src/**/*.test.ts", - "../src/@types/**/*.ts" + "./**/*.ts" ] } diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts deleted file mode 100644 index f2aa5d35e4..0000000000 --- a/packages/backend/test/unit/AnnouncementService.ts +++ /dev/null @@ -1,202 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import { jest } from '@jest/globals'; -import { ModuleMocker } from 'jest-mock'; -import { Test } from '@nestjs/testing'; -import { GlobalModule } from '@/GlobalModule.js'; -import { AnnouncementService } from '@/core/AnnouncementService.js'; -import type { MiAnnouncement, AnnouncementsRepository, AnnouncementReadsRepository, UsersRepository, MiUser } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { genAidx } from '@/misc/id/aidx.js'; -import { CacheService } from '@/core/CacheService.js'; -import { IdService } from '@/core/IdService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { secureRndstr } from '@/misc/secure-rndstr.js'; -import type { TestingModule } from '@nestjs/testing'; -import type { MockFunctionMetadata } from 'jest-mock'; - -const moduleMocker = new ModuleMocker(global); - -describe('AnnouncementService', () => { - let app: TestingModule; - let announcementService: AnnouncementService; - let usersRepository: UsersRepository; - let announcementsRepository: AnnouncementsRepository; - let announcementReadsRepository: AnnouncementReadsRepository; - let globalEventService: jest.Mocked; - let moderationLogService: jest.Mocked; - - function createUser(data: Partial = {}) { - const un = secureRndstr(16); - return usersRepository.insert({ - id: genAidx(Date.now()), - username: un, - usernameLower: un, - ...data, - }) - .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); - } - - function createAnnouncement(data: Partial = {}) { - return announcementsRepository.insert({ - id: genAidx(data.createdAt ?? new Date()), - updatedAt: null, - title: 'Title', - text: 'Text', - ...data, - }) - .then(x => announcementsRepository.findOneByOrFail(x.identifiers[0])); - } - - beforeEach(async () => { - app = await Test.createTestingModule({ - imports: [ - GlobalModule, - ], - providers: [ - AnnouncementService, - CacheService, - IdService, - ], - }) - .useMocker((token) => { - if (token === GlobalEventService) { - return { - publishMainStream: jest.fn(), - publishBroadcastStream: jest.fn(), - }; - } else if (token === ModerationLogService) { - return { - log: jest.fn(), - }; - } else if (typeof token === 'function') { - const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; - const Mock = moduleMocker.generateFromMetadata(mockMetadata); - return new Mock(); - } - }) - .compile(); - - app.enableShutdownHooks(); - - announcementService = app.get(AnnouncementService); - usersRepository = app.get(DI.usersRepository); - announcementsRepository = app.get(DI.announcementsRepository); - announcementReadsRepository = app.get(DI.announcementReadsRepository); - globalEventService = app.get(GlobalEventService) as jest.Mocked; - moderationLogService = app.get(ModerationLogService) as jest.Mocked; - }); - - afterEach(async () => { - await Promise.all([ - app.get(DI.metasRepository).delete({}), - usersRepository.delete({}), - announcementsRepository.delete({}), - announcementReadsRepository.delete({}), - ]); - - await app.close(); - }); - - describe('getUnreadAnnouncements', () => { - test('通常', async () => { - const user = await createUser(); - const announcement = await createAnnouncement({ - title: '1', - }); - - const result = await announcementService.getUnreadAnnouncements(user); - - expect(result.length).toBe(1); - expect(result[0].title).toBe(announcement.title); - }); - - test('isActiveがfalseは除外', async () => { - const user = await createUser(); - await createAnnouncement({ - isActive: false, - }); - - const result = await announcementService.getUnreadAnnouncements(user); - - expect(result.length).toBe(0); - }); - - test('forExistingUsers', async () => { - const user = await createUser(); - const [announcementAfter, announcementBefore, announcementBefore2] = await Promise.all([ - createAnnouncement({ - title: 'after', - createdAt: new Date(), - forExistingUsers: true, - }), - createAnnouncement({ - title: 'before', - createdAt: new Date(Date.now() - 1000), - forExistingUsers: true, - }), - createAnnouncement({ - title: 'before2', - createdAt: new Date(Date.now() - 1000), - forExistingUsers: false, - }), - ]); - - const result = await announcementService.getUnreadAnnouncements(user); - - expect(result.length).toBe(2); - expect(result.some(a => a.title === announcementAfter.title)).toBe(true); - expect(result.some(a => a.title === announcementBefore.title)).toBe(false); - expect(result.some(a => a.title === announcementBefore2.title)).toBe(true); - }); - }); - - describe('create', () => { - test('通常', async () => { - const me = await createUser(); - const result = await announcementService.create({ - title: 'Title', - text: 'Text', - }, me); - - expect(result.raw.title).toBe('Title'); - expect(result.packed.title).toBe('Title'); - - expect(globalEventService.publishBroadcastStream).toHaveBeenCalled(); - expect(globalEventService.publishBroadcastStream.mock.lastCall![0]).toBe('announcementCreated'); - expect((globalEventService.publishBroadcastStream.mock.lastCall![1] as any).announcement).toBe(result.packed); - expect(moderationLogService.log).toHaveBeenCalled(); - }); - - test('ユーザー指定', async () => { - const me = await createUser(); - const user = await createUser(); - const result = await announcementService.create({ - title: 'Title', - text: 'Text', - userId: user.id, - }, me); - - expect(result.raw.title).toBe('Title'); - expect(result.packed.title).toBe('Title'); - - expect(globalEventService.publishBroadcastStream).not.toHaveBeenCalled(); - expect(globalEventService.publishMainStream).toHaveBeenCalled(); - expect(globalEventService.publishMainStream.mock.lastCall![0]).toBe(user.id); - expect(globalEventService.publishMainStream.mock.lastCall![1]).toBe('announcementCreated'); - expect((globalEventService.publishMainStream.mock.lastCall![2] as any).announcement).toBe(result.packed); - expect(moderationLogService.log).toHaveBeenCalled(); - }); - }); - - describe('read', () => { - // TODO - }); -}); - diff --git a/packages/backend/test/unit/DriveService.ts b/packages/backend/test/unit/DriveService.ts deleted file mode 100644 index 7234da2e36..0000000000 --- a/packages/backend/test/unit/DriveService.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import { Test } from '@nestjs/testing'; -import { DeleteObjectCommandOutput, DeleteObjectCommand, NoSuchKey, InvalidObjectState, S3Client } from '@aws-sdk/client-s3'; -import { mockClient } from 'aws-sdk-client-mock'; -import { GlobalModule } from '@/GlobalModule.js'; -import { DriveService } from '@/core/DriveService.js'; -import { CoreModule } from '@/core/CoreModule.js'; -import type { TestingModule } from '@nestjs/testing'; - -describe('DriveService', () => { - let app: TestingModule; - let driveService: DriveService; - const s3Mock = mockClient(S3Client); - - beforeAll(async () => { - app = await Test.createTestingModule({ - imports: [GlobalModule, CoreModule], - providers: [DriveService], - }).compile(); - app.enableShutdownHooks(); - driveService = app.get(DriveService); - }); - - beforeEach(async () => { - s3Mock.reset(); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('Object storage', () => { - test('delete a file', async () => { - s3Mock.on(DeleteObjectCommand) - .resolves({} as DeleteObjectCommandOutput); - - await driveService.deleteObjectStorageFile('peace of the world'); - }); - - test('delete a file then unexpected error', async () => { - s3Mock.on(DeleteObjectCommand) - .rejects(new InvalidObjectState({ $metadata: {}, message: '' })); - - await expect(driveService.deleteObjectStorageFile('unexpected')).rejects.toThrowError(Error); - }); - - test('delete a file with no valid key', async () => { - // Some S3 implementations returns 404 Not Found on deleting with a non-existent key - s3Mock.on(DeleteObjectCommand) - .rejects(new NoSuchKey({ $metadata: {}, message: 'allowed error.' })); - - await driveService.deleteObjectStorageFile('lol no way'); - }); - }); -}); diff --git a/packages/backend/test/unit/FetchInstanceMetadataService.ts b/packages/backend/test/unit/FetchInstanceMetadataService.ts deleted file mode 100644 index 34200899d4..0000000000 --- a/packages/backend/test/unit/FetchInstanceMetadataService.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import { jest } from '@jest/globals'; -import { Test } from '@nestjs/testing'; -import { Redis } from 'ioredis'; -import { GlobalModule } from '@/GlobalModule.js'; -import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; -import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { LoggerService } from '@/core/LoggerService.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { IdService } from '@/core/IdService.js'; -import { DI } from '@/di-symbols.js'; -import type { TestingModule } from '@nestjs/testing'; - -function mockRedis() { - const hash = {}; - const set = jest.fn((key, value) => { - const ret = hash[key]; - hash[key] = value; - return ret; - }); - return set; -} - -describe('FetchInstanceMetadataService', () => { - let app: TestingModule; - let fetchInstanceMetadataService: jest.Mocked; - let federatedInstanceService: jest.Mocked; - let httpRequestService: jest.Mocked; - let redisClient: jest.Mocked; - - beforeEach(async () => { - app = await Test - .createTestingModule({ - imports: [ - GlobalModule, - ], - providers: [ - FetchInstanceMetadataService, - LoggerService, - UtilityService, - IdService, - ], - }) - .useMocker((token) => { - if (token === HttpRequestService) { - return { getJson: jest.fn(), getHtml: jest.fn(), send: jest.fn() }; - } else if (token === FederatedInstanceService) { - return { fetch: jest.fn() }; - } else if (token === DI.redis) { - return mockRedis; - }}) - .compile(); - - app.enableShutdownHooks(); - - fetchInstanceMetadataService = app.get(FetchInstanceMetadataService); - federatedInstanceService = app.get(FederatedInstanceService) as jest.Mocked; - redisClient = app.get(DI.redis) as jest.Mocked; - httpRequestService = app.get(HttpRequestService) as jest.Mocked; - }); - - afterEach(async () => { - await app.close(); - }); - - test('Lock and update', async () => { - redisClient.set = mockRedis(); - const now = Date.now(); - federatedInstanceService.fetch.mockReturnValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } }); - httpRequestService.getJson.mockImplementation(() => { throw Error(); }); - const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); - const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); - await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' }); - expect(tryLockSpy).toHaveBeenCalledTimes(1); - expect(unlockSpy).toHaveBeenCalledTimes(1); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); - expect(httpRequestService.getJson).toHaveBeenCalled(); - }); - - test('Lock and don\'t update', async () => { - redisClient.set = mockRedis(); - const now = Date.now(); - federatedInstanceService.fetch.mockReturnValue({ infoUpdatedAt: { getTime: () => now } }); - httpRequestService.getJson.mockImplementation(() => { throw Error(); }); - const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); - const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); - await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' }); - expect(tryLockSpy).toHaveBeenCalledTimes(1); - expect(unlockSpy).toHaveBeenCalledTimes(1); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); - expect(httpRequestService.getJson).toHaveBeenCalledTimes(0); - }); - - test('Do nothing when lock not acquired', async () => { - redisClient.set = mockRedis(); - federatedInstanceService.fetch.mockReturnValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } }); - httpRequestService.getJson.mockImplementation(() => { throw Error(); }); - const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); - const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); - await fetchInstanceMetadataService.tryLock('example.com'); - await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' }); - expect(tryLockSpy).toHaveBeenCalledTimes(2); - expect(unlockSpy).toHaveBeenCalledTimes(0); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0); - expect(httpRequestService.getJson).toHaveBeenCalledTimes(0); - }); -}); diff --git a/packages/backend/test/unit/FileInfoService.ts b/packages/backend/test/unit/FileInfoService.ts deleted file mode 100644 index ba524adff4..0000000000 --- a/packages/backend/test/unit/FileInfoService.ts +++ /dev/null @@ -1,344 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; -import { ModuleMocker } from 'jest-mock'; -import { Test } from '@nestjs/testing'; -import { describe, beforeAll, afterAll, test } from '@jest/globals'; -import { GlobalModule } from '@/GlobalModule.js'; -import { FileInfoService } from '@/core/FileInfoService.js'; -//import { DI } from '@/di-symbols.js'; -import type { TestingModule } from '@nestjs/testing'; -import type { MockFunctionMetadata } from 'jest-mock'; - -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); -const resources = `${_dirname}/../resources`; - -const moduleMocker = new ModuleMocker(global); - -describe('FileInfoService', () => { - let app: TestingModule; - let fileInfoService: FileInfoService; - - beforeAll(async () => { - app = await Test.createTestingModule({ - imports: [ - GlobalModule, - ], - providers: [ - FileInfoService, - ], - }) - .useMocker((token) => { - if (typeof token === 'function') { - const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; - const Mock = moduleMocker.generateFromMetadata(mockMetadata); - return new Mock(); - } - }) - .compile(); - - app.enableShutdownHooks(); - - fileInfoService = app.get(FileInfoService); - }); - - afterAll(async () => { - await app.close(); - }); - - test('Empty file', async () => { - const path = `${resources}/emptyfile`; - const info = await fileInfoService.getFileInfo(path) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 0, - md5: 'd41d8cd98f00b204e9800998ecf8427e', - type: { - mime: 'application/octet-stream', - ext: null, - }, - width: undefined, - height: undefined, - orientation: undefined, - }); - }); - - describe('IMAGE', () => { - test('Generic JPEG', async () => { - const path = `${resources}/Lenna.jpg`; - const info = await fileInfoService.getFileInfo(path) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 25360, - md5: '091b3f259662aa31e2ffef4519951168', - type: { - mime: 'image/jpeg', - ext: 'jpg', - }, - width: 512, - height: 512, - orientation: undefined, - }); - }); - - test('Generic APNG', async () => { - const path = `${resources}/anime.png`; - const info = await fileInfoService.getFileInfo(path) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 1868, - md5: '08189c607bea3b952704676bb3c979e0', - type: { - mime: 'image/apng', - ext: 'apng', - }, - width: 256, - height: 256, - orientation: undefined, - }); - }); - - test('Generic AGIF', async () => { - const path = `${resources}/anime.gif`; - const info = await fileInfoService.getFileInfo(path) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 2248, - md5: '32c47a11555675d9267aee1a86571e7e', - type: { - mime: 'image/gif', - ext: 'gif', - }, - width: 256, - height: 256, - orientation: undefined, - }); - }); - - test('PNG with alpha', async () => { - const path = `${resources}/with-alpha.png`; - const info = await fileInfoService.getFileInfo(path) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 3772, - md5: 'f73535c3e1e27508885b69b10cf6e991', - type: { - mime: 'image/png', - ext: 'png', - }, - width: 256, - height: 256, - orientation: undefined, - }); - }); - - test('Generic SVG', async () => { - const path = `${resources}/image.svg`; - const info = await fileInfoService.getFileInfo(path) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 505, - md5: 'b6f52b4b021e7b92cdd04509c7267965', - type: { - mime: 'image/svg+xml', - ext: 'svg', - }, - width: 256, - height: 256, - orientation: undefined, - }); - }); - - test('SVG with XML definition', async () => { - // https://github.com/misskey-dev/misskey/issues/4413 - const path = `${resources}/with-xml-def.svg`; - const info = await fileInfoService.getFileInfo(path) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 544, - md5: '4b7a346cde9ccbeb267e812567e33397', - type: { - mime: 'image/svg+xml', - ext: 'svg', - }, - width: 256, - height: 256, - orientation: undefined, - }); - }); - - test('Dimension limit', async () => { - const path = `${resources}/25000x25000.png`; - const info = await fileInfoService.getFileInfo(path) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 75933, - md5: '268c5dde99e17cf8fe09f1ab3f97df56', - type: { - mime: 'application/octet-stream', // do not treat as image - ext: null, - }, - width: 25000, - height: 25000, - orientation: undefined, - }); - }); - - test('Rotate JPEG', async () => { - const path = `${resources}/rotate.jpg`; - const info = await fileInfoService.getFileInfo(path) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 12624, - md5: '68d5b2d8d1d1acbbce99203e3ec3857e', - type: { - mime: 'image/jpeg', - ext: 'jpg', - }, - width: 512, - height: 256, - orientation: 8, - }); - }); - }); - - describe('AUDIO', () => { - test('MP3', async () => { - const path = `${resources}/kick_gaba7.mp3`; - const info = await fileInfoService.getFileInfo(path) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - delete info.width; - delete info.height; - delete info.orientation; - assert.deepStrictEqual(info, { - size: 19853, - md5: '4f557df8548bc3cecc794c652f690446', - type: { - mime: 'audio/mpeg', - ext: 'mp3', - }, - }); - }); - - test('WAV', async () => { - const path = `${resources}/kick_gaba7.wav`; - const info = await fileInfoService.getFileInfo(path) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - delete info.width; - delete info.height; - delete info.orientation; - assert.deepStrictEqual(info, { - size: 87630, - md5: '8bc9bb4fe5e77bb1871448209be635c1', - type: { - mime: 'audio/wav', - ext: 'wav', - }, - }); - }); - - test('AAC', async () => { - const path = `${resources}/kick_gaba7.aac`; - const info = await fileInfoService.getFileInfo(path) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - delete info.width; - delete info.height; - delete info.orientation; - assert.deepStrictEqual(info, { - size: 7291, - md5: '2789323f05e3392b648066f50be6a2a6', - type: { - mime: 'audio/aac', - ext: 'aac', - }, - }); - }); - - test('FLAC', async () => { - const path = `${resources}/kick_gaba7.flac`; - const info = await fileInfoService.getFileInfo(path) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - delete info.width; - delete info.height; - delete info.orientation; - assert.deepStrictEqual(info, { - size: 108793, - md5: 'bc0f3adfe0e1ca99ae6c7528c46b3173', - type: { - mime: 'audio/flac', - ext: 'flac', - }, - }); - }); - - /* - * video/webmとして検出されてしまう - test('WEBM AUDIO', async () => { - const path = `${resources}/kick_gaba7.webm`; - const info = await fileInfoService.getFileInfo(path) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - delete info.width; - delete info.height; - delete info.orientation; - assert.deepStrictEqual(info, { - size: 8879, - md5: '3350083dec312419cfdc06c16413aca7', - type: { - mime: 'audio/webm', - ext: 'webm', - }, - }); - }); - */ - }); -}); diff --git a/packages/backend/test/unit/MetaService.ts b/packages/backend/test/unit/MetaService.ts deleted file mode 100644 index ab30f48283..0000000000 --- a/packages/backend/test/unit/MetaService.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import { jest } from '@jest/globals'; -import { ModuleMocker } from 'jest-mock'; -import { Test } from '@nestjs/testing'; -import { GlobalModule } from '@/GlobalModule.js'; -import type { MetasRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { MetaService } from '@/core/MetaService.js'; -import { CoreModule } from '@/core/CoreModule.js'; -import type { DataSource } from 'typeorm'; -import type { TestingModule } from '@nestjs/testing'; - -describe('MetaService', () => { - let app: TestingModule; - let metaService: MetaService; - - beforeAll(async () => { - app = await Test.createTestingModule({ - imports: [ - GlobalModule, - CoreModule, - ], - }).compile(); - - app.enableShutdownHooks(); - - metaService = app.get(MetaService, { strict: false }); - - // Make it cached - await metaService.fetch(); - }); - - afterAll(async () => { - await app.close(); - }); - - test('fetch (cache)', async () => { - const db = app.get(DI.db); - const spy = jest.spyOn(db, 'transaction'); - - const result = await metaService.fetch(); - - expect(result.id).toBe('x'); - expect(spy).toHaveBeenCalledTimes(0); - }); - - test('fetch (force)', async () => { - const db = app.get(DI.db); - const spy = jest.spyOn(db, 'transaction'); - - const result = await metaService.fetch(true); - - expect(result.id).toBe('x'); - expect(spy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/backend/test/unit/MfmService.ts b/packages/backend/test/unit/MfmService.ts deleted file mode 100644 index 49e84ccec8..0000000000 --- a/packages/backend/test/unit/MfmService.ts +++ /dev/null @@ -1,107 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as assert from 'assert'; -import * as mfm from '@sharkey/sfm-js'; -import { Test } from '@nestjs/testing'; - -import { CoreModule } from '@/core/CoreModule.js'; -import { MfmService } from '@/core/MfmService.js'; -import { GlobalModule } from '@/GlobalModule.js'; - -describe('MfmService', () => { - let mfmService: MfmService; - - beforeAll(async () => { - const app = await Test.createTestingModule({ - imports: [GlobalModule, CoreModule], - }).compile(); - mfmService = app.get(MfmService); - }); - - describe('toHtml', () => { - test('br', () => { - const input = 'foo\nbar\nbaz'; - const output = '

foo
bar
baz

'; - assert.equal(mfmService.toHtml(mfm.parse(input)), output); - }); - - test('br alt', () => { - const input = 'foo\r\nbar\rbaz'; - const output = '

foo
bar
baz

'; - assert.equal(mfmService.toHtml(mfm.parse(input)), output); - }); - }); - - describe('fromHtml', () => { - test('p', () => { - assert.deepStrictEqual(mfmService.fromHtml('

a

b

'), 'a\n\nb'); - }); - - test('block element', () => { - assert.deepStrictEqual(mfmService.fromHtml('
a
b
'), 'a\nb'); - }); - - test('inline element', () => { - assert.deepStrictEqual(mfmService.fromHtml('
  • a
  • b
'), 'a\nb'); - }); - - test('block code', () => { - assert.deepStrictEqual(mfmService.fromHtml('
a\nb
'), '```\na\nb\n```'); - }); - - test('inline code', () => { - assert.deepStrictEqual(mfmService.fromHtml('a'), '`a`'); - }); - - test('quote', () => { - assert.deepStrictEqual(mfmService.fromHtml('
a\nb
'), '> a\n> b'); - }); - - test('br', () => { - assert.deepStrictEqual(mfmService.fromHtml('

abc

d

'), 'abc\n\nd'); - }); - - test('link with different text', () => { - assert.deepStrictEqual(mfmService.fromHtml('

a c d

'), 'a [c](https://example.com/b) d'); - }); - - test('link with different text, but not encoded', () => { - assert.deepStrictEqual(mfmService.fromHtml('

a c d

'), 'a [c]() d'); - }); - - test('link with same text', () => { - assert.deepStrictEqual(mfmService.fromHtml('

a https://example.com/b d

'), 'a https://example.com/b d'); - }); - - test('link with same text, but not encoded', () => { - assert.deepStrictEqual(mfmService.fromHtml('

a https://example.com/ä d

'), 'a d'); - }); - - test('link with no url', () => { - assert.deepStrictEqual(mfmService.fromHtml('

a c d

'), 'a [c](b) d'); - }); - - test('link without href', () => { - assert.deepStrictEqual(mfmService.fromHtml('

a c d

'), 'a c d'); - }); - - test('link without text', () => { - assert.deepStrictEqual(mfmService.fromHtml('

a d

'), 'a https://example.com/b d'); - }); - - test('link without both', () => { - assert.deepStrictEqual(mfmService.fromHtml('

a d

'), 'a d'); - }); - - test('mention', () => { - assert.deepStrictEqual(mfmService.fromHtml('

a @user d

'), 'a @user@example.com d'); - }); - - test('hashtag', () => { - assert.deepStrictEqual(mfmService.fromHtml('

a #a d

', ['#a']), 'a #a d'); - }); - }); -}); diff --git a/packages/backend/test/unit/ReactionService.ts b/packages/backend/test/unit/ReactionService.ts deleted file mode 100644 index 7b5bf7d0a0..0000000000 --- a/packages/backend/test/unit/ReactionService.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as assert from 'assert'; -import { Test } from '@nestjs/testing'; - -import { CoreModule } from '@/core/CoreModule.js'; -import { ReactionService } from '@/core/ReactionService.js'; -import { GlobalModule } from '@/GlobalModule.js'; - -describe('ReactionService', () => { - let reactionService: ReactionService; - - beforeAll(async () => { - const app = await Test.createTestingModule({ - imports: [GlobalModule, CoreModule], - }).compile(); - reactionService = app.get(ReactionService); - }); - - describe('normalize', () => { - test('絵文字リアクションはそのまま', async () => { - assert.strictEqual(await reactionService.normalize('👍'), '👍'); - assert.strictEqual(await reactionService.normalize('🍅'), '🍅'); - }); - - test('既存のリアクションは絵文字化する pudding', async () => { - assert.strictEqual(await reactionService.normalize('pudding'), '🍮'); - }); - - test('既存のリアクションは絵文字化する like', async () => { - assert.strictEqual(await reactionService.normalize('like'), '👍'); - }); - - test('既存のリアクションは絵文字化する love', async () => { - assert.strictEqual(await reactionService.normalize('love'), '❤'); - }); - - test('既存のリアクションは絵文字化する laugh', async () => { - assert.strictEqual(await reactionService.normalize('laugh'), '😆'); - }); - - test('既存のリアクションは絵文字化する hmm', async () => { - assert.strictEqual(await reactionService.normalize('hmm'), '🤔'); - }); - - test('既存のリアクションは絵文字化する surprise', async () => { - assert.strictEqual(await reactionService.normalize('surprise'), '😮'); - }); - - test('既存のリアクションは絵文字化する congrats', async () => { - assert.strictEqual(await reactionService.normalize('congrats'), '🎉'); - }); - - test('既存のリアクションは絵文字化する angry', async () => { - assert.strictEqual(await reactionService.normalize('angry'), '💢'); - }); - - test('既存のリアクションは絵文字化する confused', async () => { - assert.strictEqual(await reactionService.normalize('confused'), '😥'); - }); - - test('既存のリアクションは絵文字化する rip', async () => { - assert.strictEqual(await reactionService.normalize('rip'), '😇'); - }); - - test('既存のリアクションは絵文字化する star', async () => { - assert.strictEqual(await reactionService.normalize('star'), '⭐'); - }); - - test('異体字セレクタ除去', async () => { - assert.strictEqual(await reactionService.normalize('㊗️'), '㊗'); - }); - - test('異体字セレクタ除去 必要なし', async () => { - assert.strictEqual(await reactionService.normalize('㊗'), '㊗'); - }); - - test('fallback - null', async () => { - assert.strictEqual(await reactionService.normalize(null), '❤'); - }); - - test('fallback - empty', async () => { - assert.strictEqual(await reactionService.normalize(''), '❤'); - }); - - test('fallback - unknown', async () => { - assert.strictEqual(await reactionService.normalize('unknown'), '❤'); - }); - }); -}); diff --git a/packages/backend/test/unit/RelayService.ts b/packages/backend/test/unit/RelayService.ts deleted file mode 100644 index f780a25388..0000000000 --- a/packages/backend/test/unit/RelayService.ts +++ /dev/null @@ -1,105 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import { jest } from '@jest/globals'; -import { ModuleMocker } from 'jest-mock'; -import { Test } from '@nestjs/testing'; -import { GlobalModule } from '@/GlobalModule.js'; -import { RelayService } from '@/core/RelayService.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { QueueService } from '@/core/QueueService.js'; -import { IdService } from '@/core/IdService.js'; -import type { RelaysRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import type { TestingModule } from '@nestjs/testing'; -import type { MockFunctionMetadata } from 'jest-mock'; - -const moduleMocker = new ModuleMocker(global); - -describe('RelayService', () => { - let app: TestingModule; - let relayService: RelayService; - let queueService: jest.Mocked; - let relaysRepository: RelaysRepository; - let userEntityService: UserEntityService; - - beforeAll(async () => { - app = await Test.createTestingModule({ - imports: [ - GlobalModule, - ], - providers: [ - IdService, - CreateSystemUserService, - ApRendererService, - RelayService, - UserEntityService, - ], - }) - .useMocker((token) => { - if (token === QueueService) { - return { deliver: jest.fn() }; - } - if (typeof token === 'function') { - const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; - const Mock = moduleMocker.generateFromMetadata(mockMetadata); - return new Mock(); - } - }) - .compile(); - - app.enableShutdownHooks(); - - relayService = app.get(RelayService); - queueService = app.get(QueueService) as jest.Mocked; - relaysRepository = app.get(DI.relaysRepository); - userEntityService = app.get(UserEntityService); - }); - - afterAll(async () => { - await app.close(); - }); - - test('addRelay', async () => { - const result = await relayService.addRelay('https://example.com'); - - expect(result.inbox).toBe('https://example.com'); - expect(result.status).toBe('requesting'); - expect(queueService.deliver).toHaveBeenCalled(); - expect(queueService.deliver.mock.lastCall![1]?.type).toBe('Follow'); - expect(queueService.deliver.mock.lastCall![2]).toBe('https://example.com'); - //expect(queueService.deliver.mock.lastCall![0].username).toBe('relay.actor'); - }); - - test('listRelay', async () => { - const result = await relayService.listRelay(); - - expect(result.length).toBe(1); - expect(result[0].inbox).toBe('https://example.com'); - expect(result[0].status).toBe('requesting'); - }); - - test('removeRelay: succ', async () => { - await relayService.removeRelay('https://example.com'); - - expect(queueService.deliver).toHaveBeenCalled(); - expect(queueService.deliver.mock.lastCall![1]?.type).toBe('Undo'); - expect(queueService.deliver.mock.lastCall![1]?.object.type).toBe('Follow'); - expect(queueService.deliver.mock.lastCall![2]).toBe('https://example.com'); - //expect(queueService.deliver.mock.lastCall![0].username).toBe('relay.actor'); - - const list = await relayService.listRelay(); - expect(list.length).toBe(0); - }); - - test('removeRelay: fail', async () => { - await expect(relayService.removeRelay('https://x.example.com')) - .rejects.toThrow('relay not found'); - }); -}); diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts deleted file mode 100644 index 9879eb8e3e..0000000000 --- a/packages/backend/test/unit/RoleService.ts +++ /dev/null @@ -1,344 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import { jest } from '@jest/globals'; -import { ModuleMocker } from 'jest-mock'; -import { Test } from '@nestjs/testing'; -import * as lolex from '@sinonjs/fake-timers'; -import { GlobalModule } from '@/GlobalModule.js'; -import { RoleService } from '@/core/RoleService.js'; -import type { MiRole, RolesRepository, RoleAssignmentsRepository, UsersRepository, MiUser } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { MetaService } from '@/core/MetaService.js'; -import { genAidx } from '@/misc/id/aidx.js'; -import { CacheService } from '@/core/CacheService.js'; -import { IdService } from '@/core/IdService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { secureRndstr } from '@/misc/secure-rndstr.js'; -import { NotificationService } from '@/core/NotificationService.js'; -import { sleep } from '../utils.js'; -import type { TestingModule } from '@nestjs/testing'; -import type { MockFunctionMetadata } from 'jest-mock'; - -const moduleMocker = new ModuleMocker(global); - -describe('RoleService', () => { - let app: TestingModule; - let roleService: RoleService; - let usersRepository: UsersRepository; - let rolesRepository: RolesRepository; - let roleAssignmentsRepository: RoleAssignmentsRepository; - let metaService: jest.Mocked; - let notificationService: jest.Mocked; - let clock: lolex.InstalledClock; - - function createUser(data: Partial = {}) { - const un = secureRndstr(16); - return usersRepository.insert({ - id: genAidx(Date.now()), - username: un, - usernameLower: un, - ...data, - }) - .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); - } - - function createRole(data: Partial = {}) { - return rolesRepository.insert({ - id: genAidx(Date.now()), - updatedAt: new Date(), - lastUsedAt: new Date(), - description: '', - ...data, - }) - .then(x => rolesRepository.findOneByOrFail(x.identifiers[0])); - } - - beforeEach(async () => { - clock = lolex.install({ - now: new Date(), - shouldClearNativeTimers: true, - }); - - app = await Test.createTestingModule({ - imports: [ - GlobalModule, - ], - providers: [ - RoleService, - CacheService, - IdService, - GlobalEventService, - { - provide: NotificationService, - useFactory: () => ({ - createNotification: jest.fn(), - }), - }, - { - provide: NotificationService.name, - useExisting: NotificationService, - }, - ], - }) - .useMocker((token) => { - if (token === MetaService) { - return { fetch: jest.fn() }; - } - if (typeof token === 'function') { - const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; - const Mock = moduleMocker.generateFromMetadata(mockMetadata); - return new Mock(); - } - }) - .compile(); - - app.enableShutdownHooks(); - - roleService = app.get(RoleService); - usersRepository = app.get(DI.usersRepository); - rolesRepository = app.get(DI.rolesRepository); - roleAssignmentsRepository = app.get(DI.roleAssignmentsRepository); - - metaService = app.get(MetaService) as jest.Mocked; - notificationService = app.get(NotificationService) as jest.Mocked; - - await roleService.onModuleInit(); - }); - - afterEach(async () => { - clock.uninstall(); - - await Promise.all([ - app.get(DI.metasRepository).delete({}), - usersRepository.delete({}), - rolesRepository.delete({}), - roleAssignmentsRepository.delete({}), - ]); - - await app.close(); - }); - - describe('getUserPolicies', () => { - test('instance default policies', async () => { - const user = await createUser(); - metaService.fetch.mockResolvedValue({ - policies: { - canManageCustomEmojis: false, - }, - } as any); - - const result = await roleService.getUserPolicies(user.id); - - expect(result.canManageCustomEmojis).toBe(false); - }); - - test('instance default policies 2', async () => { - const user = await createUser(); - metaService.fetch.mockResolvedValue({ - policies: { - canManageCustomEmojis: true, - }, - } as any); - - const result = await roleService.getUserPolicies(user.id); - - expect(result.canManageCustomEmojis).toBe(true); - }); - - test('with role', async () => { - const user = await createUser(); - const role = await createRole({ - name: 'a', - policies: { - canManageCustomEmojis: { - useDefault: false, - priority: 0, - value: true, - }, - }, - }); - await roleService.assign(user.id, role.id); - metaService.fetch.mockResolvedValue({ - policies: { - canManageCustomEmojis: false, - }, - } as any); - - const result = await roleService.getUserPolicies(user.id); - - expect(result.canManageCustomEmojis).toBe(true); - }); - - test('priority', async () => { - const user = await createUser(); - const role1 = await createRole({ - name: 'role1', - policies: { - driveCapacityMb: { - useDefault: false, - priority: 0, - value: 200, - }, - }, - }); - const role2 = await createRole({ - name: 'role2', - policies: { - driveCapacityMb: { - useDefault: false, - priority: 1, - value: 100, - }, - }, - }); - await roleService.assign(user.id, role1.id); - await roleService.assign(user.id, role2.id); - metaService.fetch.mockResolvedValue({ - policies: { - driveCapacityMb: 50, - }, - } as any); - - const result = await roleService.getUserPolicies(user.id); - - expect(result.driveCapacityMb).toBe(100); - }); - - test('conditional role', async () => { - const user1 = await createUser({ - id: genAidx(Date.now() - (1000 * 60 * 60 * 24 * 365)), - }); - const user2 = await createUser({ - id: genAidx(Date.now() - (1000 * 60 * 60 * 24 * 365)), - followersCount: 10, - }); - await createRole({ - name: 'a', - policies: { - canManageCustomEmojis: { - useDefault: false, - priority: 0, - value: true, - }, - }, - target: 'conditional', - condFormula: { - type: 'and', - values: [{ - type: 'followersMoreThanOrEq', - value: 10, - }, { - type: 'createdMoreThan', - sec: 60 * 60 * 24 * 7, - }], - }, - }); - - metaService.fetch.mockResolvedValue({ - policies: { - canManageCustomEmojis: false, - }, - } as any); - - const user1Policies = await roleService.getUserPolicies(user1.id); - const user2Policies = await roleService.getUserPolicies(user2.id); - expect(user1Policies.canManageCustomEmojis).toBe(false); - expect(user2Policies.canManageCustomEmojis).toBe(true); - }); - - test('expired role', async () => { - const user = await createUser(); - const role = await createRole({ - name: 'a', - policies: { - canManageCustomEmojis: { - useDefault: false, - priority: 0, - value: true, - }, - }, - }); - await roleService.assign(user.id, role.id, new Date(Date.now() + (1000 * 60 * 60 * 24))); - metaService.fetch.mockResolvedValue({ - policies: { - canManageCustomEmojis: false, - }, - } as any); - - const result = await roleService.getUserPolicies(user.id); - expect(result.canManageCustomEmojis).toBe(true); - - clock.tick('25:00:00'); - - const resultAfter25h = await roleService.getUserPolicies(user.id); - expect(resultAfter25h.canManageCustomEmojis).toBe(false); - - await roleService.assign(user.id, role.id); - - // ストリーミング経由で反映されるまでちょっと待つ - clock.uninstall(); - await sleep(100); - - const resultAfter25hAgain = await roleService.getUserPolicies(user.id); - expect(resultAfter25hAgain.canManageCustomEmojis).toBe(true); - }); - }); - - describe('assign', () => { - test('公開ロールの場合は通知される', async () => { - const user = await createUser(); - const role = await createRole({ - isPublic: true, - name: 'a', - }); - - await roleService.assign(user.id, role.id); - - clock.uninstall(); - await sleep(100); - - const assignments = await roleAssignmentsRepository.find({ - where: { - userId: user.id, - roleId: role.id, - }, - }); - expect(assignments).toHaveLength(1); - - expect(notificationService.createNotification).toHaveBeenCalled(); - expect(notificationService.createNotification.mock.lastCall![0]).toBe(user.id); - expect(notificationService.createNotification.mock.lastCall![1]).toBe('roleAssigned'); - expect(notificationService.createNotification.mock.lastCall![2]).toEqual({ - roleId: role.id, - }); - }); - - test('非公開ロールの場合は通知されない', async () => { - const user = await createUser(); - const role = await createRole({ - isPublic: false, - name: 'a', - }); - - await roleService.assign(user.id, role.id); - - clock.uninstall(); - await sleep(100); - - const assignments = await roleAssignmentsRepository.find({ - where: { - userId: user.id, - roleId: role.id, - }, - }); - expect(assignments).toHaveLength(1); - - expect(notificationService.createNotification).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/backend/test/unit/S3Service.ts b/packages/backend/test/unit/S3Service.ts deleted file mode 100644 index c1eafc96b7..0000000000 --- a/packages/backend/test/unit/S3Service.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import { Test } from '@nestjs/testing'; -import { UploadPartCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; -import { mockClient } from 'aws-sdk-client-mock'; -import { GlobalModule } from '@/GlobalModule.js'; -import { CoreModule } from '@/core/CoreModule.js'; -import { S3Service } from '@/core/S3Service.js'; -import { MiMeta } from '@/models/_.js'; -import type { TestingModule } from '@nestjs/testing'; - -describe('S3Service', () => { - let app: TestingModule; - let s3Service: S3Service; - const s3Mock = mockClient(S3Client); - - beforeAll(async () => { - app = await Test.createTestingModule({ - imports: [GlobalModule, CoreModule], - providers: [S3Service], - }).compile(); - app.enableShutdownHooks(); - s3Service = app.get(S3Service); - }); - - beforeEach(async () => { - s3Mock.reset(); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('upload', () => { - test('upload a file', async () => { - s3Mock.on(PutObjectCommand).resolves({}); - - await s3Service.upload({ objectStorageRegion: 'us-east-1' } as MiMeta, { - Bucket: 'fake', - Key: 'fake', - Body: 'x', - }); - }); - - test('upload a large file', async () => { - s3Mock.on(CreateMultipartUploadCommand).resolves({ UploadId: '1' }); - s3Mock.on(UploadPartCommand).resolves({ ETag: '1' }); - s3Mock.on(CompleteMultipartUploadCommand).resolves({ Bucket: 'fake', Key: 'fake' }); - - await s3Service.upload({} as MiMeta, { - Bucket: 'fake', - Key: 'fake', - Body: 'x'.repeat(8 * 1024 * 1024 + 1), // デフォルトpartSizeにしている 8 * 1024 * 1024 を越えるサイズ - }); - }); - - test('upload a file error', async () => { - s3Mock.on(PutObjectCommand).rejects({ name: 'Fake Error' }); - - await expect(s3Service.upload({ objectStorageRegion: 'us-east-1' } as MiMeta, { - Bucket: 'fake', - Key: 'fake', - Body: 'x', - })).rejects.toThrowError(Error); - }); - - test('upload a large file error', async () => { - s3Mock.on(UploadPartCommand).rejects(); - - await expect(s3Service.upload({} as MiMeta, { - Bucket: 'fake', - Key: 'fake', - Body: 'x'.repeat(8 * 1024 * 1024 + 1), // デフォルトpartSizeにしている 8 * 1024 * 1024 を越えるサイズ - })).rejects.toThrowError(Error); - }); - }); -}); diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts deleted file mode 100644 index 014e862f9c..0000000000 --- a/packages/backend/test/unit/activitypub.ts +++ /dev/null @@ -1,371 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import { Test } from '@nestjs/testing'; -import { jest } from '@jest/globals'; - -import { ApImageService } from '@/core/activitypub/models/ApImageService.js'; -import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; -import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { GlobalModule } from '@/GlobalModule.js'; -import { CoreModule } from '@/core/CoreModule.js'; -import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import { LoggerService } from '@/core/LoggerService.js'; -import type { IActor, IApDocument, ICollection, IPost } from '@/core/activitypub/type.js'; -import { MiMeta, MiNote } from '@/models/_.js'; -import { secureRndstr } from '@/misc/secure-rndstr.js'; -import { DownloadService } from '@/core/DownloadService.js'; -import { MetaService } from '@/core/MetaService.js'; -import type { MiRemoteUser } from '@/models/User.js'; -import { genAidx } from '@/misc/id/aidx.js'; -import { MockResolver } from '../misc/mock-resolver.js'; - -const host = 'https://host1.test'; - -type NonTransientIActor = IActor & { id: string }; -type NonTransientIPost = IPost & { id: string }; - -function createRandomActor({ actorHost = host } = {}): NonTransientIActor { - const preferredUsername = secureRndstr(8); - const actorId = `${actorHost}/users/${preferredUsername.toLowerCase()}`; - - return { - '@context': 'https://www.w3.org/ns/activitystreams', - id: actorId, - type: 'Person', - preferredUsername, - inbox: `${actorId}/inbox`, - outbox: `${actorId}/outbox`, - }; -} - -function createRandomNote(actor: NonTransientIActor): NonTransientIPost { - const id = secureRndstr(8); - const noteId = `${new URL(actor.id).origin}/notes/${id}`; - - return { - id: noteId, - type: 'Note', - attributedTo: actor.id, - content: 'test test foo', - }; -} - -function createRandomNotes(actor: NonTransientIActor, length: number): NonTransientIPost[] { - return new Array(length).fill(null).map(() => createRandomNote(actor)); -} - -function createRandomFeaturedCollection(actor: NonTransientIActor, length: number): ICollection { - const items = createRandomNotes(actor, length); - - return { - '@context': 'https://www.w3.org/ns/activitystreams', - type: 'Collection', - id: actor.outbox as string, - totalItems: items.length, - items, - }; -} - -async function createRandomRemoteUser( - resolver: MockResolver, - personService: ApPersonService, -): Promise { - const actor = createRandomActor(); - resolver.register(actor.id, actor); - - return await personService.createPerson(actor.id, resolver); -} - -describe('ActivityPub', () => { - let imageService: ApImageService; - let noteService: ApNoteService; - let personService: ApPersonService; - let rendererService: ApRendererService; - let resolver: MockResolver; - - const metaInitial = { - cacheRemoteFiles: true, - cacheRemoteSensitiveFiles: true, - enableFanoutTimeline: true, - enableFanoutTimelineDbFallback: true, - perUserHomeTimelineCacheMax: 800, - perLocalUserUserTimelineCacheMax: 800, - perRemoteUserUserTimelineCacheMax: 800, - blockedHosts: [] as string[], - sensitiveWords: [] as string[], - } as MiMeta; - let meta = metaInitial; - - beforeAll(async () => { - const app = await Test.createTestingModule({ - imports: [GlobalModule, CoreModule], - }) - .overrideProvider(DownloadService).useValue({ - async downloadUrl(): Promise<{ filename: string }> { - return { - filename: 'dummy.tmp', - }; - }, - }) - .overrideProvider(MetaService).useValue({ - async fetch(): Promise { - return meta; - }, - }).compile(); - - await app.init(); - app.enableShutdownHooks(); - - noteService = app.get(ApNoteService); - personService = app.get(ApPersonService); - rendererService = app.get(ApRendererService); - imageService = app.get(ApImageService); - resolver = new MockResolver(await app.resolve(LoggerService)); - - // Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error - const federatedInstanceService = app.get(FederatedInstanceService); - jest.spyOn(federatedInstanceService, 'fetch').mockImplementation(() => new Promise(() => { })); - }); - - beforeEach(() => { - resolver.clear(); - }); - - describe('Parse minimum object', () => { - const actor = createRandomActor(); - - const post = { - '@context': 'https://www.w3.org/ns/activitystreams', - id: `${host}/users/${secureRndstr(8)}`, - type: 'Note', - attributedTo: actor.id, - to: 'https://www.w3.org/ns/activitystreams#Public', - content: 'あ', - }; - - test('Minimum Actor', async () => { - resolver.register(actor.id, actor); - - const user = await personService.createPerson(actor.id, resolver); - - assert.deepStrictEqual(user.uri, actor.id); - assert.deepStrictEqual(user.username, actor.preferredUsername); - assert.deepStrictEqual(user.inbox, actor.inbox); - }); - - test('Minimum Note', async () => { - resolver.register(actor.id, actor); - resolver.register(post.id, post); - - const note = await noteService.createNote(post.id, resolver, true); - - assert.deepStrictEqual(note?.uri, post.id); - assert.deepStrictEqual(note.visibility, 'public'); - assert.deepStrictEqual(note.text, post.content); - }); - }); - - describe('Name field', () => { - test('Truncate long name', async () => { - const actor = { - ...createRandomActor(), - name: secureRndstr(129), - }; - - resolver.register(actor.id, actor); - - const user = await personService.createPerson(actor.id, resolver); - - assert.deepStrictEqual(user.name, actor.name.slice(0, 128)); - }); - - test('Normalize empty name', async () => { - const actor = { - ...createRandomActor(), - name: '', - }; - - resolver.register(actor.id, actor); - - const user = await personService.createPerson(actor.id, resolver); - - assert.strictEqual(user.name, null); - }); - }); - - describe('Renderer', () => { - test('Render an announce with visibility: followers', () => { - rendererService.renderAnnounce('https://example.com/notes/00example', { - id: genAidx(Date.now()), - visibility: 'followers', - } as MiNote); - }); - }); - - describe('Featured', () => { - test('Fetch featured notes from IActor', async () => { - const actor = createRandomActor(); - actor.featured = `${actor.id}/collections/featured`; - - const featured = createRandomFeaturedCollection(actor, 5); - - resolver.register(actor.id, actor); - resolver.register(actor.featured, featured); - - await personService.createPerson(actor.id, resolver); - - // All notes in `featured` are same-origin, no need to fetch notes again - assert.deepStrictEqual(resolver.remoteGetTrials(), [actor.id, actor.featured]); - - // Created notes without resolving anything - for (const item of featured.items as IPost[]) { - const note = await noteService.fetchNote(item); - assert.ok(note); - assert.strictEqual(note.text, 'test test foo'); - assert.strictEqual(note.uri, item.id); - } - }); - - test('Fetch featured notes from IActor pointing to another remote server', async () => { - const actor1 = createRandomActor(); - actor1.featured = `${actor1.id}/collections/featured`; - const actor2 = createRandomActor({ actorHost: 'https://host2.test' }); - - const actor2Note = createRandomNote(actor2); - const featured = createRandomFeaturedCollection(actor1, 0); - (featured.items as IPost[]).push({ - ...actor2Note, - content: 'test test bar', // fraud! - }); - - resolver.register(actor1.id, actor1); - resolver.register(actor1.featured, featured); - resolver.register(actor2.id, actor2); - resolver.register(actor2Note.id, actor2Note); - - await personService.createPerson(actor1.id, resolver); - - // actor2Note is from a different server and needs to be fetched again - assert.deepStrictEqual( - resolver.remoteGetTrials(), - [actor1.id, actor1.featured, actor2Note.id, actor2.id], - ); - - const note = await noteService.fetchNote(actor2Note.id); - assert.ok(note); - - // Reflects the original content instead of the fraud - assert.strictEqual(note.text, 'test test foo'); - assert.strictEqual(note.uri, actor2Note.id); - }); - - test('Fetch a note that is a featured note of the attributed actor', async () => { - const actor = createRandomActor(); - actor.featured = `${actor.id}/collections/featured`; - - const featured = createRandomFeaturedCollection(actor, 5); - const firstNote = (featured.items as NonTransientIPost[])[0]; - - resolver.register(actor.id, actor); - resolver.register(actor.featured, featured); - resolver.register(firstNote.id, firstNote); - - const note = await noteService.createNote(firstNote.id as string, resolver); - assert.strictEqual(note?.uri, firstNote.id); - }); - }); - - describe('Images', () => { - test('Create images', async () => { - const imageObject: IApDocument = { - type: 'Document', - mediaType: 'image/png', - url: 'http://host1.test/foo.png', - name: '', - }; - const driveFile = await imageService.createImage( - await createRandomRemoteUser(resolver, personService), - imageObject, - ); - assert.ok(!driveFile.isLink); - - const sensitiveImageObject: IApDocument = { - type: 'Document', - mediaType: 'image/png', - url: 'http://host1.test/bar.png', - name: '', - sensitive: true, - }; - const sensitiveDriveFile = await imageService.createImage( - await createRandomRemoteUser(resolver, personService), - sensitiveImageObject, - ); - assert.ok(!sensitiveDriveFile.isLink); - }); - - test('cacheRemoteFiles=false disables caching', async () => { - meta = { ...metaInitial, cacheRemoteFiles: false }; - - const imageObject: IApDocument = { - type: 'Document', - mediaType: 'image/png', - url: 'http://host1.test/foo.png', - name: '', - }; - const driveFile = await imageService.createImage( - await createRandomRemoteUser(resolver, personService), - imageObject, - ); - assert.ok(driveFile.isLink); - - const sensitiveImageObject: IApDocument = { - type: 'Document', - mediaType: 'image/png', - url: 'http://host1.test/bar.png', - name: '', - sensitive: true, - }; - const sensitiveDriveFile = await imageService.createImage( - await createRandomRemoteUser(resolver, personService), - sensitiveImageObject, - ); - assert.ok(sensitiveDriveFile.isLink); - }); - - test('cacheRemoteSensitiveFiles=false only affects sensitive files', async () => { - meta = { ...metaInitial, cacheRemoteSensitiveFiles: false }; - - const imageObject: IApDocument = { - type: 'Document', - mediaType: 'image/png', - url: 'http://host1.test/foo.png', - name: '', - }; - const driveFile = await imageService.createImage( - await createRandomRemoteUser(resolver, personService), - imageObject, - ); - assert.ok(!driveFile.isLink); - - const sensitiveImageObject: IApDocument = { - type: 'Document', - mediaType: 'image/png', - url: 'http://host1.test/bar.png', - name: '', - sensitive: true, - }; - const sensitiveDriveFile = await imageService.createImage( - await createRandomRemoteUser(resolver, personService), - sensitiveImageObject, - ); - assert.ok(sensitiveDriveFile.isLink); - }); - }); -}); diff --git a/packages/backend/test/unit/ap-request.ts b/packages/backend/test/unit/ap-request.ts deleted file mode 100644 index 9edd53d274..0000000000 --- a/packages/backend/test/unit/ap-request.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as assert from 'assert'; -import httpSignature from '@peertube/http-signature'; - -import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; -import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js'; - -export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => { - return { - scheme: 'Signature', - params: { - keyId: 'KeyID', // dummy, not used for verify - algorithm: algorithm, - headers: ['(request-target)', 'date', 'host', 'digest'], // dummy, not used for verify - signature: signature, - }, - signingString: signingString, - algorithm: algorithm.toUpperCase(), - keyId: 'KeyID', // dummy, not used for verify - }; -}; - -describe('ap-request', () => { - test('createSignedPost with verify', async () => { - const keypair = await genRsaKeyPair(); - const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey }; - const url = 'https://example.com/inbox'; - const activity = { a: 1 }; - const body = JSON.stringify(activity); - const headers = { - 'User-Agent': 'UA', - }; - - const req = ApRequestCreator.createSignedPost({ key, url, body, additionalHeaders: headers }); - - const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256'); - - const result = httpSignature.verifySignature(parsed, keypair.publicKey); - assert.deepStrictEqual(result, true); - }); - - test('createSignedGet with verify', async () => { - const keypair = await genRsaKeyPair(); - const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey }; - const url = 'https://example.com/outbox'; - const headers = { - 'User-Agent': 'UA', - }; - - const req = ApRequestCreator.createSignedGet({ key, url, additionalHeaders: headers }); - - const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256'); - - const result = httpSignature.verifySignature(parsed, keypair.publicKey); - assert.deepStrictEqual(result, true); - }); -}); diff --git a/packages/backend/test/unit/extract-mentions.ts b/packages/backend/test/unit/extract-mentions.ts deleted file mode 100644 index 195e9b8198..0000000000 --- a/packages/backend/test/unit/extract-mentions.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as assert from 'assert'; - -import { parse } from '@sharkey/sfm-js'; -import { extractMentions } from '@/misc/extract-mentions.js'; - -describe('Extract mentions', () => { - test('simple', () => { - const ast = parse('@foo @bar @baz'); - const mentions = extractMentions(ast); - assert.deepStrictEqual(mentions, [{ - username: 'foo', - acct: '@foo', - host: null, - }, { - username: 'bar', - acct: '@bar', - host: null, - }, { - username: 'baz', - acct: '@baz', - host: null, - }]); - }); - - test('nested', () => { - const ast = parse('@foo **@bar** @baz'); - const mentions = extractMentions(ast); - assert.deepStrictEqual(mentions, [{ - username: 'foo', - acct: '@foo', - host: null, - }, { - username: 'bar', - acct: '@bar', - host: null, - }, { - username: 'baz', - acct: '@baz', - host: null, - }]); - }); -}); diff --git a/packages/backend/test/unit/misc/check-word-mute.ts b/packages/backend/test/unit/misc/check-word-mute.ts deleted file mode 100644 index 12bfca8bd7..0000000000 --- a/packages/backend/test/unit/misc/check-word-mute.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { checkWordMute } from '@/misc/check-word-mute.js'; - -describe(checkWordMute, () => { - describe('Slacc boost mode', () => { - it('should return false if mutedWords is empty', async () => { - expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [])).toBe(false); - }); - it('should return true if mutedWords is not empty and text contains muted word', async () => { - expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [['foo']])).toBe(true); - }); - it('should return false if mutedWords is not empty and text does not contain muted word', async () => { - expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [['bar']])).toBe(false); - }); - it('should return false when the note is written by me even if mutedWords is not empty and text contains muted word', async () => { - expect(await checkWordMute({ userId: '1', text: 'foo' }, { id: '1' }, [['foo']])).toBe(false); - }); - it('should return true if mutedWords is not empty and text contains muted word in CW', async () => { - expect(await checkWordMute({ userId: '1', text: 'foo', cw: 'bar' }, null, [['bar']])).toBe(true); - }); - it('should return true if mutedWords is not empty and text contains muted word in both CW and text', async () => { - expect(await checkWordMute({ userId: '1', text: 'foo', cw: 'bar' }, null, [['foo'], ['bar']])).toBe(true); - }); - it('should return true if mutedWords is not empty and text does not contain muted word in both CW and text', async () => { - expect(await checkWordMute({ userId: '1', text: 'foo', cw: 'bar' }, null, [['foo'], ['baz']])).toBe(true); - }); - }); - describe('normal mode', () => { - it('should return false if text does not contain muted words', async () => { - expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [['foo', 'bar']])).toBe(false); - }); - it('should return true if text contains muted words', async () => { - expect(await checkWordMute({ userId: '1', text: 'foobar' }, null, [['foo', 'bar']])).toBe(true); - }); - it('should return false when the note is written by me even if text contains muted words', async () => { - expect(await checkWordMute({ userId: '1', text: 'foo bar' }, { id: '1' }, [['foo', 'bar']])).toBe(false); - }); - }); - describe('RegExp mode', () => { - it('should return false if text does not contain muted words', async () => { - expect(await checkWordMute({ userId: '1', text: 'foo' }, null, ['/bar/'])).toBe(false); - }); - it('should return true if text contains muted words', async () => { - expect(await checkWordMute({ userId: '1', text: 'foobar' }, null, ['/bar/'])).toBe(true); - }); - it('should return false when the note is written by me even if text contains muted words', async () => { - expect(await checkWordMute({ userId: '1', text: 'foo bar' }, { id: '1' }, ['/bar/'])).toBe(false); - }); - }); -}); diff --git a/packages/backend/test/unit/misc/correct-filename.ts b/packages/backend/test/unit/misc/correct-filename.ts deleted file mode 100644 index 0c4482e0bf..0000000000 --- a/packages/backend/test/unit/misc/correct-filename.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { correctFilename } from '@/misc/correct-filename.js'; - -describe(correctFilename, () => { - it('no ext to null', () => { - expect(correctFilename('test', null)).toBe('test.unknown'); - }); - it('no ext to jpg', () => { - expect(correctFilename('test', 'jpg')).toBe('test.jpg'); - }); - it('jpg to webp', () => { - expect(correctFilename('test.jpg', 'webp')).toBe('test.jpg.webp'); - }); - it('jpg to .webp', () => { - expect(correctFilename('test.jpg', '.webp')).toBe('test.jpg.webp'); - }); - it('jpeg to jpg', () => { - expect(correctFilename('test.jpeg', 'jpg')).toBe('test.jpeg'); - }); - it('JPEG to jpg', () => { - expect(correctFilename('test.JPEG', 'jpg')).toBe('test.JPEG'); - }); - it('jpg to jpg', () => { - expect(correctFilename('test.jpg', 'jpg')).toBe('test.jpg'); - }); - it('JPG to jpg', () => { - expect(correctFilename('test.JPG', 'jpg')).toBe('test.JPG'); - }); - it('tiff to tif', () => { - expect(correctFilename('test.tiff', 'tif')).toBe('test.tiff'); - }); - it('skip gz', () => { - expect(correctFilename('test.unitypackage', 'gz')).toBe('test.unitypackage'); - }); - it('skip text file', () => { - expect(correctFilename('test.txt', null)).toBe('test.txt'); - }); - it('unknown', () => { - expect(correctFilename('test.hoge', null)).toBe('test.hoge'); - }); - test('non ascii with space', () => { - expect(correctFilename('ファイル 名前', 'jpg')).toBe('ファイル 名前.jpg'); - }); -}); diff --git a/packages/backend/test/unit/misc/id.ts b/packages/backend/test/unit/misc/id.ts deleted file mode 100644 index 59783a9fa1..0000000000 --- a/packages/backend/test/unit/misc/id.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { ulid } from 'ulid'; -import { describe, test, expect } from '@jest/globals'; -import { aidRegExp, genAid, parseAid } from '@/misc/id/aid.js'; -import { aidxRegExp, genAidx, parseAidx } from '@/misc/id/aidx.js'; -import { genMeid, meidRegExp, parseMeid } from '@/misc/id/meid.js'; -import { genMeidg, meidgRegExp, parseMeidg } from '@/misc/id/meidg.js'; -import { genObjectId, objectIdRegExp, parseObjectId } from '@/misc/id/object-id.js'; -import { ulidRegExp, parseUlid } from '@/misc/id/ulid.js'; - -describe('misc:id', () => { - test('aid', () => { - const date = Date.now(); - const gotAid = genAid(date); - expect(gotAid).toMatch(aidRegExp); - expect(parseAid(gotAid).date.getTime()).toBe(date); - }); - - test('aidx', () => { - const date = Date.now(); - const gotAidx = genAidx(date); - expect(gotAidx).toMatch(aidxRegExp); - expect(parseAidx(gotAidx).date.getTime()).toBe(date); - }); - - test('meid', () => { - const date = Date.now(); - const gotMeid = genMeid(date); - expect(gotMeid).toMatch(meidRegExp); - expect(parseMeid(gotMeid).date.getTime()).toBe(date); - }); - - test('meidg', () => { - const date = Date.now(); - const gotMeidg = genMeidg(date); - expect(gotMeidg).toMatch(meidgRegExp); - expect(parseMeidg(gotMeidg).date.getTime()).toBe(date); - }); - - test('objectid', () => { - const date = Date.now(); - const gotObjectId = genObjectId(date); - expect(gotObjectId).toMatch(objectIdRegExp); - expect(Math.floor(parseObjectId(gotObjectId).date.getTime() / 1000)).toBe(Math.floor(date / 1000)); - }); - - test('ulid', () => { - const date = Date.now(); - const gotUlid = ulid(date); - expect(gotUlid).toMatch(ulidRegExp); - expect(parseUlid(gotUlid).date.getTime()).toBe(date); - }); -}); diff --git a/packages/backend/test/unit/misc/loader.ts b/packages/backend/test/unit/misc/loader.ts deleted file mode 100644 index fa37950951..0000000000 --- a/packages/backend/test/unit/misc/loader.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { DebounceLoader } from '@/misc/loader.js'; - -class Mock { - loadCountByKey = new Map(); - load = async (key: number): Promise => { - const count = this.loadCountByKey.get(key); - if (typeof count === 'undefined') { - this.loadCountByKey.set(key, 1); - } else { - this.loadCountByKey.set(key, count + 1); - } - return key * 2; - }; - reset() { - this.loadCountByKey.clear(); - } -} - -describe(DebounceLoader, () => { - describe('single request', () => { - it('loads once', async () => { - const mock = new Mock(); - const loader = new DebounceLoader(mock.load); - expect(await loader.load(7)).toBe(14); - expect(mock.loadCountByKey.size).toBe(1); - expect(mock.loadCountByKey.get(7)).toBe(1); - }); - }); - - describe('two duplicated requests at same time', () => { - it('loads once', async () => { - const mock = new Mock(); - const loader = new DebounceLoader(mock.load); - const [v1, v2] = await Promise.all([ - loader.load(7), - loader.load(7), - ]); - expect(v1).toBe(14); - expect(v2).toBe(14); - expect(mock.loadCountByKey.size).toBe(1); - expect(mock.loadCountByKey.get(7)).toBe(1); - }); - }); - - describe('two different requests at same time', () => { - it('loads twice', async () => { - const mock = new Mock(); - const loader = new DebounceLoader(mock.load); - const [v1, v2] = await Promise.all([ - loader.load(7), - loader.load(13), - ]); - expect(v1).toBe(14); - expect(v2).toBe(26); - expect(mock.loadCountByKey.size).toBe(2); - expect(mock.loadCountByKey.get(7)).toBe(1); - expect(mock.loadCountByKey.get(13)).toBe(1); - }); - }); - - describe('non-continuous same two requests', () => { - it('loads twice', async () => { - const mock = new Mock(); - const loader = new DebounceLoader(mock.load); - expect(await loader.load(7)).toBe(14); - expect(mock.loadCountByKey.size).toBe(1); - expect(mock.loadCountByKey.get(7)).toBe(1); - mock.reset(); - expect(await loader.load(7)).toBe(14); - expect(mock.loadCountByKey.size).toBe(1); - expect(mock.loadCountByKey.get(7)).toBe(1); - }); - }); - - describe('non-continuous different two requests', () => { - it('loads twice', async () => { - const mock = new Mock(); - const loader = new DebounceLoader(mock.load); - expect(await loader.load(7)).toBe(14); - expect(mock.loadCountByKey.size).toBe(1); - expect(mock.loadCountByKey.get(7)).toBe(1); - mock.reset(); - expect(await loader.load(13)).toBe(26); - expect(mock.loadCountByKey.size).toBe(1); - expect(mock.loadCountByKey.get(13)).toBe(1); - }); - }); -}); diff --git a/packages/backend/test/unit/misc/others.ts b/packages/backend/test/unit/misc/others.ts deleted file mode 100644 index b16d26d866..0000000000 --- a/packages/backend/test/unit/misc/others.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { describe, test, expect } from '@jest/globals'; -import { contentDisposition } from '@/misc/content-disposition.js'; - -describe('misc:content-disposition', () => { - test('inline', () => { - expect(contentDisposition('inline', 'foo bar')).toBe('inline; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar'); - }); - test('attachment', () => { - expect(contentDisposition('attachment', 'foo bar')).toBe('attachment; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar'); - }); - test('non ascii', () => { - expect(contentDisposition('attachment', 'ファイル名')).toBe('attachment; filename=\"_____\"; filename*=UTF-8\'\'%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E5%90%8D'); - }); -}); diff --git a/packages/backend/test/user-notes.ts b/packages/backend/test/user-notes.ts new file mode 100644 index 0000000000..4447754d66 --- /dev/null +++ b/packages/backend/test/user-notes.ts @@ -0,0 +1,61 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import * as childProcess from 'child_process'; +import { async, signup, request, post, uploadUrl, startServer, shutdownServer } from './utils.js'; + +describe('users/notes', () => { + let p: childProcess.ChildProcess; + + let alice: any; + let jpgNote: any; + let pngNote: any; + let jpgPngNote: any; + + before(async () => { + p = await startServer(); + alice = await signup({ username: 'alice' }); + const jpg = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); + const png = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.png'); + jpgNote = await post(alice, { + fileIds: [jpg.id], + }); + pngNote = await post(alice, { + fileIds: [png.id], + }); + jpgPngNote = await post(alice, { + fileIds: [jpg.id, png.id], + }); + }); + + after(async() => { + await shutdownServer(p); + }); + + it('ファイルタイプ指定 (jpg)', async(async () => { + const res = await request('/users/notes', { + userId: alice.id, + fileType: ['image/jpeg'], + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 2); + assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true); + })); + + it('ファイルタイプ指定 (jpg or png)', async(async () => { + const res = await request('/users/notes', { + userId: alice.id, + fileType: ['image/jpeg', 'image/png'], + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 3); + assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === pngNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true); + })); +}); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 8c5fa0006c..245cf858d8 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -1,134 +1,93 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as assert from 'node:assert'; -import { readFile } from 'node:fs/promises'; -import { isAbsolute, basename } from 'node:path'; -import { randomUUID } from 'node:crypto'; -import { inspect } from 'node:util'; -import WebSocket, { ClientOptions } from 'ws'; -import fetch, { File, RequestInit } from 'node-fetch'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import * as childProcess from 'child_process'; +import * as http from 'node:http'; +import { SIGKILL } from 'constants'; +import WebSocket from 'ws'; +import * as misskey from 'misskey-js'; +import fetch from 'node-fetch'; +import FormData from 'form-data'; import { DataSource } from 'typeorm'; -import { JSDOM } from 'jsdom'; -import { DEFAULT_POLICIES } from '@/core/RoleService.js'; -import { Packed } from '@/misc/json-schema.js'; -import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; -import { entities } from '../src/postgres.js'; -import { loadConfig } from '../src/config.js'; -import type * as misskey from 'misskey-js'; +import loadConfig from '../src/config/load.js'; +import { entities } from '../src/db/postgre.js'; +import got from 'got'; -export { server as startServer } from '@/boot/common.js'; - -interface UserToken { - token: string; - bearer?: boolean; -} +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); const config = loadConfig(); export const port = config.port; -export const origin = config.url; -export const host = new URL(config.url).host; -export const cookie = (me: UserToken): string => { - return `token=${me.token};`; +export const async = (fn: Function) => (done: Function) => { + fn().then(() => { + done(); + }, (err: Error) => { + done(err); + }); }; -export const api = async (endpoint: string, params: any, me?: UserToken) => { - const normalized = endpoint.replace(/^\//, ''); - return await request(`api/${normalized}`, params, me); -}; +export const api = async (endpoint: string, params: any, me?: any) => { + endpoint = endpoint.replace(/^\//, ''); -export type ApiRequest = { - endpoint: string, - parameters: object, - user: UserToken | undefined, -}; + const auth = me ? { + i: me.token + } : {}; -export const successfulApiCall = async (request: ApiRequest, assertion: { - status?: number, -} = {}): Promise => { - const { endpoint, parameters, user } = request; - const res = await api(endpoint, parameters, user); - const status = assertion.status ?? (res.body == null ? 204 : 200); - assert.strictEqual(res.status, status, inspect(res.body, { depth: 5, colors: true })); - return res.body; -}; - -export const failedApiCall = async (request: ApiRequest, assertion: { - status: number, - code: string, - id: string -}): Promise => { - const { endpoint, parameters, user } = request; - const { status, code, id } = assertion; - const res = await api(endpoint, parameters, user); - assert.strictEqual(res.status, status, inspect(res.body)); - assert.strictEqual(res.body.error.code, code, inspect(res.body)); - assert.strictEqual(res.body.error.id, id, inspect(res.body)); - return res.body; -}; - -const request = async (path: string, params: any, me?: UserToken): Promise<{ status: number, headers: Headers, body: any }> => { - const bodyAuth: Record = {}; - const headers: Record = { - 'Content-Type': 'application/json', - }; - - if (me?.bearer) { - headers.Authorization = `Bearer ${me.token}`; - } else if (me) { - bodyAuth.i = me.token; - } - - const res = await relativeFetch(path, { + const res = await got(`http://localhost:${port}/api/${endpoint}`, { method: 'POST', - headers, - body: JSON.stringify(Object.assign(bodyAuth, params)), - redirect: 'manual', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(Object.assign(auth, params)), + retry: { + limit: 0, + }, + hooks: { + beforeError: [ + error => { + const { response } = error; + if (response && response.body) console.warn(response.body); + return error; + } + ] + }, }); - const body = res.headers.get('content-type') === 'application/json; charset=utf-8' - ? await res.json() - : null; + const status = res.statusCode; + const body = res.statusCode !== 204 ? await JSON.parse(res.body) : null; return { - status: res.status, - headers: res.headers, - body, + status, + body }; }; -export const relativeFetch = async (path: string, init?: RequestInit | undefined) => { - return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init); +export const request = async (endpoint: string, params: any, me?: any): Promise<{ body: any, status: number }> => { + const auth = me ? { + i: me.token, + } : {}; + + const res = await fetch(`http://localhost:${port}/api${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(Object.assign(auth, params)), + }); + + const status = res.status; + const body = res.status !== 204 ? await res.json().catch() : null; + + return { + body, status, + }; }; -export function randomString(chars = 'abcdefghijklmnopqrstuvwxyz0123456789', length = 16) { - let randomString = ''; - for (let i = 0; i < length; i++) { - randomString += chars[Math.floor(Math.random() * chars.length)]; - } - return randomString; -} - -/** - * @brief プロミスにタイムアウト追加 - * @param p 待ち対象プロミス - * @param timeout 待機ミリ秒 - */ -function timeoutPromise(p: Promise, timeout: number): Promise { - return Promise.race([ - p, - new Promise((reject) => { - setTimeout(() => { reject(new Error('timed out')); }, timeout); - }) as never, - ]); -} - -export const signup = async (params?: Partial): Promise> => { +export const signup = async (params?: any): Promise => { const q = Object.assign({ - username: randomString(), + username: 'test', password: 'test', }, params); @@ -137,230 +96,73 @@ export const signup = async (params?: Partial => { - const q = params; +export const post = async (user: any, params?: misskey.Endpoints['notes/create']['req']): Promise => { + const q = Object.assign({ + text: 'test', + }, params); const res = await api('notes/create', q, user); return res.body ? res.body.createdNote : null; }; -export const createAppToken = async (user: UserToken, permissions: (typeof misskey.permissions)[number][]) => { - const res = await api('miauth/gen-token', { - session: randomUUID(), - permission: permissions, - }, user); - - return (res.body as misskey.entities.MiauthGenTokenResponse).token; -}; - -// 非公開ノートをAPI越しに見たときのノート NoteEntityService.ts -export const hiddenNote = (note: any): any => { - const temp = { - ...note, - fileIds: [], - files: [], - text: null, - cw: null, - isHidden: true, - }; - delete temp.visibleUserIds; - delete temp.poll; - return temp; -}; - -export const react = async (user: UserToken, note: any, reaction: string): Promise => { +export const react = async (user: any, note: any, reaction: string): Promise => { await api('notes/reactions/create', { noteId: note.id, reaction: reaction, }, user); }; -export const userList = async (user: UserToken, userList: any = {}): Promise => { - const res = await api('users/lists/create', { - name: 'test', - }, user); - return res.body; -}; - -export const page = async (user: UserToken, page: any = {}): Promise => { - const res = await api('pages/create', { - alignCenter: false, - content: [ - { - id: '2be9a64b-5ada-43a3-85f3-ec3429551ded', - text: 'Hello World!', - type: 'text', - }, - ], - eyeCatchingImageId: null, - font: 'sans-serif', - hideTitleWhenPinned: false, - name: '1678594845072', - script: '', - summary: null, - title: '', - variables: [], - ...page, - }, user); - return res.body; -}; - -export const play = async (user: UserToken, play: any = {}): Promise => { - const res = await api('flash/create', { - permissions: [], - script: 'test', - summary: '', - title: 'test', - ...play, - }, user); - return res.body; -}; - -export const clip = async (user: UserToken, clip: any = {}): Promise => { - const res = await api('clips/create', { - description: null, - isPublic: true, - name: 'test', - ...clip, - }, user); - return res.body; -}; - -export const galleryPost = async (user: UserToken, channel: any = {}): Promise => { - const res = await api('gallery/posts/create', { - description: null, - fileIds: [], - isSensitive: false, - title: 'test', - ...channel, - }, user); - return res.body; -}; - -export const channel = async (user: UserToken, channel: any = {}): Promise => { - const res = await api('channels/create', { - bannerId: null, - description: null, - name: 'test', - ...channel, - }, user); - return res.body; -}; - -export const role = async (user: UserToken, role: any = {}, policies: any = {}): Promise => { - const res = await api('admin/roles/create', { - asBadge: false, - canEditMembersByModerator: false, - color: null, - condFormula: { - id: 'ebef1684-672d-49b6-ad82-1b3ec3784f85', - type: 'isRemote', - }, - description: '', - displayOrder: 0, - iconUrl: null, - isAdministrator: false, - isModerator: false, - isPublic: false, - name: 'New Role', - target: 'manual', - policies: { - ...Object.entries(DEFAULT_POLICIES).map(([k, v]) => [k, { - priority: 0, - useDefault: true, - value: v, - }]), - ...policies, - }, - ...role, - }, user); - return res.body; -}; - -interface UploadOptions { - /** Optional, absolute path or relative from ./resources/ */ - path?: string | URL; - /** The name to be used for the file upload */ - name?: string; - /** A Blob can be provided instead of path */ - blob?: Blob; -} - /** * Upload file * @param user User + * @param _path Optional, absolute path or relative from ./resources/ */ -export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadOptions = {}): Promise<{ status: number, headers: Headers, body: misskey.Endpoints['drive/files/create']['res'] | null }> => { - const absPath = path == null - ? new URL('resources/Lenna.jpg', import.meta.url) - : isAbsolute(path.toString()) - ? new URL(path) - : new URL(path, new URL('resources/', import.meta.url)); +export const uploadFile = async (user: any, _path?: string): Promise => { + const absPath = _path == null ? `${_dirname}/resources/Lenna.jpg` : path.isAbsolute(_path) ? _path : `${_dirname}/resources/${_path}`; - const formData = new FormData(); - formData.append('file', blob ?? - new File([await readFile(absPath)], basename(absPath.toString()))); + const formData = new FormData() as any; + formData.append('i', user.token); + formData.append('file', fs.createReadStream(absPath)); formData.append('force', 'true'); - if (name) { - formData.append('name', name); - } - const headers: Record = {}; - if (user?.bearer) { - headers.Authorization = `Bearer ${user.token}`; - } else if (user) { - formData.append('i', user.token); - } - - const res = await relativeFetch('api/drive/files/create', { + const res = await got(`http://localhost:${port}/api/drive/files/create`, { method: 'POST', body: formData, - headers, + retry: { + limit: 0, + }, }); - const body = res.status !== 204 ? await res.json() as misskey.Endpoints['drive/files/create']['res'] : null; - return { - status: res.status, - headers: res.headers, - body, - }; + const body = res.statusCode !== 204 ? await JSON.parse(res.body) : null; + + return body; }; -export const uploadUrl = async (user: UserToken, url: string) => { - let resolve: unknown; - const file = new Promise(ok => resolve = ok); - const marker = Math.random().toString(); +export const uploadUrl = async (user: any, url: string) => { + let file: any; - const catcher = makeStreamCatcher( - user, - 'main', - (msg) => msg.type === 'urlUploadFinished' && msg.body.marker === marker, - (msg) => msg.body.file as Packed<'DriveFile'>, - 60 * 1000, - ); + const ws = await connectStream(user, 'main', (msg) => { + if (msg.type === 'driveFileCreated') { + file = msg.body; + } + }); await api('drive/files/upload-from-url', { url, - marker, force: true, }, user); + await sleep(5000); + ws.close(); + return file; }; -export function connectStream(user: UserToken, channel: string, listener: (message: Record) => any, params?: any): Promise { +export function connectStream(user: any, channel: string, listener: (message: Record) => any, params?: any): Promise { return new Promise((res, rej) => { - const url = new URL(`ws://127.0.0.1:${port}/streaming`); - const options: ClientOptions = {}; - if (user.bearer) { - options.headers = { Authorization: `Bearer ${user.token}` }; - } else { - url.searchParams.set('i', user.token); - } - const ws = new WebSocket(url, options); + const ws = new WebSocket(`ws://localhost:${port}/streaming?i=${user.token}`); - ws.on('unexpected-response', (req, res) => rej(res)); ws.on('open', () => { ws.on('message', data => { const msg = JSON.parse(data.toString()); @@ -384,9 +186,9 @@ export function connectStream(user: UserToken, channel: string, listener: (messa }); } -export const waitFire = async (user: UserToken, channel: string, trgr: () => any, cond: (msg: Record) => boolean, params?: any) => { +export const waitFire = async (user: any, channel: string, trgr: () => any, cond: (msg: Record) => boolean, params?: any) => { return new Promise(async (res, rej) => { - let timer: NodeJS.Timeout | null = null; + let timer: NodeJS.Timeout; let ws: WebSocket; try { @@ -415,176 +217,47 @@ export const waitFire = async (user: UserToken, channel: string, trgr: () => any if (timer) clearTimeout(timer); rej(e); } - }); + }) }; -/** - * @brief WebSocketストリームから特定条件の通知を拾うプロミスを生成 - * @param user ユーザー認証情報 - * @param channel チャンネル - * @param cond 条件 - * @param extractor 取り出し処理 - * @param timeout ミリ秒タイムアウト - * @returns 時間内に正常に処理できた場合に通知からextractorを通した値を得る - */ -export function makeStreamCatcher( - user: UserToken, - channel: string, - cond: (message: Record) => boolean, - extractor: (message: Record) => T, - timeout = 60 * 1000): Promise { - let ws: WebSocket; - const p = new Promise(async (resolve) => { - ws = await connectStream(user, channel, (msg) => { - if (cond(msg)) { - resolve(extractor(msg)); +export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status?: number, type?: string, location?: string }> => { + // node-fetchだと3xxを取れない + return await new Promise((resolve, reject) => { + const req = http.request(`http://localhost:${port}${path}`, { + headers: { + Accept: accept, + }, + }, res => { + if (res.statusCode! >= 400) { + reject(res); + } else { + resolve({ + status: res.statusCode, + type: res.headers['content-type'], + location: res.headers.location, + }); } }); - }).finally(() => { - ws.close(); + + req.end(); }); - - return timeoutPromise(p, timeout); -} - -export type SimpleGetResponse = { - status: number, - body: any | JSDOM | null, - type: string | null, - location: string | null -}; -export const simpleGet = async (path: string, accept = '*/*', cookie: any = undefined): Promise => { - const res = await relativeFetch(path, { - headers: { - Accept: accept, - Cookie: cookie, - }, - redirect: 'manual', - }); - - const jsonTypes = [ - 'application/json; charset=utf-8', - 'application/activity+json; charset=utf-8', - ]; - const htmlTypes = [ - 'text/html; charset=utf-8', - ]; - - if (res.ok && ( - accept.startsWith('application/activity+json') || - (accept.startsWith('application/ld+json') && accept.includes('https://www.w3.org/ns/activitystreams')) - )) { - // validateContentTypeSetAsActivityPubのテストを兼ねる - validateContentTypeSetAsActivityPub(res); - } - - const body = - jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() : - htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) : - null; - - return { - status: res.status, - body, - type: res.headers.get('content-type'), - location: res.headers.get('location'), - }; }; -/** - * あるAPIエンドポイントのPaginationが複数の条件で一貫した挙動であることをテストします。 - * (sinceId, untilId, sinceDate, untilDate, offset, limit) - * @param expected 期待値となるEntityの並び(例:Note[])昇順降順が一致している必要がある - * @param fetchEntities Entity[]を返却するテスト対象のAPIを呼び出す関数 - * @param offsetBy 何をキーとしてPaginationするか。 - * @param ordering 昇順・降順 - */ -export async function testPaginationConsistency( - expected: Entity[], - fetchEntities: (paginationParam: { - limit?: number, - offset?: number, - sinceId?: string, - untilId?: string, - sinceDate?: number, - untilDate?: number, - }) => Promise, - offsetBy: 'offset' | 'id' | 'createdAt' = 'id', - ordering: 'desc' | 'asc' = 'desc'): Promise { - const rangeToParam = (p: { limit?: number, until?: Entity, since?: Entity }): object => { - if (offsetBy === 'id') { - return { limit: p.limit, sinceId: p.since?.id, untilId: p.until?.id }; - } else { - const sinceDate = p.since?.createdAt !== undefined ? new Date(p.since.createdAt).getTime() : undefined; - const untilDate = p.until?.createdAt !== undefined ? new Date(p.until.createdAt).getTime() : undefined; - return { limit: p.limit, sinceDate, untilDate }; - } +export function launchServer(callbackSpawnedProcess: (p: childProcess.ChildProcess) => void, moreProcess: () => Promise = async () => {}) { + return (done: (err?: Error) => any) => { + const p = childProcess.spawn('node', [_dirname + '/../index.js'], { + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + env: { NODE_ENV: 'test', PATH: process.env.PATH }, + }); + callbackSpawnedProcess(p); + p.on('message', message => { + if (message === 'ok') moreProcess().then(() => done()).catch(e => done(e)); + }); }; - - for (const limit of [1, 5, 10, 100, undefined]) { - /* - // 1. sinceId/DateとuntilId/Dateで両端を指定して取得した結果が期待通りになっていること - if (ordering === 'desc') { - const end = expected.at(-1)!; - let last = await fetchEntities(rangeToParam({ limit, since: end })); - const actual: Entity[] = []; - while (last.length !== 0) { - actual.push(...last); - last = await fetchEntities(rangeToParam({ limit, until: last.at(-1), since: end })); - } - actual.push(end); - assert.deepStrictEqual( - actual.map(({ id, createdAt }) => id + ':' + createdAt), - expected.map(({ id, createdAt }) => id + ':' + createdAt)); - } - - // 2. sinceId/Date指定+limitで取得してつなぎ合わせた結果が期待通りになっていること - if (ordering === 'asc') { - // 昇順にしたときの先頭(一番古いもの)をもってくる(expected[1]を基準に降順にして0番目) - let last = await fetchEntities({ limit: 1, untilId: expected[1].id }); - const actual: Entity[] = []; - while (last.length !== 0) { - actual.push(...last); - last = await fetchEntities(rangeToParam({ limit, since: last.at(-1) })); - } - assert.deepStrictEqual( - actual.map(({ id, createdAt }) => id + ':' + createdAt), - expected.map(({ id, createdAt }) => id + ':' + createdAt)); - } - */ - - // 3. untilId指定+limitで取得してつなぎ合わせた結果が期待通りになっていること - if (ordering === 'desc') { - let last = await fetchEntities({ limit }); - const actual: Entity[] = []; - while (last.length !== 0) { - actual.push(...last); - last = await fetchEntities(rangeToParam({ limit, until: last.at(-1) })); - } - assert.deepStrictEqual( - actual.map(({ id, createdAt }) => id + ':' + createdAt), - expected.map(({ id, createdAt }) => id + ':' + createdAt)); - } - - // 4. offset指定+limitで取得してつなぎ合わせた結果が期待通りになっていること - if (offsetBy === 'offset') { - let last = await fetchEntities({ limit, offset: 0 }); - let offset = limit ?? 10; - const actual: Entity[] = []; - while (last.length !== 0) { - actual.push(...last); - last = await fetchEntities({ limit, offset }); - offset += limit ?? 10; - } - assert.deepStrictEqual( - actual.map(({ id, createdAt }) => id + ':' + createdAt), - expected.map(({ id, createdAt }) => id + ':' + createdAt)); - } - } } export async function initTestDb(justBorrow = false, initEntities?: any[]) { - if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV is not a test'); + if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test'; const db = new DataSource({ type: 'postgres', @@ -595,7 +268,7 @@ export async function initTestDb(justBorrow = false, initEntities?: any[]) { database: config.db.db, synchronize: true && !justBorrow, dropSchema: true && !justBorrow, - entities: initEntities ?? entities, + entities: initEntities || entities, }); await db.initialize(); @@ -603,6 +276,45 @@ export async function initTestDb(justBorrow = false, initEntities?: any[]) { return db; } +export function startServer(timeout = 60 * 1000): Promise { + return new Promise((res, rej) => { + const t = setTimeout(() => { + p.kill(SIGKILL); + rej('timeout to start'); + }, timeout); + + const p = childProcess.spawn('node', [_dirname + '/../built/index.js'], { + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + env: { NODE_ENV: 'test', PATH: process.env.PATH }, + }); + + p.on('error', e => rej(e)); + + p.on('message', message => { + if (message === 'ok') { + clearTimeout(t); + res(p); + } + }); + }); +} + +export function shutdownServer(p: childProcess.ChildProcess, timeout = 20 * 1000) { + return new Promise((res, rej) => { + const t = setTimeout(() => { + p.kill(SIGKILL); + res('force exit'); + }, timeout); + + p.once('exit', () => { + clearTimeout(t); + res('exited'); + }); + + p.kill(); + }); +} + export function sleep(msec: number) { return new Promise(res => { setTimeout(() => { diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index 2b15a5cc7a..dea4eb27db 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "allowJs": true, - "noEmitOnError": true, + "noEmitOnError": false, "noImplicitAny": true, "noImplicitReturns": true, "noUnusedParameters": false, @@ -9,16 +9,15 @@ "noFallthroughCasesInSwitch": true, "declaration": false, "sourceMap": false, - "target": "ES2022", - "module": "nodenext", - "moduleResolution": "nodenext", + "target": "es2021", + "module": "es2020", + "moduleResolution": "node", "allowSyntheticDefaultImports": true, "removeComments": false, "noLib": false, "strict": true, "strictNullChecks": true, "strictPropertyInitialization": false, - "skipLibCheck": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "resolveJsonModule": true, @@ -26,16 +25,17 @@ "rootDir": "./src", "baseUrl": "./", "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] }, "outDir": "./built", "types": [ "node" ], "typeRoots": [ - "./src/@types", "./node_modules/@types", - "./node_modules" + "./src/@types" ], "lib": [ "esnext" @@ -45,7 +45,4 @@ "include": [ "./src/**/*.ts" ], - "exclude": [ - "./src/**/*.test.ts" - ] } diff --git a/packages/backend/watch.mjs b/packages/backend/watch.mjs index 81c23a0f50..9c9d2dbd86 100644 --- a/packages/backend/watch.mjs +++ b/packages/backend/watch.mjs @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - import { execa } from 'execa'; (async () => { diff --git a/packages/backend/yarn.lock b/packages/backend/yarn.lock new file mode 100644 index 0000000000..3be2f0d520 --- /dev/null +++ b/packages/backend/yarn.lock @@ -0,0 +1,8160 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/helper-validator-identifier@^7.12.11": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" + integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw== + +"@babel/parser@^7.6.0", "@babel/parser@^7.9.6": + version "7.13.9" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.13.9.tgz#ca34cb95e1c2dd126863a84465ae8ef66114be99" + integrity sha512-nEUfRiARCcaVo3ny3ZQjURjHQZUo/JkEw7rLlSZy/psWGnvwXFtPcr6jb7Yb41DVW5LTe6KRq9LGleRNsg1Frw== + +"@babel/types@^7.6.1", "@babel/types@^7.9.6": + version "7.13.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.13.0.tgz#74424d2816f0171b4100f0ab34e9a374efdf7f80" + integrity sha512-hE+HE8rnG1Z6Wzo+MhaKE5lM5eMx71T4EHJgku2E3xIfaULhDcxiiRxUYgwX8qwP1BBSlag+TdGOt6JAidIZTA== + dependencies: + "@babel/helper-validator-identifier" "^7.12.11" + lodash "^4.17.19" + to-fast-properties "^2.0.0" + +"@bull-board/api@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@bull-board/api/-/api-4.2.2.tgz#42838f4fda71a3bdca560ea7c6eb80b3d846f446" + integrity sha512-YFkkeWvMit0P04k+xu4ZZ22i24m+Tq/w82LBtpt3z9Xu1rGrZoui8CI/YRsaJJE0o9TsqL5tY653oFVcdg35pQ== + dependencies: + redis-info "^3.0.8" + +"@bull-board/koa@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@bull-board/koa/-/koa-4.2.2.tgz#97b74fde56d2df51c3cd2277cedc6f91a921dc63" + integrity sha512-ekrD3utbSM1PEdNcstvhli+aFjtdoFJpulkxoLfBPQweRc9yCzfqbgcg6g1DgjaNgQ5iEWLKGr3FSwBON5v6wQ== + dependencies: + "@bull-board/api" "4.2.2" + "@bull-board/ui" "4.2.2" + ejs "^3.1.7" + koa "^2.13.1" + koa-mount "^4.0.0" + koa-router "^10.0.0" + koa-static "^5.0.0" + koa-views "^7.0.1" + +"@bull-board/ui@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@bull-board/ui/-/ui-4.2.2.tgz#2d5d7cbabfdea292988458d58e267bbc4b33aff0" + integrity sha512-QLWWTtVj6kQ01ox4OqCs/IdKm+jWFtLvhBU7RwYt8UxmxA6dZ8ffS6hWmjWk5sJ4cKk9GzPoASYMgFv0AMuh0w== + dependencies: + "@bull-board/api" "4.2.2" + +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + +"@cto.af/textdecoder@^0.0.0": + version "0.0.0" + resolved "https://registry.yarnpkg.com/@cto.af/textdecoder/-/textdecoder-0.0.0.tgz#e1e8d84c936c30a0f4619971f19ca41941af9fdc" + integrity sha512-sJpx3F5xcVV/9jNYJQtvimo4Vfld/nD3ph+ZWtQzZ03Zo8rJC7QKQTRcIGS13Rcz80DwFNthCWMrd58vpY4ZAQ== + +"@digitalbazaar/http-client@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@digitalbazaar/http-client/-/http-client-3.2.0.tgz#b85ea09028c7d0f288f976c852d0a8f3875f0fcf" + integrity sha512-NhYXcWE/JDE7AnJikNX7q0S6zNuUPA2NuIoRdUpmvHlarjmRqyr6hIO3Awu2FxlUzbdiI1uzuWrZyB9mD1tTvw== + dependencies: + ky "^0.30.0" + ky-universal "^0.10.1" + undici "^5.2.0" + +"@discordapp/twemoji@14.0.2": + version "14.0.2" + resolved "https://registry.yarnpkg.com/@discordapp/twemoji/-/twemoji-14.0.2.tgz#50cc19f6f3769dc6b36eb251421b5f5d4629e837" + integrity sha512-eYJpFsjViDTYwq3f6v+tRu8iRc+yLAeGrlh6kmNRvvC6rroUE2bMlBfEQ/WNh+2Q1FtSEFXpxzuQPOHzRzbAyA== + dependencies: + fs-extra "^8.0.1" + jsonfile "^5.0.0" + twemoji-parser "14.0.0" + universalify "^0.1.2" + +"@elastic/elasticsearch@7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@elastic/elasticsearch/-/elasticsearch-7.11.0.tgz#e196243d0ed026742fc160d72cc5b4b5b6c7807d" + integrity sha512-AFVVuANIdbV1qYjuOi4hnsX/DehWYG+bbhQO4amq9K4/NnzU7mpGWOPgVlRQTiX+vBfBkx7SL6h4QEjIlM3ztA== + dependencies: + debug "^4.1.1" + hpagent "^0.1.1" + ms "^2.1.1" + pump "^3.0.0" + secure-json-parse "^2.1.0" + +"@eslint/eslintrc@^1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.1.tgz#de0807bfeffc37b964a7d0400e0c348ce5a2543d" + integrity sha512-OhSY22oQQdw3zgPOOwdoj01l/Dzl1Z+xyUP33tkSN+aqyEhymJCcPHyXt+ylW8FSe0TfRC2VG+ROQOapD0aZSQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^9.4.0" + globals "^13.15.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@gar/promisify@^1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" + integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== + +"@humanwhocodes/config-array@^0.10.4": + version "0.10.4" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.4.tgz#01e7366e57d2ad104feea63e72248f22015c520c" + integrity sha512-mXAIHxZT3Vcpg83opl1wGlVZ9xydbfZO3r5YfRSH6Gpp2J/PfdBP0wbDa2sO6/qRbcalpoevVyW6A/fI6LfeMw== + dependencies: + "@humanwhocodes/object-schema" "^1.2.1" + debug "^4.1.1" + minimatch "^3.0.4" + +"@humanwhocodes/gitignore-to-minimatch@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz#316b0a63b91c10e53f242efb4ace5c3b34e8728d" + integrity sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA== + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/object-schema@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" + integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== + +"@jridgewell/resolve-uri@^3.0.3": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.0.7.tgz#30cd49820a962aff48c8fffc5cd760151fca61fe" + integrity sha512-8cXDaBBHOr2pQ7j77Y6Vp5VDT2sIqWyWQ56TjEq4ih/a4iST3dItRe8Q9fp0rrIl9DoKhWQtUQz/YpOxLkXbNA== + +"@jridgewell/sourcemap-codec@^1.4.10": + version "1.4.13" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.13.tgz#b6461fb0c2964356c469e115f504c95ad97ab88c" + integrity sha512-GryiOJmNcWbovBxTfZSF71V/mXbgcV3MewDe3kIMCLyIh5e7SKAeUZs+rMnJ8jkMolZ/4/VsdBmMrw3l+VdZ3w== + +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + +"@koa/cors@3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.1.0.tgz#618bb073438cfdbd3ebd0e648a76e33b84f3a3b2" + integrity sha512-7ulRC1da/rBa6kj6P4g2aJfnET3z8Uf3SWu60cjbtxTA5g8lxRdX/Bd2P92EagGwwAhANeNw8T8if99rJliR6Q== + dependencies: + vary "^1.1.2" + +"@koa/multer@3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@koa/multer/-/multer-3.0.0.tgz#439777949f28097d7b329c0b4ce3048074c862f8" + integrity sha512-y+OQBmex5D1jIl723gAEUYcAWPEicIXppaAKw/zCMfpllQ08ZNweDPwoCLxEoatqd5pCu2XG6V8dl67JRq3RJw== + +"@koa/router@9.0.1": + version "9.0.1" + resolved "https://registry.yarnpkg.com/@koa/router/-/router-9.0.1.tgz#4090a14223ea7e78aa13b632761209cba69acd95" + integrity sha512-OI+OU49CJV4px0WkIMmayBeqVXB/JS1ZMq7UoGlTZt6Y7ijK7kdeQ18+SEHHJPytmtI1y6Hf8XLrpxva3mhv5Q== + dependencies: + debug "^4.1.1" + http-errors "^1.7.3" + koa-compose "^4.1.0" + methods "^1.1.2" + path-to-regexp "^6.1.0" + +"@mapbox/node-pre-gyp@1.0.9": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.9.tgz#09a8781a3a036151cdebbe8719d6f8b25d4058bc" + integrity sha512-aDF3S3rK9Q2gey/WAttUlISduDItz5BU3306M9Eyv6/oS40aMprnopshtlKTykxRNIBEZuRMaZAnbrQ4QtKGyw== + dependencies: + detect-libc "^2.0.0" + https-proxy-agent "^5.0.0" + make-dir "^3.1.0" + node-fetch "^2.6.7" + nopt "^5.0.0" + npmlog "^5.0.1" + rimraf "^3.0.2" + semver "^7.3.5" + tar "^6.1.11" + +"@node-redis/bloom@^1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@node-redis/bloom/-/bloom-1.0.1.tgz#144474a0b7dc4a4b91badea2cfa9538ce0a1854e" + integrity sha512-mXEBvEIgF4tUzdIN89LiYsbi6//EdpFA7L8M+DHCvePXg+bfHWi+ct5VI6nHUFQE5+ohm/9wmgihCH3HSkeKsw== + +"@node-redis/client@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@node-redis/client/-/client-1.0.2.tgz#7f09fb739675728fbc6e73536f7cd1be99bf7b8f" + integrity sha512-C+gkx68pmTnxfV+y4pzasvCH3s4UGHNOAUNhdJxGI27aMdnXNDZct7ffDHBL7bAZSGv9FSwCP5PeYvEIEKGbiA== + dependencies: + cluster-key-slot "1.1.0" + generic-pool "3.8.2" + redis-parser "3.0.0" + yallist "4.0.0" + +"@node-redis/json@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@node-redis/json/-/json-1.0.2.tgz#8ad2d0f026698dc1a4238cc3d1eb099a3bee5ab8" + integrity sha512-qVRgn8WfG46QQ08CghSbY4VhHFgaTY71WjpwRBGEuqGPfWwfRcIf3OqSpR7Q/45X+v3xd8mvYjywqh0wqJ8T+g== + +"@node-redis/search@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@node-redis/search/-/search-1.0.2.tgz#8cfc91006ea787df801d41410283e1f59027f818" + integrity sha512-gWhEeji+kTAvzZeguUNJdMSZNH2c5dv3Bci8Nn2f7VGuf6IvvwuZDSBOuOlirLVgayVuWzAG7EhwaZWK1VDnWQ== + +"@node-redis/time-series@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@node-redis/time-series/-/time-series-1.0.1.tgz#703149f8fa4f6fff377c61a0873911e7c1ba5cc3" + integrity sha512-+nTn6EewVj3GlUXPuD3dgheWqo219jTxlo6R+pg24OeVvFHx9aFGGiyOgj3vBPhWUdRZ0xMcujXV5ki4fbLyMw== + +"@nodelib/fs.scandir@2.1.3": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz#3a582bdb53804c6ba6d146579c46e52130cf4a3b" + integrity sha512-eGmwYQn3gxo4r7jdQnkrrN6bY478C3P+a/y72IJukF8LjB6ZHeB3c+Ehacj3sYeSmUXGlnA67/PmbM9CVwL7Dw== + dependencies: + "@nodelib/fs.stat" "2.0.3" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.3", "@nodelib/fs.stat@^2.0.2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.3.tgz#34dc5f4cabbc720f4e60f75a747e7ecd6c175bd3" + integrity sha512-bQBFruR2TAwoevBEd/NWMoAAtNGzTRgdrqnYCc7dhzfoNvqPzLyqlEQnzZ3kVnNrSp25iyxE00/3h2fqGAGArA== + +"@nodelib/fs.walk@^1.2.3": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.4.tgz#011b9202a70a6366e436ca5c065844528ab04976" + integrity sha512-1V9XOY4rDW0rehzbrcqAmHnz8e7SKvX27gh8Gt2WgB0+pdzdiLV83p72kZPU+jvMbS1qU5mauP2iOvO8rhmurQ== + dependencies: + "@nodelib/fs.scandir" "2.1.3" + fastq "^1.6.0" + +"@npmcli/fs@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-2.1.0.tgz#f2a21c28386e299d1a9fae8051d35ad180e33109" + integrity sha512-DmfBvNXGaetMxj9LTp8NAN9vEidXURrf5ZTslQzEAi/6GbW+4yjaLFQc6Tue5cpZ9Frlk4OBo/Snf1Bh/S7qTQ== + dependencies: + "@gar/promisify" "^1.1.3" + semver "^7.3.5" + +"@npmcli/move-file@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-2.0.0.tgz#417f585016081a0184cef3e38902cd917a9bbd02" + integrity sha512-UR6D5f4KEGWJV6BGPH3Qb2EtgH+t+1XQ1Tt85c7qicN6cezzuHPdZwwAxqZr4JLtnQu0LZsTza/5gmNmSl8XLg== + dependencies: + mkdirp "^1.0.4" + rimraf "^3.0.2" + +"@nsfw-filter/gif-frames@1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@nsfw-filter/gif-frames/-/gif-frames-1.0.2.tgz#a00731e56a944c7cfc8e65f26f6f2a6945432ea6" + integrity sha512-XZrbJWEN8YfVla5i+PD4Wj51rRlJ8OgnXiPjjOt/OsrbsCR9GZRD4jr953oNWcwiRaoIcOCFWQNMQukO7Yb1dA== + dependencies: + "@nsfw-filter/save-pixels" "^2.3.4" + get-pixels-frame-info-update "3.3.2" + multi-integer-range "3.0.0" + +"@nsfw-filter/save-pixels@^2.3.4": + version "2.3.4" + resolved "https://registry.yarnpkg.com/@nsfw-filter/save-pixels/-/save-pixels-2.3.4.tgz#671d8b741d47030d8b18390e56ad7e912447265d" + integrity sha512-dRZXwrXadMvxwJYKChrDBqC6GNvxVqlmdkyvZJO5DV65qyBsHZw8bPg9CnX7EgpxGl6+4ba/MAdHDLxs2XoD0Q== + dependencies: + gif-encoder "0.4.1" + ndarray "1.0.18" + ndarray-ops "1.2.2" + pngjs-nozlib "1.0.0" + through "2.3.4" + +"@peertube/http-signature@1.7.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@peertube/http-signature/-/http-signature-1.7.0.tgz#12a84f3fc62e786aa3a2eb09426417bad65736dc" + integrity sha512-aGQIwo6/sWtyyqhVK4e1MtxYz4N1X8CNt6SOtCc+Wnczs5S5ONaLHDDR8LYaGn0MgOwvGgXyuZ5sJIfd7iyoUw== + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.14.1" + +"@redocly/ajv@^8.6.5": + version "8.6.5" + resolved "https://registry.yarnpkg.com/@redocly/ajv/-/ajv-8.6.5.tgz#b6e737248b791905b3f600fb329779a807f0f774" + integrity sha512-3P2TY/u4c6OBqkP+1cTH1iGAEv0O34PV3vV2Wnos/nNHu62OTrtC4zcaxttG0pHtPtn42StrhGq7SsiFgP4Bfw== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + +"@redocly/openapi-core@1.0.0-beta.108": + version "1.0.0-beta.108" + resolved "https://registry.yarnpkg.com/@redocly/openapi-core/-/openapi-core-1.0.0-beta.108.tgz#fbf1b4e31c148f8816d2d63aa37b7831e305ec0f" + integrity sha512-4Lq7KB+XiBvVzpaY/M0a8qog/Zr8kGrvJbRW2z7Sk2Zpc/m+8LTuZbRh15eMoneVc13M9qbHFIRh3PG18g3Tng== + dependencies: + "@redocly/ajv" "^8.6.5" + "@types/node" "^14.11.8" + colorette "^1.2.0" + js-levenshtein "^1.1.6" + js-yaml "^4.1.0" + lodash.isequal "^4.5.0" + minimatch "^5.0.1" + node-fetch "^2.6.1" + pluralize "^8.0.0" + yaml-ast-parser "0.0.43" + +"@sindresorhus/is@^4.0.0": + version "4.6.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f" + integrity sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw== + +"@sindresorhus/is@^5.2.0": + version "5.3.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-5.3.0.tgz#0ec9264cf54a527671d990eb874e030b55b70dcc" + integrity sha512-CX6t4SYQ37lzxicAqsBtxA3OseeoVrh9cSJ5PFYam0GksYlupRfy1A+Q4aYD3zvcfECLc0zO2u+ZnR2UYKvCrw== + +"@sinonjs/commons@^1.7.0": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.7.2.tgz#505f55c74e0272b43f6c52d81946bed7058fc0e2" + integrity sha512-+DUO6pnp3udV/v2VfUWgaY5BIE1IfT7lLfeDzPVeMT1XKkaAp9LgSI9x5RtrFQoZ9Oi0PgXQQHPaoKu7dCjVxw== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@9.1.2": + version "9.1.2" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz#4eaab737fab77332ab132d396a3c0d364bd0ea8c" + integrity sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw== + dependencies: + "@sinonjs/commons" "^1.7.0" + +"@sqltools/formatter@^1.2.2": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@sqltools/formatter/-/formatter-1.2.3.tgz#1185726610acc37317ddab11c3c7f9066966bd20" + integrity sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg== + +"@syuilo/aiscript@0.11.1": + version "0.11.1" + resolved "https://registry.yarnpkg.com/@syuilo/aiscript/-/aiscript-0.11.1.tgz#52c14692113c58d1d62e6ae696352ba49abdf2eb" + integrity sha512-chwOIA3yLUKvOB0G611hjLArKTeOWNmTm3lHERSaDW1d+dS6do56naX6Lkwy2UpnwWC0qzeNSgg35elk6t2gZg== + dependencies: + autobind-decorator "2.4.0" + chalk "4.0.0" + seedrandom "3.0.5" + stringz "2.1.0" + uuid "7.0.3" + +"@szmarczak/http-timer@^4.0.5": + version "4.0.5" + resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-4.0.5.tgz#bfbd50211e9dfa51ba07da58a14cdfd333205152" + integrity sha512-PyRA9sm1Yayuj5OIoJ1hGt2YISX45w9WcFbh6ddT0Z/0yaFxOtGLInr4jUfU1EAFVs0Yfyfev4RNwBlUaHdlDQ== + dependencies: + defer-to-connect "^2.0.0" + +"@szmarczak/http-timer@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-5.0.1.tgz#c7c1bf1141cdd4751b0399c8fc7b8b664cd5be3a" + integrity sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw== + dependencies: + defer-to-connect "^2.0.1" + +"@tensorflow/tfjs-backend-cpu@3.20.0": + version "3.20.0" + resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-3.20.0.tgz#338ec5cfc7c713355839fd85ddf90b5b59b6099a" + integrity sha512-gf075YaBLwSAAiUwa0D4GvYyUBhbJ1BVSivUNQmUfGKvIr2lIhF0qstBr033YTc3lhkbFSHEEPAHh/EfpqyjXQ== + dependencies: + "@types/seedrandom" "^2.4.28" + seedrandom "^3.0.5" + +"@tensorflow/tfjs-backend-webgl@3.20.0": + version "3.20.0" + resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-backend-webgl/-/tfjs-backend-webgl-3.20.0.tgz#1ea6dff51a62cda64bd80ff9e7861a6010924f5a" + integrity sha512-SucbyQ08re3HvRgVfarRtKFIjNM4JvIAzcXmw4vaE/HrCtPEePkGO1VrmfQoN470EdUmGiwgqAjoyBvM2VOlVg== + dependencies: + "@tensorflow/tfjs-backend-cpu" "3.20.0" + "@types/offscreencanvas" "~2019.3.0" + "@types/seedrandom" "^2.4.28" + "@types/webgl-ext" "0.0.30" + "@types/webgl2" "0.0.6" + seedrandom "^3.0.5" + +"@tensorflow/tfjs-converter@3.20.0": + version "3.20.0" + resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-converter/-/tfjs-converter-3.20.0.tgz#5d3d2b388775997585204bc867ecdf1e52789a38" + integrity sha512-8EIYqtQwvSYw9GFNW2OFU8Qnl/FQF/kKAsQJoORYaZ419WJo+FIZWbAWDtCpJSAgkgoHH1jYWgV9H313cVmqxg== + +"@tensorflow/tfjs-core@3.20.0": + version "3.20.0" + resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-core/-/tfjs-core-3.20.0.tgz#b6f89ae6490099e2c0c992faa59c96f563f9eba2" + integrity sha512-L16JyVA4a8jFJXFgB9/oYZxcGq/GfLypt5dMVTyedznARZZ9SiY/UMMbo3IKl9ZylG1dOVVTpjzV3EvBYfeJXw== + dependencies: + "@types/long" "^4.0.1" + "@types/offscreencanvas" "~2019.3.0" + "@types/seedrandom" "^2.4.28" + "@types/webgl-ext" "0.0.30" + "@webgpu/types" "0.1.16" + long "4.0.0" + node-fetch "~2.6.1" + seedrandom "^3.0.5" + +"@tensorflow/tfjs-data@3.20.0": + version "3.20.0" + resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-data/-/tfjs-data-3.20.0.tgz#8e267ea9f01066e0276be3226cb21b7d38711144" + integrity sha512-DiD3M/K/RYyTpOsrTL0ZUsdLgoczbSEx1+cQgCtlO3wUFoMxGYMxRTFeqp4hnJalY9MDptEqZ2gXAO1BMd2IXA== + dependencies: + "@types/node-fetch" "^2.1.2" + node-fetch "~2.6.1" + string_decoder "^1.3.0" + +"@tensorflow/tfjs-layers@3.20.0": + version "3.20.0" + resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-layers/-/tfjs-layers-3.20.0.tgz#53aac3e719c9aa06cdff894e564704555e73a069" + integrity sha512-CbeDFX7XgWVsjsqp9HisbhO+a+soLt9tMNNBD/F+Rmw+YDJ5+X64iRpsMj3OWK8yKp895waoeads56UhG+Pxlw== + +"@tensorflow/tfjs-node@3.20.0": + version "3.20.0" + resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-node/-/tfjs-node-3.20.0.tgz#df401f99a6e7690d64f35136910d541cd337ba0f" + integrity sha512-lhMaqydtFNQ89kiET2nNMsV/rhOVa/Xh+hUHpxJP5e6KpVBzACmcJD8MjxM122G2EBntbf/vOQUAfnbX9AI9PA== + dependencies: + "@mapbox/node-pre-gyp" "1.0.9" + "@tensorflow/tfjs" "3.20.0" + adm-zip "^0.5.2" + google-protobuf "^3.9.2" + https-proxy-agent "^2.2.1" + progress "^2.0.0" + rimraf "^2.6.2" + tar "^4.4.6" + +"@tensorflow/tfjs@3.20.0": + version "3.20.0" + resolved "https://registry.yarnpkg.com/@tensorflow/tfjs/-/tfjs-3.20.0.tgz#4a525be52c72deb9964fe2e5a0abaae04b95d862" + integrity sha512-Vx6MBFgZs+o413a/tM9nLdo4LM5U8Rh3d1fB8ioPL4j8dsqoqfCeee/215J3zzWPHIiSqv3pcD7bkK3fDA27GQ== + dependencies: + "@tensorflow/tfjs-backend-cpu" "3.20.0" + "@tensorflow/tfjs-backend-webgl" "3.20.0" + "@tensorflow/tfjs-converter" "3.20.0" + "@tensorflow/tfjs-core" "3.20.0" + "@tensorflow/tfjs-data" "3.20.0" + "@tensorflow/tfjs-layers" "3.20.0" + argparse "^1.0.10" + chalk "^4.1.0" + core-js "3" + regenerator-runtime "^0.13.5" + yargs "^16.0.3" + +"@tokenizer/token@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" + integrity sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A== + +"@tootallnate/once@2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" + integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== + +"@tsconfig/node10@^1.0.7": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.7.tgz#1eb1de36c73478a2479cc661ef5af1c16d86d606" + integrity sha512-aBvUmXLQbayM4w3A8TrjwrXs4DZ8iduJnuJLLRGdkWlyakCf1q6uHZJBzXoRA/huAEknG5tcUyQxN3A+In5euQ== + +"@tsconfig/node12@^1.0.7": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.7.tgz#677bd9117e8164dc319987dd6ff5fc1ba6fbf18b" + integrity sha512-dgasobK/Y0wVMswcipr3k0HpevxFJLijN03A8mYfEPvWvOs14v0ZlYTR4kIgMx8g4+fTyTFv8/jLCIfRqLDJ4A== + +"@tsconfig/node14@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.0.tgz#5bd046e508b1ee90bc091766758838741fdefd6e" + integrity sha512-RKkL8eTdPv6t5EHgFKIVQgsDapugbuOptNd9OOunN/HAkzmmTnZELx1kNCK0rSdUYGmiFMM3rRQMAWiyp023LQ== + +"@tsconfig/node16@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e" + integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA== + +"@types/accepts@*": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575" + integrity sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ== + dependencies: + "@types/node" "*" + +"@types/bcryptjs@2.4.2": + version "2.4.2" + resolved "https://registry.yarnpkg.com/@types/bcryptjs/-/bcryptjs-2.4.2.tgz#e3530eac9dd136bfdfb0e43df2c4c5ce1f77dfae" + integrity sha512-LiMQ6EOPob/4yUL66SZzu6Yh77cbzJFYll+ZfaPiPPFswtIlA/Fs1MzdKYA7JApHU49zQTbJGX3PDmCpIdDBRQ== + +"@types/body-parser@*": + version "1.19.0" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f" + integrity sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ== + dependencies: + "@types/connect" "*" + "@types/node" "*" + +"@types/bull@3.15.9": + version "3.15.9" + resolved "https://registry.yarnpkg.com/@types/bull/-/bull-3.15.9.tgz#e10e0901ec3762bff85716b3c580277960751c93" + integrity sha512-MPUcyPPQauAmynoO3ezHAmCOhbB0pWmYyijr/5ctaCqhbKWsjW0YCod38ZcLzUBprosfZ9dPqfYIcfdKjk7RNQ== + dependencies: + "@types/ioredis" "*" + "@types/redis" "^2.8.0" + +"@types/cacheable-request@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.1.tgz#5d22f3dded1fd3a84c0bbeb5039a7419c2c91976" + integrity sha512-ykFq2zmBGOCbpIXtoVbz4SKY5QriWPh3AjyU4G74RYbtt5yOc5OfaY75ftjg7mikMOla1CTGpX3lLbuJh8DTrQ== + dependencies: + "@types/http-cache-semantics" "*" + "@types/keyv" "*" + "@types/node" "*" + "@types/responselike" "*" + +"@types/cacheable-request@^6.0.2": + version "6.0.2" + resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.2.tgz#c324da0197de0a98a2312156536ae262429ff6b9" + integrity sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA== + dependencies: + "@types/http-cache-semantics" "*" + "@types/keyv" "*" + "@types/node" "*" + "@types/responselike" "*" + +"@types/cbor@6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@types/cbor/-/cbor-6.0.0.tgz#ddead015e14ef4463287d40cd92a6297a34dac8d" + integrity sha512-mGQ1lbYOwVti5Xlarn1bTeBZqgY0kstsdjnkoEovgohYKdBjGejHyNGXHdMBeqyQazIv32Jjp33+5pBEaSRy2w== + dependencies: + cbor "*" + +"@types/color-name@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" + integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== + +"@types/connect@*": + version "3.4.33" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.33.tgz#31610c901eca573b8713c3330abc6e6b9f588546" + integrity sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A== + dependencies: + "@types/node" "*" + +"@types/content-disposition@*": + version "0.5.3" + resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.3.tgz#0aa116701955c2faa0717fc69cd1596095e49d96" + integrity sha512-P1bffQfhD3O4LW0ioENXUhZ9OIa0Zn+P7M+pWgkCKaT53wVLSq0mrKksCID/FGHpFhRSxRGhgrQmfhRuzwtKdg== + +"@types/cookies@*": + version "0.7.4" + resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.4.tgz#26dedf791701abc0e36b5b79a5722f40e455f87b" + integrity sha512-oTGtMzZZAVuEjTwCjIh8T8FrC8n/uwy+PG0yTvQcdZ7etoel7C7/3MSd7qrukENTgQtotG7gvBlBojuVs7X5rw== + dependencies: + "@types/connect" "*" + "@types/express" "*" + "@types/keygrip" "*" + "@types/node" "*" + +"@types/disposable-email-domains@^1.0.1": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@types/disposable-email-domains/-/disposable-email-domains-1.0.2.tgz#0280f6b38fa7f14e54b056a434135ecd254483b1" + integrity sha512-SDKwyYTjk3y5aZBxxc38yRecpJPjsqn57STz1bNxYYlv4k11bBe7QB8w4llXDTmQXKT1mFvgGmJv+8Zdu3YmJw== + +"@types/escape-regexp@0.0.1": + version "0.0.1" + resolved "https://registry.yarnpkg.com/@types/escape-regexp/-/escape-regexp-0.0.1.tgz#f1a977ccdf2ef059e9862bd3af5e92cbbe723e0e" + integrity sha512-ogj/ZTIdeFkiuxDwawYuZSIgC6suFGgBeZPr6Xs5lHEcvIXTjXGtH+/n8f1XhZhespaUwJ5LIGRICPji972FLw== + +"@types/express-serve-static-core@*": + version "4.17.5" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.5.tgz#a00ac7dadd746ae82477443e4d480a6a93ea083c" + integrity sha512-578YH5Lt88AKoADy0b2jQGwJtrBxezXtVe/MBqWXKZpqx91SnC0pVkVCcxcytz3lWW+cHBYDi3Ysh0WXc+rAYw== + dependencies: + "@types/node" "*" + "@types/range-parser" "*" + +"@types/express@*": + version "4.17.6" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.6.tgz#6bce49e49570507b86ea1b07b806f04697fac45e" + integrity sha512-n/mr9tZI83kd4azlPG5y997C/M4DNABK9yErhFM6hKdym4kkmd9j0vtsJyjFIwfRBxtrxZtAfGZCNRIBMFLK5w== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "*" + "@types/qs" "*" + "@types/serve-static" "*" + +"@types/fluent-ffmpeg@2.1.20": + version "2.1.20" + resolved "https://registry.yarnpkg.com/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.20.tgz#3b5f42fc8263761d58284fa46ee6759a64ce54ac" + integrity sha512-B+OvhCdJ3LgEq2PhvWNOiB/EfwnXLElfMCgc4Z1K5zXgSfo9I6uGKwR/lqmNPFQuebNnes7re3gqkV77SyypLg== + dependencies: + "@types/node" "*" + +"@types/http-assert@*": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.1.tgz#d775e93630c2469c2f980fc27e3143240335db3b" + integrity sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ== + +"@types/http-cache-semantics@*": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz#9140779736aa2655635ee756e2467d787cfe8a2a" + integrity sha512-c3Xy026kOF7QOTn00hbIllV1dLR9hG9NkSrLQgCVs8NF6sBU+VGWjD3wLPhmh1TYAc7ugCFsvHYMN4VcBN1U1A== + +"@types/http-errors@*": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.0.tgz#682477dbbbd07cd032731cb3b0e7eaee3d026b69" + integrity sha512-2aoSC4UUbHDj2uCsCxcG/vRMXey/m17bC7UwitVm5hn22nI8O8Y9iDpA76Orc+DWkQ4zZrOKEshCqR/jSuXAHA== + +"@types/ioredis@*": + version "4.14.9" + resolved "https://registry.yarnpkg.com/@types/ioredis/-/ioredis-4.14.9.tgz#774387d44d3ad60e1b849044b2b28b96e5813866" + integrity sha512-yNdzppM6vY4DYqXCnt4A3PXArxsMWeJCYxFlyl4AJKrNSGMEAP9TPcXR+8Q6zh9glcCtxmwMQhi4pwdqqHH3OA== + dependencies: + "@types/node" "*" + +"@types/js-yaml@4.0.5": + version "4.0.5" + resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.5.tgz#738dd390a6ecc5442f35e7f03fa1431353f7e138" + integrity sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA== + +"@types/jsdom@20.0.0": + version "20.0.0" + resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-20.0.0.tgz#4414fb629465167f8b7b3804b9e067bdd99f1791" + integrity sha512-YfAchFs0yM1QPDrLm2VHe+WHGtqms3NXnXAMolrgrVP6fgBHHXy1ozAbo/dFtPNtZC/m66bPiCTWYmqp1F14gA== + dependencies: + "@types/node" "*" + "@types/tough-cookie" "*" + parse5 "^7.0.0" + +"@types/json-schema@^7.0.6": + version "7.0.6" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.6.tgz#f4c7ec43e81b319a9815115031709f26987891f0" + integrity sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw== + +"@types/json-schema@^7.0.9": + version "7.0.9" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" + integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ== + +"@types/json5@^0.0.29": + version "0.0.29" + resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" + integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= + +"@types/jsonld@1.5.6": + version "1.5.6" + resolved "https://registry.yarnpkg.com/@types/jsonld/-/jsonld-1.5.6.tgz#4396c0b17128abf5773bb68b5453b88fc565b0d4" + integrity sha512-OUcfMjRie5IOrJulUQwVNvV57SOdKcTfBj3pjXNxzXqeOIrY2aGDNGW/Tlp83EQPkz4tCE6YWVrGuc/ZeaAQGg== + +"@types/jsrsasign@10.5.2": + version "10.5.2" + resolved "https://registry.yarnpkg.com/@types/jsrsasign/-/jsrsasign-10.5.2.tgz#c8d5a7bccffd2fdee73553a130876a88e91419ec" + integrity sha512-oroCALq37fnUKPRYatawNq3oBNITN7lROpy6JBUanYLhuMZwG5shVxCyZ1/wM3RQCNJ/Ac5/+g7yZaZ+tVBy3A== + +"@types/keygrip@*": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72" + integrity sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw== + +"@types/keyv@*": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.1.tgz#e45a45324fca9dab716ab1230ee249c9fb52cfa7" + integrity sha512-MPtoySlAZQ37VoLaPcTHCu1RWJ4llDkULYZIzOYxlhxBqYPB0RsRlmMU0R6tahtFe27mIdkHV+551ZWV4PLmVw== + dependencies: + "@types/node" "*" + +"@types/koa-bodyparser@4.3.7": + version "4.3.7" + resolved "https://registry.yarnpkg.com/@types/koa-bodyparser/-/koa-bodyparser-4.3.7.tgz#3ac41f2dec9d97db7a6f798bbb2e2368be762714" + integrity sha512-21NhEp7LjZm4zbNV5alHHmrNY4J+S7B8lYTO6CzRL8ShTMnl20Gd14dRgVhAxraLaW5iZMofox+BycbuiDvj2Q== + dependencies: + "@types/koa" "*" + +"@types/koa-compose@*": + version "3.2.5" + resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.5.tgz#85eb2e80ac50be95f37ccf8c407c09bbe3468e9d" + integrity sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ== + dependencies: + "@types/koa" "*" + +"@types/koa-cors@0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@types/koa-cors/-/koa-cors-0.0.2.tgz#369c753fb383640f225579c70a4f9a286b4931b7" + integrity sha512-uNaDY26HUVO+2C6arK8ZFODs9mBjYprD8mlvkVe2bYdX9wzEeKtycVXPafXpUkePhMh4sffIMkhRDyedokG/QA== + dependencies: + "@types/koa" "*" + +"@types/koa-favicon@2.0.21": + version "2.0.21" + resolved "https://registry.yarnpkg.com/@types/koa-favicon/-/koa-favicon-2.0.21.tgz#d8a0ed062a6f5e3f838fe09c21e8b3f0490369cd" + integrity sha512-paH1nheVhijx/VduoR/RCD/qTCiX+OI/6fHLi3mZae053Ts+gUBOrKtzl3pMTDbdEBqdLolfLje3PZbb6jW0jQ== + dependencies: + "@types/koa" "*" + +"@types/koa-logger@3.1.2": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@types/koa-logger/-/koa-logger-3.1.2.tgz#91e890f405ddb0626bc385767e4cc0cd7226d1a8" + integrity sha512-sioTA1xlKYiIgryANWPRHBkG3XGbWftw9slWADUPC+qvPIY/yRLSrhvX7zkJwMrntub5dPO0GuAoyGGf0yitfQ== + dependencies: + "@types/koa" "*" + +"@types/koa-mount@4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/koa-mount/-/koa-mount-4.0.1.tgz#2994be86eaa3d9dc97365e6ebfa227cee3c5f157" + integrity sha512-HNeg80CVS9Dfq8dGYqCZZCAUm7g6jPCNJ1ydqVLEJxLrjmeburpvq+lOZkE4rxBZ6O38dr3tj9IA3IfbdoI05w== + dependencies: + "@types/koa" "*" + +"@types/koa-send@4.1.3": + version "4.1.3" + resolved "https://registry.yarnpkg.com/@types/koa-send/-/koa-send-4.1.3.tgz#17193c6472ae9e5d1b99ae8086949cc4fd69179d" + integrity sha512-daaTqPZlgjIJycSTNjKpHYuKhXYP30atFc1pBcy6HHqB9+vcymDgYTguPdx9tO4HMOqNyz6bz/zqpxt5eLR+VA== + dependencies: + "@types/koa" "*" + +"@types/koa-views@7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@types/koa-views/-/koa-views-7.0.0.tgz#5613450c77ab69c980c47104378da4b7669c5f2e" + integrity sha512-AB/NB+oFHcLOZJYFv3bG5Af8YbwYCD9/zK0WcKALsbjI/FRKrcXTUTC64RebDrkyOkBm3bpCgpGndhAH/3YQ2Q== + dependencies: + koa-views "*" + +"@types/koa@*", "@types/koa@^2.13.1": + version "2.13.1" + resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.1.tgz#e29877a6b5ad3744ab1024f6ec75b8cbf6ec45db" + integrity sha512-Qbno7FWom9nNqu0yHZ6A0+RWt4mrYBhw3wpBAQ3+IuzGcLlfeYkzZrnMq5wsxulN2np8M4KKeUpTodsOsSad5Q== + dependencies: + "@types/accepts" "*" + "@types/content-disposition" "*" + "@types/cookies" "*" + "@types/http-assert" "*" + "@types/http-errors" "*" + "@types/keygrip" "*" + "@types/koa-compose" "*" + "@types/node" "*" + +"@types/koa@2.13.5": + version "2.13.5" + resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.5.tgz#64b3ca4d54e08c0062e89ec666c9f45443b21a61" + integrity sha512-HSUOdzKz3by4fnqagwthW/1w/yJspTgppyyalPVbgZf8jQWvdIXcVW5h2DGtw4zYntOaeRGx49r1hxoPWrD4aA== + dependencies: + "@types/accepts" "*" + "@types/content-disposition" "*" + "@types/cookies" "*" + "@types/http-assert" "*" + "@types/http-errors" "*" + "@types/keygrip" "*" + "@types/koa-compose" "*" + "@types/node" "*" + +"@types/koa__cors@3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@types/koa__cors/-/koa__cors-3.1.1.tgz#198b5abbc425a672ae57c311b420bc270e65bdef" + integrity sha512-O7MBkCocnLrpEvkMrYAp17arUDS+KuS5bXMG/Z4aPSbrO7vrYB6YrqcsTD3Dp2OnAL3j4WME2k/x2kOcyzwNUw== + dependencies: + "@types/koa" "*" + +"@types/koa__multer@2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@types/koa__multer/-/koa__multer-2.0.4.tgz#e0f0fd1800a46b51886bebab480a57100f2488b0" + integrity sha512-WRkshXhE5rpYFUbbtAjyMhdOOSdbu1XX+2AQlRNM6AZtgxd0/WXMU4lrP7e9tk5HWVTWbx8DOOsVBmfHjSGJ4w== + dependencies: + "@types/koa" "*" + +"@types/koa__router@8.0.11": + version "8.0.11" + resolved "https://registry.yarnpkg.com/@types/koa__router/-/koa__router-8.0.11.tgz#d7b37e6db934fc072ea1baa2ab92bc8ac4564f3e" + integrity sha512-WXgKWpBsbS14kzmzD9LeFapOIa678h7zvUHxDwXwSx4ETKXhXLVUAToX6jZ/U7EihM7qwyD9W/BZvB0MRu7MTQ== + dependencies: + "@types/koa" "*" + +"@types/long@^4.0.1": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a" + integrity sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA== + +"@types/mime@*": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d" + integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw== + +"@types/mocha@9.1.1": + version "9.1.1" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-9.1.1.tgz#e7c4f1001eefa4b8afbd1eee27a237fee3bf29c4" + integrity sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw== + +"@types/node-fetch@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-3.0.3.tgz#9d969c9a748e841554a40ee435d26e53fa3ee899" + integrity sha512-HhggYPH5N+AQe/OmN6fmhKmRRt2XuNJow+R3pQwJxOOF9GuwM7O2mheyGeIrs5MOIeNjDEdgdoyHBOrFeJBR3g== + dependencies: + node-fetch "*" + +"@types/node-fetch@^2.1.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.2.tgz#d1a9c5fd049d9415dce61571557104dec3ec81da" + integrity sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A== + dependencies: + "@types/node" "*" + form-data "^3.0.0" + +"@types/node@*": + version "16.6.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.6.2.tgz#331b7b9f8621c638284787c5559423822fdffc50" + integrity sha512-LSw8TZt12ZudbpHc6EkIyDM3nHVWKYrAvGy6EAJfNfjusbwnThqjqxUKKRwuV3iWYeW/LYMzNgaq3MaLffQ2xA== + +"@types/node@18.7.16": + version "18.7.16" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.16.tgz#0eb3cce1e37c79619943d2fd903919fc30850601" + integrity sha512-EQHhixfu+mkqHMZl1R2Ovuvn47PUw18azMJOTwSZr9/fhzHNGXAJ0ma0dayRVchprpCj0Kc1K1xKoWaATWF1qg== + +"@types/node@^14.11.8": + version "14.17.9" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.9.tgz#b97c057e6138adb7b720df2bd0264b03c9f504fd" + integrity sha512-CMjgRNsks27IDwI785YMY0KLt3co/c0cQ5foxHYv/shC2w8oOnVwz5Ubq1QG5KzrcW+AXk6gzdnxIkDnTvzu3g== + +"@types/nodemailer@6.4.5": + version "6.4.5" + resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.5.tgz#09011ac73259245475d1688e4ba101860567dc39" + integrity sha512-zuP3nBRQHI6M2PkXnGGy1Ww4VB+MyYHGgnfV2T+JR9KLkeWqPJuyVUgLpKXuFnA/b7pZaIDFh2sV4759B7jK1g== + dependencies: + "@types/node" "*" + +"@types/oauth@0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@types/oauth/-/oauth-0.9.1.tgz#e17221e7f7936b0459ae7d006255dff61adca305" + integrity sha512-a1iY62/a3yhZ7qH7cNUsxoI3U/0Fe9+RnuFrpTKr+0WVOzbKlSLojShCKe20aOD1Sppv+i8Zlq0pLDuTJnwS4A== + dependencies: + "@types/node" "*" + +"@types/offscreencanvas@~2019.3.0": + version "2019.3.0" + resolved "https://registry.yarnpkg.com/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz#3336428ec7e9180cf4566dfea5da04eb586a6553" + integrity sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q== + +"@types/pug@2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@types/pug/-/pug-2.0.6.tgz#f830323c88172e66826d0bde413498b61054b5a6" + integrity sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg== + +"@types/punycode@2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/punycode/-/punycode-2.1.0.tgz#89e4f3d09b3f92e87a80505af19be7e0c31d4e83" + integrity sha512-PG5aLpW6PJOeV2fHRslP4IOMWn+G+Uq8CfnyJ+PDS8ndCbU+soO+fB3NKCKo0p/Jh2Y4aPaiQZsrOXFdzpcA6g== + +"@types/qrcode@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.0.tgz#6a98fe9a9a7b2a9a3167b6dde17eff999eabe40b" + integrity sha512-x5ilHXRxUPIMfjtM+1vf/GPTRWZ81nqscursm5gMznJeK9M0YnZ1c3bEvRLQ0zSSgedLx1J6MGL231ObQGGhaA== + dependencies: + "@types/node" "*" + +"@types/qs@*": + version "6.9.1" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.1.tgz#937fab3194766256ee09fcd40b781740758617e7" + integrity sha512-lhbQXx9HKZAPgBkISrBcmAcMpZsmpe/Cd/hY7LGZS5OfkySUBItnPZHgQPssWYUET8elF+yCFBbP1Q0RZPTdaw== + +"@types/random-seed@0.3.3": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@types/random-seed/-/random-seed-0.3.3.tgz#7741f7b0a4513198a9396ce4ad25832f799a6727" + integrity sha512-kHsCbIRHNXJo6EN5W8EA5b4i1hdT6jaZke5crBPLUcLqaLdZ0QBq8QVMbafHzhjFF83Cl9qlee2dChD18d/kPg== + +"@types/range-parser@*": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" + integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA== + +"@types/ratelimiter@3.4.3": + version "3.4.3" + resolved "https://registry.yarnpkg.com/@types/ratelimiter/-/ratelimiter-3.4.3.tgz#2159c234b9d75bcc2be39379f05c6af0a5e4a3b7" + integrity sha512-B/IRdHGcttRsDeDJ4+VFjzRA1mzqTxsYlg2X8GLQtTgRUMhQQc+bL8zFmuHhZkK4oA+Ldb4K1NogspNDxevWBA== + dependencies: + "@types/redis" "^2.8.0" + +"@types/redis@4.0.11": + version "4.0.11" + resolved "https://registry.yarnpkg.com/@types/redis/-/redis-4.0.11.tgz#0bb4c11ac9900a21ad40d2a6768ec6aaf651c0e1" + integrity sha512-bI+gth8La8Wg/QCR1+V1fhrL9+LZUSWfcqpOj2Kc80ZQ4ffbdL173vQd5wovmoV9i071FU9oP2g6etLuEwb6Rg== + dependencies: + redis "*" + +"@types/redis@^2.8.0": + version "2.8.32" + resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.32.tgz#1d3430219afbee10f8cfa389dad2571a05ecfb11" + integrity sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w== + dependencies: + "@types/node" "*" + +"@types/rename@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@types/rename/-/rename-1.0.4.tgz#30c6f0306042591a560361ea02639e89647dd173" + integrity sha512-eV81+6bVv2mdCBahkMefjEUwAjKDAP3AuyhqWCWRxcRaeVdUeHUBaoq2zSz+5HNHF2jzTajMcfLvJsy4K3cbwA== + +"@types/responselike@*", "@types/responselike@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.0.tgz#251f4fe7d154d2bad125abe1b429b23afd262e29" + integrity sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA== + dependencies: + "@types/node" "*" + +"@types/sanitize-html@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.6.2.tgz#9c47960841b9def1e4c9dfebaaab010a3f6e97b9" + integrity sha512-7Lu2zMQnmHHQGKXVvCOhSziQMpa+R2hMHFefzbYoYMHeaXR0uXqNeOc3JeQQQ8/6Xa2Br/P1IQTLzV09xxAiUQ== + dependencies: + htmlparser2 "^6.0.0" + +"@types/seedrandom@^2.4.28": + version "2.4.30" + resolved "https://registry.yarnpkg.com/@types/seedrandom/-/seedrandom-2.4.30.tgz#d2efe425869b84163c2d56e779dddadb9372cbfa" + integrity sha512-AnxLHewubLVzoF/A4qdxBGHCKifw8cY32iro3DQX9TPcetE95zBeVt3jnsvtvAUf1vwzMfwzp4t/L2yqPlnjkQ== + +"@types/semver@7.3.12": + version "7.3.12" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.12.tgz#920447fdd78d76b19de0438b7f60df3c4a80bf1c" + integrity sha512-WwA1MW0++RfXmCr12xeYOOC5baSC9mSb0ZqCquFzKhcoF4TvHu5MKOuXsncgZcpVFhB1pXd5hZmM0ryAoCp12A== + +"@types/serve-static@*": + version "1.13.3" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.3.tgz#eb7e1c41c4468272557e897e9171ded5e2ded9d1" + integrity sha512-oprSwp094zOglVrXdlo/4bAHtKTAxX6VT8FOZlBKrmyLbNvE1zxZyJ6yikMVtHIvwP45+ZQGJn+FdXGKTozq0g== + dependencies: + "@types/express-serve-static-core" "*" + "@types/mime" "*" + +"@types/sharp@0.30.5": + version "0.30.5" + resolved "https://registry.yarnpkg.com/@types/sharp/-/sharp-0.30.5.tgz#d75d91f7acf5260525aeae229845046dcff6d17a" + integrity sha512-EhO29617AIBqxoVtpd1qdBanWpspk/kD2B6qTFRJ31Q23Rdf+DNU1xlHSwtqvwq1vgOqBwq1i38SX+HGCymIQg== + dependencies: + "@types/node" "*" + +"@types/sinonjs__fake-timers@8.1.2": + version "8.1.2" + resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz#bf2e02a3dbd4aecaf95942ecd99b7402e03fad5e" + integrity sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA== + +"@types/speakeasy@2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/speakeasy/-/speakeasy-2.0.7.tgz#cb087c501b3eef744a1ae620c19812dd1c3b2f3f" + integrity sha512-JEcOhN2SQCoX86ZfiZEe8px84sVJtivBXMZfOVyARTYEj0hrwwbj1nF0FwEL3nJSoEV6uTbcdLllMKBgAYHWCQ== + dependencies: + "@types/node" "*" + +"@types/tinycolor2@1.4.3": + version "1.4.3" + resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.3.tgz#ed4a0901f954b126e6a914b4839c77462d56e706" + integrity sha512-Kf1w9NE5HEgGxCRyIcRXR/ZYtDv0V8FVPtYHwLxl0O+maGX0erE77pQlD0gpP+/KByMZ87mOA79SjifhSB3PjQ== + +"@types/tmp@0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@types/tmp/-/tmp-0.2.3.tgz#908bfb113419fd6a42273674c00994d40902c165" + integrity sha512-dDZH/tXzwjutnuk4UacGgFRwV+JSLaXL1ikvidfJprkb7L9Nx1njcRHHmi3Dsvt7pgqqTEeucQuOrWHPFgzVHA== + +"@types/tough-cookie@*": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.0.tgz#fef1904e4668b6e5ecee60c52cc6a078ffa6697d" + integrity sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A== + +"@types/uuid@8.3.4": + version "8.3.4" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" + integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== + +"@types/web-push@3.3.2": + version "3.3.2" + resolved "https://registry.yarnpkg.com/@types/web-push/-/web-push-3.3.2.tgz#8c32434147c0396415862e86405c9edc9c50fc15" + integrity sha512-JxWGVL/m7mWTIg4mRYO+A6s0jPmBkr4iJr39DqJpRJAc+jrPiEe1/asmkwerzRon8ZZDxaZJpsxpv0Z18Wo9gw== + dependencies: + "@types/node" "*" + +"@types/webgl-ext@0.0.30": + version "0.0.30" + resolved "https://registry.yarnpkg.com/@types/webgl-ext/-/webgl-ext-0.0.30.tgz#0ce498c16a41a23d15289e0b844d945b25f0fb9d" + integrity sha512-LKVgNmBxN0BbljJrVUwkxwRYqzsAEPcZOe6S2T6ZaBDIrFp0qu4FNlpc5sM1tGbXUYFgdVQIoeLk1Y1UoblyEg== + +"@types/webgl2@0.0.6": + version "0.0.6" + resolved "https://registry.yarnpkg.com/@types/webgl2/-/webgl2-0.0.6.tgz#1ea2db791362bd8521548d664dbd3c5311cdf4b6" + integrity sha512-50GQhDVTq/herLMiqSQkdtRu+d5q/cWHn4VvKJtrj4DJAjo1MNkWYa2MA41BaBO1q1HgsUjuQvEOk0QHvlnAaQ== + +"@types/websocket@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/websocket/-/websocket-1.0.5.tgz#3fb80ed8e07f88e51961211cd3682a3a4a81569c" + integrity sha512-NbsqiNX9CnEfC1Z0Vf4mE1SgAJ07JnRYcNex7AJ9zAVzmiGHmjKFEk7O4TJIsgv2B1sLEb6owKFZrACwdYngsQ== + dependencies: + "@types/node" "*" + +"@types/ws@8.5.3": + version "8.5.3" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d" + integrity sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w== + dependencies: + "@types/node" "*" + +"@typescript-eslint/eslint-plugin@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.36.2.tgz#6df092a20e0f9ec748b27f293a12cb39d0c1fe4d" + integrity sha512-OwwR8LRwSnI98tdc2z7mJYgY60gf7I9ZfGjN5EjCwwns9bdTuQfAXcsjSB2wSQ/TVNYSGKf4kzVXbNGaZvwiXw== + dependencies: + "@typescript-eslint/scope-manager" "5.36.2" + "@typescript-eslint/type-utils" "5.36.2" + "@typescript-eslint/utils" "5.36.2" + debug "^4.3.4" + functional-red-black-tree "^1.0.1" + ignore "^5.2.0" + regexpp "^3.2.0" + semver "^7.3.7" + tsutils "^3.21.0" + +"@typescript-eslint/parser@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.36.2.tgz#3ddf323d3ac85a25295a55fcb9c7a49ab4680ddd" + integrity sha512-qS/Kb0yzy8sR0idFspI9Z6+t7mqk/oRjnAYfewG+VN73opAUvmYL3oPIMmgOX6CnQS6gmVIXGshlb5RY/R22pA== + dependencies: + "@typescript-eslint/scope-manager" "5.36.2" + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/typescript-estree" "5.36.2" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.36.2.tgz#a75eb588a3879ae659514780831370642505d1cd" + integrity sha512-cNNP51L8SkIFSfce8B1NSUBTJTu2Ts4nWeWbFrdaqjmn9yKrAaJUBHkyTZc0cL06OFHpb+JZq5AUHROS398Orw== + dependencies: + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/visitor-keys" "5.36.2" + +"@typescript-eslint/type-utils@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.36.2.tgz#752373f4babf05e993adf2cd543a763632826391" + integrity sha512-rPQtS5rfijUWLouhy6UmyNquKDPhQjKsaKH0WnY6hl/07lasj8gPaH2UD8xWkePn6SC+jW2i9c2DZVDnL+Dokw== + dependencies: + "@typescript-eslint/typescript-estree" "5.36.2" + "@typescript-eslint/utils" "5.36.2" + debug "^4.3.4" + tsutils "^3.21.0" + +"@typescript-eslint/types@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.36.2.tgz#a5066e500ebcfcee36694186ccc57b955c05faf9" + integrity sha512-9OJSvvwuF1L5eS2EQgFUbECb99F0mwq501w0H0EkYULkhFa19Qq7WFbycdw1PexAc929asupbZcgjVIe6OK/XQ== + +"@typescript-eslint/typescript-estree@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.36.2.tgz#0c93418b36c53ba0bc34c61fe9405c4d1d8fe560" + integrity sha512-8fyH+RfbKc0mTspfuEjlfqA4YywcwQK2Amcf6TDOwaRLg7Vwdu4bZzyvBZp4bjt1RRjQ5MDnOZahxMrt2l5v9w== + dependencies: + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/visitor-keys" "5.36.2" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + semver "^7.3.7" + tsutils "^3.21.0" + +"@typescript-eslint/utils@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.36.2.tgz#b01a76f0ab244404c7aefc340c5015d5ce6da74c" + integrity sha512-uNcopWonEITX96v9pefk9DC1bWMdkweeSsewJ6GeC7L6j2t0SJywisgkr9wUTtXk90fi2Eljj90HSHm3OGdGRg== + dependencies: + "@types/json-schema" "^7.0.9" + "@typescript-eslint/scope-manager" "5.36.2" + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/typescript-estree" "5.36.2" + eslint-scope "^5.1.1" + eslint-utils "^3.0.0" + +"@typescript-eslint/visitor-keys@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.36.2.tgz#2f8f78da0a3bad3320d2ac24965791ac39dace5a" + integrity sha512-BtRvSR6dEdrNt7Net2/XDjbYKU5Ml6GqJgVfXT0CxTCJlnIqK7rAGreuWKMT2t8cFUT2Msv5oxw0GMRD7T5J7A== + dependencies: + "@typescript-eslint/types" "5.36.2" + eslint-visitor-keys "^3.3.0" + +"@ungap/promise-all-settled@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" + integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q== + +"@webgpu/types@0.1.16": + version "0.1.16" + resolved "https://registry.yarnpkg.com/@webgpu/types/-/types-0.1.16.tgz#1f05497b95b7c013facf7035c8e21784645f5cc4" + integrity sha512-9E61voMP4+Rze02jlTXud++Htpjyyk8vw5Hyw9FGRrmhHQg2GqbuOfwf5Klrb8vTxc2XWI3EfO7RUHMpxTj26A== + +abab@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" + integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== + +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + +accepts@^1.3.5: + version "1.3.7" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" + integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA== + dependencies: + mime-types "~2.1.24" + negotiator "0.6.2" + +acorn-globals@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-6.0.0.tgz#46cdd39f0f8ff08a876619b55f5ac8a6dc770b45" + integrity sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg== + dependencies: + acorn "^7.1.1" + acorn-walk "^7.1.1" + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn-walk@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.1.1.tgz#345f0dffad5c735e7373d2fec9a1023e6a44b83e" + integrity sha512-wdlPY2tm/9XBr7QkKlq0WQVgiuGTX6YWPyRyBviSoScBuLfTVQhvwg6wJ369GJ/1nPfTLMfnrFIfjqVg6d+jQQ== + +acorn-walk@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.1.1.tgz#3ddab7f84e4a7e2313f6c414c5b7dac85f4e3ebc" + integrity sha512-FbJdceMlPHEAWJOILDk1fXD8lnTlEIWFkqtfk+MvmL5q/qlHfN7GEHcsFZWt/Tea9jRNPWUZG4G976nqAAmU9w== + +acorn@^7.1.1: + version "7.4.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" + integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== + +acorn@^8.4.1: + version "8.4.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.4.1.tgz#56c36251fc7cabc7096adc18f05afe814321a28c" + integrity sha512-asabaBSkEKosYKMITunzX177CXxQ4Q8BSSzMTKD+FefUhipQC70gfW5SiUDhYQ3vk8G+81HqQk7Fv9OXwwn9KA== + +acorn@^8.7.1: + version "8.7.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" + integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== + +acorn@^8.8.0: + version "8.8.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8" + integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== + +adm-zip@^0.5.2: + version "0.5.9" + resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.9.tgz#b33691028333821c0cf95c31374c5462f2905a83" + integrity sha512-s+3fXLkeeLjZ2kLjCBwQufpI5fuN+kIGBxu6530nVQZGVol0d7Y/M88/xw9HGGUcJjKf8LutN3VPRUBq6N7Ajg== + +agent-base@6, agent-base@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + +agent-base@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" + integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== + dependencies: + es6-promisify "^5.0.0" + +agentkeepalive@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.2.1.tgz#a7975cbb9f83b367f06c90cc51ff28fe7d499717" + integrity sha512-Zn4cw2NEqd+9fiSVWMscnjyQ1a8Yfoc5oBajLeo5w+YBHgDUcEBY2hS4YpTz6iN5f/2zQiktcuM6tS8x1p9dpA== + dependencies: + debug "^4.1.0" + depd "^1.1.2" + humanize-ms "^1.2.1" + +aggregate-error@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" + integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== + dependencies: + clean-stack "^2.0.0" + indent-string "^4.0.0" + +ajv-keywords@^3.5.2: + version "3.5.2" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" + integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== + +ajv@8.11.0: + version "8.11.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.11.0.tgz#977e91dd96ca669f54a11e23e378e33b884a565f" + integrity sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + +ajv@^6.10.0, ajv@^6.12.4, ajv@^6.12.5: + version "6.12.5" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.5.tgz#19b0e8bae8f476e5ba666300387775fb1a00a4da" + integrity sha512-lRF8RORchjpKG50/WFf8xmg7sgCLFiYNNnqdKflk63whMQcWR5ngGjiSXkL9bjxy6B2npOK2HSMN49jEBMSkag== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ajv@^6.12.3: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ansi-colors@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" + integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== + +ansi-regex@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= + +ansi-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" + integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= + +ansi-regex@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" + integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.0.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359" + integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA== + dependencies: + "@types/color-name" "^1.1.1" + color-convert "^2.0.1" + +ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +any-promise@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" + integrity sha1-q8av7tzqUugJzcA3au0845Y10X8= + +anymatch@~3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142" + integrity sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +anymatch@~3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" + integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +app-root-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/app-root-path/-/app-root-path-3.0.0.tgz#210b6f43873227e18a4b810a032283311555d5ad" + integrity sha512-qMcx+Gy2UZynHjOHOIXPNvpf+9cjvk3cWrBBK7zg4gH9+clobJRb9NGzcT7mQTcV/6Gm/1WelUtqxVXnNlrwcw== + +append-field@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56" + integrity sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY= + +aproba@^1.0.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" + integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== + +"aproba@^1.0.3 || ^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" + integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== + +archiver-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-2.1.0.tgz#e8a460e94b693c3e3da182a098ca6285ba9249e2" + integrity sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw== + dependencies: + glob "^7.1.4" + graceful-fs "^4.2.0" + lazystream "^1.0.0" + lodash.defaults "^4.2.0" + lodash.difference "^4.5.0" + lodash.flatten "^4.4.0" + lodash.isplainobject "^4.0.6" + lodash.union "^4.6.0" + normalize-path "^3.0.0" + readable-stream "^2.0.0" + +archiver@5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/archiver/-/archiver-5.3.1.tgz#21e92811d6f09ecfce649fbefefe8c79e57cbbb6" + integrity sha512-8KyabkmbYrH+9ibcTScQ1xCJC/CGcugdVIwB+53f5sZziXgwUh3iXlAlANMxcZyDEfTHMe6+Z5FofV8nopXP7w== + dependencies: + archiver-utils "^2.1.0" + async "^3.2.3" + buffer-crc32 "^0.2.1" + readable-stream "^3.6.0" + readdir-glob "^1.0.0" + tar-stream "^2.2.0" + zip-stream "^4.1.0" + +are-we-there-yet@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" + integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw== + dependencies: + delegates "^1.0.0" + readable-stream "^3.6.0" + +are-we-there-yet@~1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" + integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== + dependencies: + delegates "^1.0.0" + readable-stream "^2.0.6" + +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + +argparse@^1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +array-includes@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.4.tgz#f5b493162c760f3539631f005ba2bb46acb45ba9" + integrity sha512-ZTNSQkmWumEbiHO2GF4GmWxYVTiQyJy2XOTa15sdQSrvKn7l+180egQMqlrMOUMCyLMD7pmyQe4mMDUT6Behrw== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.1" + get-intrinsic "^1.1.1" + is-string "^1.0.7" + +array-union@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" + integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== + +array.prototype.flat@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.5.tgz#07e0975d84bbc7c48cd1879d609e682598d33e13" + integrity sha512-KaYU+S+ndVqyUnignHftkwc58o3uVU1jzczILJ1tN2YaIZpFIKBiP/x/j97E5MVPsaCloPbqWLB/8qCTVvT2qg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.0" + +asap@~2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= + +asn1.js@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.3.0.tgz#439099fe9174e09cff5a54a9dda70260517e8689" + integrity sha512-WHnQJFcOrIWT1RLOkFFBQkFVvyt9BPOOrH+Dp152Zk4R993rSzXUGPmkybIcUFhHE2d/iHH+nCaOWVCDbO8fgA== + dependencies: + bn.js "^4.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + safer-buffer "^2.1.0" + +asn1@~0.2.3: + version "0.2.4" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" + integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== + dependencies: + safer-buffer "~2.1.0" + +assert-never@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/assert-never/-/assert-never-1.2.1.tgz#11f0e363bf146205fb08193b5c7b90f4d1cf44fe" + integrity sha512-TaTivMB6pYI1kXwrFlEhLeGfOqoDNdTxjCdwRfFFkEA30Eu+k48W34nlok2EYWJfFFzqaEmichdNM7th6M5HNw== + +assert-plus@1.0.0, assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= + +async@>=0.2.9, async@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9" + integrity sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + +autobind-decorator@2.4.0, autobind-decorator@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/autobind-decorator/-/autobind-decorator-2.4.0.tgz#ea9e1c98708cf3b5b356f7cf9f10f265ff18239c" + integrity sha512-OGYhWUO72V6DafbF8PM8rm3EPbfuyMZcJhtm5/n26IDwO18pohE4eNazLoCGhPiXOCD0gEGmrbU3849QvM8bbw== + +autwh@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/autwh/-/autwh-0.1.0.tgz#24a5300923309d105133401a2568f9c8ab7d7e03" + integrity sha512-IkGZ4kjVlZMkEmDiVtZpGG3lDGHPqsMBIh4IpQKN7idYOJ5EGedqKPO+ychNqh8zrJEEqYsN0NcBkcmoE2uFAw== + dependencies: + oauth "0.9.15" + +available-typed-arrays@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" + integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== + +aws-sdk@2.1213.0: + version "2.1213.0" + resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.1213.0.tgz#efdbe52c72e6879348650ff9a69ab55ef948b6f3" + integrity sha512-ZfrUfhlLjwvQ6QTbg8lR4+SRFHaivzZMmfpS+64YzafcKjwc7rklYqiyh4MPTSmdNZkErgaAykYpXPMuTiWBug== + dependencies: + buffer "4.9.2" + events "1.1.1" + ieee754 "1.1.13" + jmespath "0.16.0" + querystring "0.2.0" + sax "1.2.1" + url "0.10.3" + util "^0.12.4" + uuid "8.0.0" + xml2js "0.4.19" + +aws-sign2@~0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" + integrity sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA== + +aws4@^1.8.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" + integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== + +axios@^0.24.0: + version "0.24.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.24.0.tgz#804e6fa1e4b9c5288501dd9dff56a7a0940d20d6" + integrity sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA== + dependencies: + follow-redirects "^1.14.4" + +babel-walk@3.0.0-canary-5: + version "3.0.0-canary-5" + resolved "https://registry.yarnpkg.com/babel-walk/-/babel-walk-3.0.0-canary-5.tgz#f66ecd7298357aee44955f235a6ef54219104b11" + integrity sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw== + dependencies: + "@babel/types" "^7.9.6" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +base32.js@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/base32.js/-/base32.js-0.0.1.tgz#d045736a57b1f6c139f0c7df42518a84e91bb2ba" + integrity sha1-0EVzalex9sE58MffQlGKhOkbsro= + +base64-js@^1.0.2: + version "1.3.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" + integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== + +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +bcrypt-pbkdf@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= + dependencies: + tweetnacl "^0.14.3" + +bcryptjs@2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb" + integrity sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms= + +big-integer@^1.6.17: + version "1.6.51" + resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686" + integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg== + +big.js@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" + integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== + +binary-extensions@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c" + integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow== + +binary@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79" + integrity sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk= + dependencies: + buffers "~0.1.1" + chainsaw "~0.1.0" + +bl@^4.0.1, bl@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.0.3.tgz#12d6287adc29080e22a705e5764b2a9522cdc489" + integrity sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + +bluebird@^3.7.2: + version "3.7.2" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" + integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== + +bluebird@~3.4.1: + version "3.4.7" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3" + integrity sha1-9y12C+Cbf3bQjtj66Ysomo0F+rM= + +blurhash@1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-1.1.5.tgz#3034104cd5dce5a3e5caa871ae2f0f1f2d0ab566" + integrity sha512-a+LO3A2DfxTaTztsmkbLYmUzUeApi0LZuKalwbNmqAHR6HhJGMt1qSV/R3wc+w4DL28holjqO3Bg74aUGavGjg== + +bn.js@^4.0.0: + version "4.11.8" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" + integrity sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA== + +boolbase@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +braces@^3.0.1, braces@^3.0.2, braces@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + +browser-process-hrtime@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626" + integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow== + +browser-stdout@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + +buffer-crc32@^0.2.1, buffer-crc32@^0.2.13: + version "0.2.13" + resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" + integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI= + +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk= + +buffer-from@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" + integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== + +buffer-indexof-polyfill@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz#d2732135c5999c64b277fcf9b1abe3498254729c" + integrity sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A== + +buffer-writer@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/buffer-writer/-/buffer-writer-2.0.0.tgz#ce7eb81a38f7829db09c873f2fbb792c0c98ec04" + integrity sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw== + +buffer@4.9.2: + version "4.9.2" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.2.tgz#230ead344002988644841ab0244af8c44bbe3ef8" + integrity sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg== + dependencies: + base64-js "^1.0.2" + ieee754 "^1.1.4" + isarray "^1.0.0" + +buffer@^5.5.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.6.0.tgz#a31749dc7d81d84db08abf937b6b8c4033f62786" + integrity sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw== + dependencies: + base64-js "^1.0.2" + ieee754 "^1.1.4" + +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + +buffers@~0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/buffers/-/buffers-0.1.1.tgz#b24579c3bed4d6d396aeee6d9a8ae7f5482ab7bb" + integrity sha1-skV5w77U1tOWru5tmorn9Ugqt7s= + +bufferutil@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/bufferutil/-/bufferutil-4.0.1.tgz#3a177e8e5819a1243fe16b63a199951a7ad8d4a7" + integrity sha512-xowrxvpxojqkagPcWRQVXZl0YXhRhAtBEIq3VoER1NH5Mw1n1o0ojdspp+GS2J//2gCVyrzQDApQ4unGF+QOoA== + dependencies: + node-gyp-build "~3.7.0" + +bull@4.9.0: + version "4.9.0" + resolved "https://registry.yarnpkg.com/bull/-/bull-4.9.0.tgz#522a955fa045141ce2c063ab24c2c78e4ee3782e" + integrity sha512-yiaSb41dywjIhJ3i1mczjQGDmM6pLIoM1Ea0Gcf5HKDxOoEzL5i9XEEKW7fbsj7u083UEOnQ4gSWfbWIUDO6JQ== + dependencies: + cron-parser "^4.2.1" + debuglog "^1.0.0" + get-port "^5.1.1" + ioredis "^4.28.5" + lodash "^4.17.21" + msgpackr "^1.5.2" + p-timeout "^3.2.0" + semver "^7.3.2" + uuid "^8.3.0" + +busboy@^0.2.11: + version "0.2.14" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz#6c2a622efcf47c57bbbe1e2a9c37ad36c7925453" + integrity sha1-bCpiLvz0fFe7vh4qnDetNseSVFM= + dependencies: + dicer "0.2.5" + readable-stream "1.1.x" + +bytes@3.1.0, bytes@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" + integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg== + +cacache@^16.1.0: + version "16.1.1" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-16.1.1.tgz#4e79fb91d3efffe0630d5ad32db55cc1b870669c" + integrity sha512-VDKN+LHyCQXaaYZ7rA/qtkURU+/yYhviUdvqEv2LT6QPZU8jpyzEkEVAcKlKLt5dJ5BRp11ym8lo3NKLluEPLg== + dependencies: + "@npmcli/fs" "^2.1.0" + "@npmcli/move-file" "^2.0.0" + chownr "^2.0.0" + fs-minipass "^2.1.0" + glob "^8.0.1" + infer-owner "^1.0.4" + lru-cache "^7.7.1" + minipass "^3.1.6" + minipass-collect "^1.0.2" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + mkdirp "^1.0.4" + p-map "^4.0.0" + promise-inflight "^1.0.1" + rimraf "^3.0.2" + ssri "^9.0.0" + tar "^6.1.11" + unique-filename "^1.1.1" + +cache-content-type@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c" + integrity sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA== + dependencies: + mime-types "^2.1.18" + ylru "^1.2.0" + +cacheable-lookup@6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-6.1.0.tgz#0330a543471c61faa4e9035db583aad753b36385" + integrity sha512-KJ/Dmo1lDDhmW2XDPMo+9oiy/CeqosPguPCrgcVzKyZrL6pM1gU2GmPY/xo6OQPTUaA/c0kwHuywB4E6nmT9ww== + +cacheable-lookup@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.3.tgz#049fdc59dffdd4fc285e8f4f82936591bd59fec3" + integrity sha512-W+JBqF9SWe18A72XFzN/V/CULFzPm7sBXzzR6ekkE+3tLG72wFZrBiBZhrZuDoYexop4PHJVdFAKb/Nj9+tm9w== + +cacheable-lookup@^6.0.4: + version "6.0.4" + resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-6.0.4.tgz#65c0e51721bb7f9f2cb513aed6da4a1b93ad7dc8" + integrity sha512-mbcDEZCkv2CZF4G01kr8eBd/5agkt9oCqz75tJMSIsquvRZ2sL6Hi5zGVKi/0OSC9oO1GHfJ2AV0ZIOY9vye0A== + +cacheable-request@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.2.tgz#ea0d0b889364a25854757301ca12b2da77f91d27" + integrity sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew== + dependencies: + clone-response "^1.0.2" + get-stream "^5.1.0" + http-cache-semantics "^4.0.0" + keyv "^4.0.0" + lowercase-keys "^2.0.0" + normalize-url "^6.0.1" + responselike "^2.0.0" + +call-bind@^1.0.0, call-bind@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" + integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== + dependencies: + function-bind "^1.1.1" + get-intrinsic "^1.0.2" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camelcase@^5.0.0: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +camelcase@^6.0.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809" + integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== + +canonicalize@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/canonicalize/-/canonicalize-1.0.1.tgz#657b4f3fa38a6ecb97a9e5b7b26d7a19cc6e0da9" + integrity sha512-N3cmB3QLhS5TJ5smKFf1w42rJXWe6C1qP01z4dxJiI5v269buii4fLHWETDyf7yEd0azGLNC63VxNMiPd2u0Cg== + +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== + +cbor@*: + version "7.0.5" + resolved "https://registry.yarnpkg.com/cbor/-/cbor-7.0.5.tgz#ed54cdbc19fa7352bb328d00a5393aa7ce45a10f" + integrity sha512-0aaAPgW92lLmypb9iCd22k7tSD1FbF6dps8VQzmIBKY6ych2gO09b2vo/SbaLTmezJuB8Kh88Rvpl/Uq52mNZg== + dependencies: + "@cto.af/textdecoder" "^0.0.0" + nofilter "^2.0.3" + +cbor@8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/cbor/-/cbor-8.1.0.tgz#cfc56437e770b73417a2ecbfc9caf6b771af60d5" + integrity sha512-DwGjNW9omn6EwP70aXsn7FQJx5kO12tX0bZkaTjzdVFM6/7nhA4t0EENocKGx6D2Bch9PE2KzCUf5SceBdeijg== + dependencies: + nofilter "^3.1.0" + +chainsaw@~0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/chainsaw/-/chainsaw-0.1.0.tgz#5eab50b28afe58074d0d58291388828b5e5fbc98" + integrity sha1-XqtQsor+WAdNDVgpE4iCi15fvJg= + dependencies: + traverse ">=0.3.0 <0.4" + +chalk-template@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/chalk-template/-/chalk-template-0.4.0.tgz#692c034d0ed62436b9062c1707fadcd0f753204b" + integrity sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg== + dependencies: + chalk "^4.1.2" + +chalk@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.0.0.tgz#6e98081ed2d17faab615eb52ac66ec1fe6209e72" + integrity sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chalk@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.0.1.tgz#ca57d71e82bb534a296df63bbacc4a1c22b2a4b6" + integrity sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w== + +chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@^4.0.0, chalk@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" + integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chalk@^4.0.2, chalk@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +char-regex@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" + integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== + +character-parser@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/character-parser/-/character-parser-2.2.0.tgz#c7ce28f36d4bcd9744e5ffc2c5fcde1c73261fc0" + integrity sha1-x84o821LzZdE5f/CxfzeHHMmH8A= + dependencies: + is-regex "^1.0.3" + +cheerio@0.22.0: + version "0.22.0" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-0.22.0.tgz#a9baa860a3f9b595a6b81b1a86873121ed3a269e" + integrity sha1-qbqoYKP5tZWmuBsahocxIe06Jp4= + dependencies: + css-select "~1.2.0" + dom-serializer "~0.1.0" + entities "~1.1.1" + htmlparser2 "^3.9.1" + lodash.assignin "^4.0.9" + lodash.bind "^4.1.4" + lodash.defaults "^4.0.1" + lodash.filter "^4.4.0" + lodash.flatten "^4.2.0" + lodash.foreach "^4.3.0" + lodash.map "^4.4.0" + lodash.merge "^4.4.0" + lodash.pick "^4.2.1" + lodash.reduce "^4.4.0" + lodash.reject "^4.4.0" + lodash.some "^4.4.0" + +chokidar@3.5.3, chokidar@^3.3.1, chokidar@^3.5.3: + version "3.3.1" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.1.tgz#c84e5b3d18d9a4d77558fef466b1bf16bbeb3450" + integrity sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.3.0" + optionalDependencies: + fsevents "~2.1.2" + +chownr@^1.1.1, chownr@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + +chownr@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" + integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== + +clean-stack@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" + integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== + +cli-highlight@2.1.11, cli-highlight@^2.1.11: + version "2.1.11" + resolved "https://registry.yarnpkg.com/cli-highlight/-/cli-highlight-2.1.11.tgz#49736fa452f0aaf4fae580e30acb26828d2dc1bf" + integrity sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg== + dependencies: + chalk "^4.0.0" + highlight.js "^10.7.1" + mz "^2.4.0" + parse5 "^5.1.1" + parse5-htmlparser2-tree-adapter "^6.0.0" + yargs "^16.0.0" + +cliui@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" + integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^6.2.0" + +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +clone-response@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b" + integrity sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws= + dependencies: + mimic-response "^1.0.0" + +cluster-key-slot@1.1.0, cluster-key-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz#30474b2a981fb12172695833052bc0d01336d10d" + integrity sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw== + +co-body@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/co-body/-/co-body-5.2.0.tgz#5a0a658c46029131e0e3a306f67647302f71c124" + integrity sha512-sX/LQ7LqUhgyaxzbe7IqwPeTr2yfpfUIQ/dgpKo6ZI4y4lpQA0YxAomWIY+7I7rHWcG02PG+OuPREzMW/5tszQ== + dependencies: + inflation "^2.0.0" + qs "^6.4.0" + raw-body "^2.2.0" + type-is "^1.6.14" + +co-body@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/co-body/-/co-body-6.0.0.tgz#965b9337d7f5655480787471f4237664820827e3" + integrity sha512-9ZIcixguuuKIptnY8yemEOuhb71L/lLf+Rl5JfJEUiDNJk0e02MBt7BPxR2GEh5mw8dPthQYR4jPI/BnS1MQgw== + dependencies: + inflation "^2.0.0" + qs "^6.5.2" + raw-body "^2.3.3" + type-is "^1.6.16" + +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= + +code-point-at@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" + integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= + +color-convert@2.0.1, color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + +color-name@^1.0.0, color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +color-string@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.0.tgz#63b6ebd1bec11999d1df3a79a7569451ac2be8aa" + integrity sha512-9Mrz2AQLefkH1UvASKj6v6hj/7eWgjnT/cVsR8CumieLoT+g900exWeNogqtweI8dxloXN9BDQTYro1oWu/5CQ== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + +color-support@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" + integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== + +color@^4.0.1: + version "4.2.3" + resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a" + integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A== + dependencies: + color-convert "^2.0.1" + color-string "^1.9.0" + +colorette@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" + integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== + +combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +commander@^2.19.0: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + +commander@^9.0.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-9.2.0.tgz#6e21014b2ed90d8b7c9647230d8b7a94a4a419a9" + integrity sha512-e2i4wANQiSXgnrBlIatyHtP1odfUp0BbV5Y5nEGbxtIrStkEOAAzCUirvLBNXHLr7kwLvJl6V+4V3XV9x7Wd9w== + +compress-commons@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-4.1.1.tgz#df2a09a7ed17447642bad10a85cc9a19e5c42a7d" + integrity sha512-QLdDLCKNV2dtoTorqgxngQCMA+gWXkM/Nwu7FpeBhk/RdkzimqC3jueb/FDmaZeXh+uby1jkBqE3xArsLBE5wQ== + dependencies: + buffer-crc32 "^0.2.13" + crc32-stream "^4.0.2" + normalize-path "^3.0.0" + readable-stream "^3.6.0" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + +concat-stream@^1.5.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +condense-newlines@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/condense-newlines/-/condense-newlines-0.2.1.tgz#3de985553139475d32502c83b02f60684d24c55f" + integrity sha1-PemFVTE5R10yUCyDsC9gaE0kxV8= + dependencies: + extend-shallow "^2.0.1" + is-whitespace "^0.3.0" + kind-of "^3.0.2" + +config-chain@^1.1.12: + version "1.1.12" + resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.12.tgz#0fde8d091200eb5e808caf25fe618c02f48e4efa" + integrity sha512-a1eOIcu8+7lUInge4Rpf/n4Krkf3Dd9lqhljRzII1/Zno/kRtUWnznPO3jOKBmTEktkt3fkxisUcivoj0ebzoA== + dependencies: + ini "^1.3.4" + proto-list "~1.2.1" + +console-control-strings@^1.0.0, console-control-strings@^1.1.0, console-control-strings@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= + +consolidate@^0.16.0: + version "0.16.0" + resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-0.16.0.tgz#a11864768930f2f19431660a65906668f5fbdc16" + integrity sha512-Nhl1wzCslqXYTJVDyJCu3ODohy9OfBMB5uD2BiBTzd7w+QY0lBzafkR8y8755yMYHAaMD4NuzbAw03/xzfw+eQ== + dependencies: + bluebird "^3.7.2" + +constantinople@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/constantinople/-/constantinople-4.0.1.tgz#0def113fa0e4dc8de83331a5cf79c8b325213151" + integrity sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw== + dependencies: + "@babel/parser" "^7.6.0" + "@babel/types" "^7.6.1" + +content-disposition@0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + +content-disposition@~0.5.2: + version "0.5.3" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd" + integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g== + dependencies: + safe-buffer "5.1.2" + +content-type@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" + integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== + +cookies@~0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90" + integrity sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow== + dependencies: + depd "~2.0.0" + keygrip "~1.1.0" + +copy-to@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/copy-to/-/copy-to-2.0.1.tgz#2680fbb8068a48d08656b6098092bdafc906f4a5" + integrity sha1-JoD7uAaKSNCGVrYJgJK9r8kG9KU= + +core-js@3: + version "3.23.3" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.23.3.tgz#3b977612b15da6da0c9cc4aec487e8d24f371112" + integrity sha512-oAKwkj9xcWNBAvGbT//WiCdOMpb9XQG92/Fe3ABFM/R16BsHgePG00mFOgKf7IsCtfj8tA1kHtf/VwErhriz5Q== + +core-util-is@1.0.2, core-util-is@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + +crc-32@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.0.tgz#cb2db6e29b88508e32d9dd0ec1693e7b41a18208" + integrity sha512-1uBwHxF+Y/4yF5G48fwnKq6QsIXheor3ZLPT80yGBV1oEUwpPojlEhQbWKVw1VwcTQyMGHK1/XMmTjmlsmTTGA== + dependencies: + exit-on-epipe "~1.0.1" + printj "~1.1.0" + +crc32-stream@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-4.0.2.tgz#c922ad22b38395abe9d3870f02fa8134ed709007" + integrity sha512-DxFZ/Hk473b/muq1VJ///PMNLj0ZMnzye9thBpmjpJKCc5eMgB95aK8zCGrGfQ90cWo561Te6HK9D+j4KPdM6w== + dependencies: + crc-32 "^1.2.0" + readable-stream "^3.4.0" + +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + +cron-parser@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.2.1.tgz#b43205d05ccd5c93b097dae64f3bd811f5993af3" + integrity sha512-5sJBwDYyCp+0vU5b7POl8zLWfgV5fOHxlc45FWoWdHecGC7MQHCjx0CHivCMRnGFovghKhhyYM+Zm9DcY5qcHg== + dependencies: + luxon "^1.28.0" + +cross-env@7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" + integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== + dependencies: + cross-spawn "^7.0.1" + +cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +css-select@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858" + integrity sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg= + dependencies: + boolbase "~1.0.0" + css-what "2.1" + domutils "1.5.1" + nth-check "~1.0.1" + +css-what@2.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2" + integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg== + +cssom@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.5.0.tgz#d254fa92cd8b6fbd83811b9fbaed34663cc17c36" + integrity sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw== + +cssom@~0.3.6: + version "0.3.8" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" + integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg== + +cssstyle@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-2.3.0.tgz#ff665a0ddbdc31864b09647f34163443d90b0852" + integrity sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A== + dependencies: + cssom "~0.3.6" + +cwise-compiler@^1.0.0, cwise-compiler@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/cwise-compiler/-/cwise-compiler-1.1.3.tgz#f4d667410e850d3a313a7d2db7b1e505bb034cc5" + integrity sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ== + dependencies: + uniq "^1.0.0" + +d@1, d@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a" + integrity sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA== + dependencies: + es5-ext "^0.10.50" + type "^1.0.1" + +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= + dependencies: + assert-plus "^1.0.0" + +data-uri-to-buffer@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-0.0.3.tgz#18ae979a6a0ca994b0625853916d2662bbae0b1a" + integrity sha512-Cp+jOa8QJef5nXS5hU7M1DWzXPEIoVR3kbV0dQuVGwROZg8bGf1DcCnkmajBTnvghTtSNMUdRrPjgaT6ZQucbw== + +data-uri-to-buffer@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz#b5db46aea50f6176428ac05b73be39a57701a64b" + integrity sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA== + +data-urls@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143" + integrity sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ== + dependencies: + abab "^2.0.6" + whatwg-mimetype "^3.0.0" + whatwg-url "^11.0.0" + +date-fns@2.29.2: + version "2.29.2" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.2.tgz#0d4b3d0f3dff0f920820a070920f0d9662c51931" + integrity sha512-0VNbwmWJDS/G3ySwFSJA3ayhbURMTJLtwM2DTxf9CWondCnh6DTNlO9JgRSq6ibf4eD0lfMJNBxUdEAHHix+bA== + +date-fns@^2.28.0: + version "2.28.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2" + integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw== + +debug@2, debug@^2.2.0, debug@^2.5.2, debug@^2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" + integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== + dependencies: + ms "2.1.2" + +debug@4.3.3: + version "4.3.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664" + integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q== + dependencies: + ms "2.1.2" + +debug@4.3.4, debug@^4.3.3, debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + +debug@^3.1.0, debug@^3.2.7: + version "3.2.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + +debug@^3.2.6: + version "3.2.6" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" + integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== + dependencies: + ms "^2.1.1" + +debug@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b" + integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw== + dependencies: + ms "2.1.2" + +debuglog@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" + integrity sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI= + +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= + +decamelize@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" + integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== + +decimal.js@^10.3.1: + version "10.3.1" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783" + integrity sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ== + +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" + +deep-email-validator@0.1.21: + version "0.1.21" + resolved "https://registry.yarnpkg.com/deep-email-validator/-/deep-email-validator-0.1.21.tgz#5d0120fe1aeae83ab7cb39378a40a381b681219f" + integrity sha512-DBAmMzbr+MAubXQ+TS9tZuPwLcdKscb8YzKZiwoLqF3NmaeEgXvSSHhZ0EXOFeKFE2FNWC4mNXCyiQ/JdFXUwg== + dependencies: + "@types/disposable-email-domains" "^1.0.1" + axios "^0.24.0" + disposable-email-domains "^1.0.59" + mailcheck "^1.1.1" + +deep-equal@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" + integrity sha1-9dJgKStmDghO/0zbyfCK0yR0SLU= + +deep-extend@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + +deep-is@^0.1.3, deep-is@~0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" + integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= + +deepmerge@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" + integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== + +defer-to-connect@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.0.tgz#83d6b199db041593ac84d781b5222308ccf4c2c1" + integrity sha512-bYL2d05vOSf1JEZNx5vSAtPuBMkX8K9EUutg7zlKvTqKXHt7RhWJFbmd7qakVuf13i+IkGmp6FwSsONOf6VYIg== + +defer-to-connect@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587" + integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg== + +define-properties@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" + integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== + dependencies: + object-keys "^1.0.12" + +define-properties@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.4.tgz#0b14d7bd7fbeb2f3572c3a7eda80ea5d57fb05b1" + integrity sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA== + dependencies: + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= + +denque@^1.1.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/denque/-/denque-1.4.1.tgz#6744ff7641c148c3f8a69c307e51235c1f4a37cf" + integrity sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ== + +depd@^1.1.2, depd@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" + integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= + +depd@^2.0.0, depd@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +destroy@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" + integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= + +detect-libc@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= + +detect-libc@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.0.tgz#c528bc09bc6d1aa30149228240917c225448f204" + integrity sha512-S55LzUl8HUav8l9E2PBTlC5PAJrHK7tkM+XXFGD+fbsbkTzhCpG6K05LxJcUOEWzMa4v6ptcMZ9s3fOdJDu0Zw== + +dicer@0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.2.5.tgz#5996c086bb33218c812c090bddc09cd12facb70f" + integrity sha1-WZbAhrszIYyBLAkL3cCc0S+stw8= + dependencies: + readable-stream "1.1.x" + streamsearch "0.1.2" + +diff@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" + integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== + +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + +dijkstrajs@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.1.tgz#d3cd81221e3ea40742cfcde556d4e99e98ddc71b" + integrity sha1-082BIh4+pAdCz83lVtTpnpjdxxs= + +dir-glob@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" + integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== + dependencies: + path-type "^4.0.0" + +disposable-email-domains@^1.0.59: + version "1.0.59" + resolved "https://registry.yarnpkg.com/disposable-email-domains/-/disposable-email-domains-1.0.59.tgz#8b3670667dcef9d0d21b224de283d56d468913c2" + integrity sha512-45NbOP1Oboaddf0pD5mGnT+1msEifY6VUcR9Msq4zBHk2EeGv9PxiwuoynIfdGID1BSFR3U3egPfMbERkqXxUQ== + +doctrine@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" + integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== + dependencies: + esutils "^2.0.2" + +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + +doctypes@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/doctypes/-/doctypes-1.1.0.tgz#ea80b106a87538774e8a3a4a5afe293de489e0a9" + integrity sha1-6oCxBqh1OHdOijpKWv4pPeSJ4Kk= + +dom-serializer@0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" + integrity sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g== + dependencies: + domelementtype "^2.0.1" + entities "^2.0.0" + +dom-serializer@^1.0.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.1.tgz#d845a1565d7c041a95e5dab62184ab41e3a519be" + integrity sha512-Pv2ZluG5ife96udGgEDovOOOA5UELkltfJpnIExPrAk1LTvecolUGn6lIaoLh86d83GiB86CjzciMd9BuRB71Q== + dependencies: + domelementtype "^2.0.1" + domhandler "^4.0.0" + entities "^2.0.0" + +dom-serializer@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0" + integrity sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA== + dependencies: + domelementtype "^1.3.0" + entities "^1.1.1" + +domelementtype@1, domelementtype@^1.3.0, domelementtype@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" + integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== + +domelementtype@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.0.1.tgz#1f8bdfe91f5a78063274e803b4bdcedf6e94f94d" + integrity sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ== + +domelementtype@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57" + integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A== + +domexception@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-4.0.0.tgz#4ad1be56ccadc86fc76d033353999a8037d03673" + integrity sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw== + dependencies: + webidl-conversions "^7.0.0" + +domhandler@^2.3.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803" + integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA== + dependencies: + domelementtype "1" + +domhandler@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.1.0.tgz#c1d8d494d5ec6db22de99e46a149c2a4d23ddd43" + integrity sha512-/6/kmsGlMY4Tup/nGVutdrK9yQi4YjWVcVeoQmixpzjOUK1U7pQkvAPHBJeUxOgxF0J8f8lwCJSlCfD0V4CMGQ== + dependencies: + domelementtype "^2.2.0" + +domhandler@^4.2.0: + version "4.2.2" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.2.2.tgz#e825d721d19a86b8c201a35264e226c678ee755f" + integrity sha512-PzE9aBMsdZO8TK4BnuJwH0QT41wgMbRzuZrHUcpYncEjmQazq8QEaBWgLG7ZyC/DAZKEgglpIA6j4Qn/HmxS3w== + dependencies: + domelementtype "^2.2.0" + +domutils@1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" + integrity sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8= + dependencies: + dom-serializer "0" + domelementtype "1" + +domutils@^1.5.1: + version "1.7.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" + integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg== + dependencies: + dom-serializer "0" + domelementtype "1" + +domutils@^2.5.2: + version "2.8.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" + integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== + dependencies: + dom-serializer "^1.0.1" + domelementtype "^2.2.0" + domhandler "^4.2.0" + +dotenv@^16.0.0: + version "16.0.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.0.tgz#c619001253be89ebb638d027b609c75c26e47411" + integrity sha512-qD9WU0MPM4SWLPJy/r2Be+2WgQj8plChsyrCNQzW/0WjvcJQiKQJ9mH3ZgB3fxbUUxgc/11ZJ0Fi5KiimWGz2Q== + +duplexer2@~0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" + integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME= + dependencies: + readable-stream "^2.0.2" + +ecc-jsbn@~0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" + integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= + dependencies: + jsbn "~0.1.0" + safer-buffer "^2.1.0" + +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + +editorconfig@^0.15.3: + version "0.15.3" + resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.3.tgz#bef84c4e75fb8dcb0ce5cee8efd51c15999befc5" + integrity sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g== + dependencies: + commander "^2.19.0" + lru-cache "^4.1.5" + semver "^5.6.0" + sigmund "^1.0.1" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= + +ejs@^3.1.7: + version "3.1.8" + resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.8.tgz#758d32910c78047585c7ef1f92f9ee041c1c190b" + integrity sha512-/sXZeMlhS0ArkfX2Aw780gJzXSMPnKjtspYZv+f3NiKLlubezAHDU5+9xz6gd3/NhG3txQCo6xlglmTS+oTGEQ== + dependencies: + jake "^10.8.5" + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emojis-list@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" + integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== + +encode-utf8@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" + integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw== + +encodeurl@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= + +encoding@^0.1.13: + version "0.1.13" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" + integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== + dependencies: + iconv-lite "^0.6.2" + +end-of-stream@^1.1.0, end-of-stream@^1.4.1: + version "1.4.4" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + +enhanced-resolve@^5.0.0: + version "5.8.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.8.0.tgz#d9deae58f9d3773b6a111a5a46831da5be5c9ac0" + integrity sha512-Sl3KRpJA8OpprrtaIswVki3cWPiPKxXuFxJXBp+zNb6s6VwNWwFRUdtmzd2ReUut8n+sCPx7QCtQ7w5wfJhSgQ== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.2.0" + +entities@^1.1.1, entities@~1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" + integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== + +entities@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4" + integrity sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw== + +entities@^2.0.3: + version "2.2.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" + integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== + +entities@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.3.0.tgz#62915f08d67353bb4eb67e3d62641a4059aec656" + integrity sha512-/iP1rZrSEJ0DTlPiX+jbzlA3eVkY/e8L8SozroF395fIqE3TYF/Nz7YOMAawta+vLmyJ/hkGNNPcSbMADCCXbg== + +entities@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.4.0.tgz#97bdaba170339446495e653cfd2db78962900174" + integrity sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA== + +env-paths@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.0.tgz#cdca557dc009152917d6166e2febe1f039685e43" + integrity sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA== + +err-code@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9" + integrity sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA== + +es-abstract@^1.19.0, es-abstract@^1.19.1: + version "1.19.1" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.19.1.tgz#d4885796876916959de78edaa0df456627115ec3" + integrity sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w== + dependencies: + call-bind "^1.0.2" + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + get-intrinsic "^1.1.1" + get-symbol-description "^1.0.0" + has "^1.0.3" + has-symbols "^1.0.2" + internal-slot "^1.0.3" + is-callable "^1.2.4" + is-negative-zero "^2.0.1" + is-regex "^1.1.4" + is-shared-array-buffer "^1.0.1" + is-string "^1.0.7" + is-weakref "^1.0.1" + object-inspect "^1.11.0" + object-keys "^1.1.1" + object.assign "^4.1.2" + string.prototype.trimend "^1.0.4" + string.prototype.trimstart "^1.0.4" + unbox-primitive "^1.0.1" + +es-abstract@^1.19.5, es-abstract@^1.20.0: + version "1.20.1" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.20.1.tgz#027292cd6ef44bd12b1913b828116f54787d1814" + integrity sha512-WEm2oBhfoI2sImeM4OF2zE2V3BYdSF+KnSi9Sidz51fQHd7+JuF8Xgcj9/0o+OWeIeIS/MiuNnlruQrJf16GQA== + dependencies: + call-bind "^1.0.2" + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + function.prototype.name "^1.1.5" + get-intrinsic "^1.1.1" + get-symbol-description "^1.0.0" + has "^1.0.3" + has-property-descriptors "^1.0.0" + has-symbols "^1.0.3" + internal-slot "^1.0.3" + is-callable "^1.2.4" + is-negative-zero "^2.0.2" + is-regex "^1.1.4" + is-shared-array-buffer "^1.0.2" + is-string "^1.0.7" + is-weakref "^1.0.2" + object-inspect "^1.12.0" + object-keys "^1.1.1" + object.assign "^4.1.2" + regexp.prototype.flags "^1.4.3" + string.prototype.trimend "^1.0.5" + string.prototype.trimstart "^1.0.5" + unbox-primitive "^1.0.2" + +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" + integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +es5-ext@^0.10.35, es5-ext@^0.10.50: + version "0.10.53" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.53.tgz#93c5a3acfdbef275220ad72644ad02ee18368de1" + integrity sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q== + dependencies: + es6-iterator "~2.0.3" + es6-symbol "~3.1.3" + next-tick "~1.0.0" + +es6-iterator@~2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7" + integrity sha1-p96IkUGgWpSwhUQDstCg+/qY87c= + dependencies: + d "1" + es5-ext "^0.10.35" + es6-symbol "^3.1.1" + +es6-promise@^4.0.3: + version "4.2.8" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" + integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== + +es6-promisify@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203" + integrity sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ== + dependencies: + es6-promise "^4.0.3" + +es6-symbol@^3.1.1, es6-symbol@~3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.3.tgz#bad5d3c1bcdac28269f4cb331e431c78ac705d18" + integrity sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA== + dependencies: + d "^1.0.1" + ext "^1.1.2" + +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== + +escape-html@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= + +escape-regexp@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/escape-regexp/-/escape-regexp-0.0.1.tgz#f44bda12d45bbdf9cb7f862ee7e4827b3dd32254" + integrity sha1-9EvaEtRbvfnLf4Yu5+SCez3TIlQ= + +escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + +escodegen@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.0.0.tgz#5e32b12833e8aa8fa35e1bf0befa89380484c7dd" + integrity sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw== + dependencies: + esprima "^4.0.1" + estraverse "^5.2.0" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.6.1" + +eslint-import-resolver-node@^0.3.6: + version "0.3.6" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz#4048b958395da89668252001dbd9eca6b83bacbd" + integrity sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw== + dependencies: + debug "^3.2.7" + resolve "^1.20.0" + +eslint-module-utils@^2.7.3: + version "2.7.3" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.7.3.tgz#ad7e3a10552fdd0642e1e55292781bd6e34876ee" + integrity sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ== + dependencies: + debug "^3.2.7" + find-up "^2.1.0" + +eslint-plugin-import@2.26.0: + version "2.26.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz#f812dc47be4f2b72b478a021605a59fc6fe8b88b" + integrity sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA== + dependencies: + array-includes "^3.1.4" + array.prototype.flat "^1.2.5" + debug "^2.6.9" + doctrine "^2.1.0" + eslint-import-resolver-node "^0.3.6" + eslint-module-utils "^2.7.3" + has "^1.0.3" + is-core-module "^2.8.1" + is-glob "^4.0.3" + minimatch "^3.1.2" + object.values "^1.1.5" + resolve "^1.22.0" + tsconfig-paths "^3.14.1" + +eslint-scope@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" + integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== + dependencies: + esrecurse "^4.3.0" + estraverse "^4.1.1" + +eslint-scope@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.1.tgz#fff34894c2f65e5226d3041ac480b4513a163642" + integrity sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-utils@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-3.0.0.tgz#8aebaface7345bb33559db0a1f13a1d2d48c3672" + integrity sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA== + dependencies: + eslint-visitor-keys "^2.0.0" + +eslint-visitor-keys@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz#21fdc8fbcd9c795cc0321f0563702095751511a8" + integrity sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ== + +eslint-visitor-keys@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" + integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== + +eslint@8.23.0: + version "8.23.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.23.0.tgz#a184918d288820179c6041bb3ddcc99ce6eea040" + integrity sha512-pBG/XOn0MsJcKcTRLr27S5HpzQo4kLr+HjLQIyK4EiCsijDl/TB+h5uEuJU6bQ8Edvwz1XWOjpaP2qgnXGpTcA== + dependencies: + "@eslint/eslintrc" "^1.3.1" + "@humanwhocodes/config-array" "^0.10.4" + "@humanwhocodes/gitignore-to-minimatch" "^1.0.2" + "@humanwhocodes/module-importer" "^1.0.1" + ajv "^6.10.0" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + doctrine "^3.0.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.1.1" + eslint-utils "^3.0.0" + eslint-visitor-keys "^3.3.0" + espree "^9.4.0" + esquery "^1.4.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + find-up "^5.0.0" + functional-red-black-tree "^1.0.1" + glob-parent "^6.0.1" + globals "^13.15.0" + globby "^11.1.0" + grapheme-splitter "^1.0.4" + ignore "^5.2.0" + import-fresh "^3.0.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + js-yaml "^4.1.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.1" + regexpp "^3.2.0" + strip-ansi "^6.0.1" + strip-json-comments "^3.1.0" + text-table "^0.2.0" + +espree@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.0.tgz#cd4bc3d6e9336c433265fc0aa016fc1aaf182f8a" + integrity sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw== + dependencies: + acorn "^8.8.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.3.0" + +esprima@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esquery@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.4.0.tgz#2148ffc38b82e8c7057dfed48425b3e61f0f24a5" + integrity sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^4.1.1: + version "4.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" + integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== + +estraverse@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.1.0.tgz#374309d39fd935ae500e7b92e8a6b4c720e59642" + integrity sha512-FyohXK+R0vE+y1nHLoBM7ZTyqRpqAlhdZHCWIWEviFLiGB8b04H6bQs8G+XTthacvT8VuwvteiP7RJSxMs8UEw== + +estraverse@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880" + integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + +eventemitter3@^4.0.7: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + +events@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" + integrity sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ= + +execa@6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-6.1.0.tgz#cea16dee211ff011246556388effa0818394fb20" + integrity sha512-QVWlX2e50heYJcCPG0iWtf8r0xjEYfz/OYLGDYH+IyjWezzPNxz63qNFOu0l4YftGWuizFVZHHs8PrLU5p2IDA== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.1" + human-signals "^3.0.1" + is-stream "^3.0.0" + merge-stream "^2.0.0" + npm-run-path "^5.1.0" + onetime "^6.0.0" + signal-exit "^3.0.7" + strip-final-newline "^3.0.0" + +exit-on-epipe@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz#0bdd92e87d5285d267daa8171d0eb06159689692" + integrity sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw== + +expand-template@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" + integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== + +ext@^1.1.2: + version "1.4.0" + resolved "https://registry.yarnpkg.com/ext/-/ext-1.4.0.tgz#89ae7a07158f79d35517882904324077e4379244" + integrity sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A== + dependencies: + type "^2.0.0" + +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8= + dependencies: + is-extendable "^0.1.0" + +extend@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +extsprintf@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" + integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= + +extsprintf@^1.2.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" + integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= + +fast-deep-equal@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz#545145077c501491e33b15ec408c294376e94ae4" + integrity sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA== + +fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-glob@^3.1.1: + version "3.2.4" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.4.tgz#d20aefbf99579383e7f3cc66529158c9b98554d3" + integrity sha512-kr/Oo6PX51265qeuCYsyGypiO5uJFgBS0jksyG7FUeCyQzNwYnzrNIMR1NXfkZXsMYXYLRAHgISHBz8gQcxKHQ== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.0" + merge2 "^1.3.0" + micromatch "^4.0.2" + picomatch "^2.2.1" + +fast-glob@^3.2.9: + version "3.2.11" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9" + integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= + +fast-xml-parser@^3.19.0: + version "3.19.0" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-3.19.0.tgz#cb637ec3f3999f51406dd8ff0e6fc4d83e520d01" + integrity sha512-4pXwmBplsCPv8FOY1WRakF970TjNGnGnfbOnLqjlYvMiF1SR3yOHyxMR/YCXpPTOspNF5gwudqktIP4VsWkvBg== + +fastq@^1.6.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.8.0.tgz#550e1f9f59bbc65fe185cb6a9b4d95357107f481" + integrity sha512-SMIZoZdLh/fgofivvIkmknUXyPnvxRE3DhtZ5Me3Mrsk5gyPL42F0xr51TdRXskBxHfMp+07bcYzfsYEsSQA9Q== + dependencies: + reusify "^1.0.4" + +feed@4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/feed/-/feed-4.2.2.tgz#865783ef6ed12579e2c44bbef3c9113bc4956a7e" + integrity sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ== + dependencies: + xml-js "^1.6.11" + +fetch-blob@^3.1.2, fetch-blob@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.1.4.tgz#e8c6567f80ad7fc22fd302e7dcb72bafde9c1717" + integrity sha512-Eq5Xv5+VlSrYWEqKrusxY1C3Hm/hjeAsCGVG3ft7pZahlUAChpGZT/Ms1WmSLnEAisEXszjzu/s+ce6HZB2VHA== + dependencies: + node-domexception "^1.0.0" + web-streams-polyfill "^3.0.3" + +file-entry-cache@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" + integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== + dependencies: + flat-cache "^3.0.4" + +file-type@17.1.6: + version "17.1.6" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-17.1.6.tgz#18669e0577a4849ef6e73a41f8bdf1ab5ae21023" + integrity sha512-hlDw5Ev+9e883s0pwUsuuYNu4tD7GgpUnOvykjv1Gya0ZIjuKumthDRua90VUn6/nlRKAjcxLUnHNTIUWwWIiw== + dependencies: + readable-web-to-node-stream "^3.0.2" + strtok3 "^7.0.0-alpha.9" + token-types "^5.0.0-alpha.2" + +filelist@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.3.tgz#448607750376484932f67ef1b9ff07386b036c83" + integrity sha512-LwjCsruLWQULGYKy7TX0OPtrL9kLpojOFKc5VCTxdFTV7w5zbsgqVKfnkKG7Qgjtq50gKfO56hJv88OfcGb70Q== + dependencies: + minimatch "^5.0.1" + +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + +find-up@5.0.0, find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +find-up@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" + integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c= + dependencies: + locate-path "^2.0.0" + +find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +flat-cache@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" + integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg== + dependencies: + flatted "^3.1.0" + rimraf "^3.0.2" + +flat@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" + integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== + +flatted@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.1.0.tgz#a5d06b4a8b01e3a63771daa5cb7a1903e2e57067" + integrity sha512-tW+UkmtNg/jv9CSofAKvgVcO7c2URjhTdW1ZTkcAritblu8tajiYy7YisnIflEwtKssCtOxpnBRoCB7iap0/TA== + +fluent-ffmpeg@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/fluent-ffmpeg/-/fluent-ffmpeg-2.1.2.tgz#c952de2240f812ebda0aa8006d7776ee2acf7d74" + integrity sha1-yVLeIkD4EuvaCqgAbXd27irPfXQ= + dependencies: + async ">=0.2.9" + which "^1.1.1" + +follow-redirects@^1.14.4: + version "1.14.8" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc" + integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA== + +for-each@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" + integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== + dependencies: + is-callable "^1.1.3" + +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw== + +form-data-encoder@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-2.0.1.tgz#aec41860aca0275cb6026650d139c6701b0992c1" + integrity sha512-Oy+P9w5mnO4TWXVgUiQvggNKPI9/ummcSt5usuIV6HkaLKigwzPpoenhEqmGmx3zHqm6ZLJ+CR/99N8JLinaEw== + +form-data@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" + integrity sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +form-data@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" + integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + +formdata-polyfill@^4.0.10: + version "4.0.10" + resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" + integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== + dependencies: + fetch-blob "^3.1.2" + +fresh@~0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= + +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + +fs-extra@^8.0.1: + version "8.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" + integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^4.0.0" + universalify "^0.1.0" + +fs-minipass@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7" + integrity sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA== + dependencies: + minipass "^2.6.0" + +fs-minipass@^2.0.0, fs-minipass@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" + integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== + dependencies: + minipass "^3.0.0" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + +fsevents@~2.1.2: + version "2.1.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e" + integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ== + +fsevents@~2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +fstream@^1.0.12: + version "1.0.12" + resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045" + integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg== + dependencies: + graceful-fs "^4.1.2" + inherits "~2.0.0" + mkdirp ">=0.5 0" + rimraf "2" + +function-bind@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== + +function.prototype.name@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.5.tgz#cce0505fe1ffb80503e6f9e46cc64e46a12a9621" + integrity sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.0" + functions-have-names "^1.2.2" + +functional-red-black-tree@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" + integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= + +functions-have-names@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== + +gauge@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" + integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q== + dependencies: + aproba "^1.0.3 || ^2.0.0" + color-support "^1.1.2" + console-control-strings "^1.0.0" + has-unicode "^2.0.1" + object-assign "^4.1.1" + signal-exit "^3.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + wide-align "^1.1.2" + +gauge@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-4.0.0.tgz#afba07aa0374a93c6219603b1fb83eaa2264d8f8" + integrity sha512-F8sU45yQpjQjxKkm1UOAhf0U/O0aFt//Fl7hsrNVto+patMHjs7dPI9mFOGUKbhrgKm0S3EjW3scMFuQmWSROw== + dependencies: + ansi-regex "^5.0.1" + aproba "^1.0.3 || ^2.0.0" + color-support "^1.1.2" + console-control-strings "^1.0.0" + has-unicode "^2.0.1" + signal-exit "^3.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + wide-align "^1.1.2" + +gauge@~2.7.3: + version "2.7.4" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" + integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= + dependencies: + aproba "^1.0.3" + console-control-strings "^1.0.0" + has-unicode "^2.0.0" + object-assign "^4.1.0" + signal-exit "^3.0.0" + string-width "^1.0.1" + strip-ansi "^3.0.1" + wide-align "^1.1.0" + +generic-pool@3.8.2: + version "3.8.2" + resolved "https://registry.yarnpkg.com/generic-pool/-/generic-pool-3.8.2.tgz#aab4f280adb522fdfbdc5e5b64d718d3683f04e9" + integrity sha512-nGToKy6p3PAbYQ7p1UlWl6vSPwfwU6TMSWK7TTu+WUY4ZjyZQGniGGt2oNVvyNSpyZYSB43zMXVLcBm08MTMkg== + +get-caller-file@^2.0.1, get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6" + integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + +get-paths@0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/get-paths/-/get-paths-0.0.7.tgz#15331086752077cf130166ccd233a1cdbeefcf38" + integrity sha512-0wdJt7C1XKQxuCgouqd+ZvLJ56FQixKoki9MrFaO4EriqzXOiH9gbukaDE1ou08S8Ns3/yDzoBAISNPqj6e6tA== + dependencies: + pify "^4.0.1" + +get-pixels-frame-info-update@3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/get-pixels-frame-info-update/-/get-pixels-frame-info-update-3.3.2.tgz#8b549efcb570454094e5a9dc51d61cb9a62cdb4f" + integrity sha512-LzVij57X/gK4Y6LpcDdqj+R9WCpD6Sv3ZH85GMA+S3xgPGCz81mHql4GiSnF4GijRjk7TE0ja2sDr8FFYKLe2g== + dependencies: + data-uri-to-buffer "0.0.3" + jpeg-js "^0.3.2" + mime-types "^2.0.1" + ndarray "^1.0.13" + ndarray-pack "^1.1.1" + node-bitmap "0.0.1" + omggif "^1.0.5" + parse-data-uri "^0.2.0" + pngjs "^3.3.3" + request "^2.44.0" + through "^2.3.4" + +get-port@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193" + integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ== + +get-stream@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + dependencies: + pump "^3.0.0" + +get-stream@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + +get-symbol-description@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6" + integrity sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.1.1" + +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= + dependencies: + assert-plus "^1.0.0" + +gif-encoder@0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/gif-encoder/-/gif-encoder-0.4.1.tgz#0ec2192b35b91e50073258354b13d3e5165f106b" + integrity sha512-++rNGpDBgWQ9eXj9JfTBLHMUEd7lDOdzIvFyHQM9yL8ffxkcg4G6jWmsgu/r59Uq6nHc3wcVwtgy3geLnIWunQ== + dependencies: + readable-stream "~1.1.9" + +github-from-package@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" + integrity sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4= + +glob-parent@^5.1.0, glob-parent@^5.1.2, glob-parent@~5.1.0, glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-parent@^6.0.1: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob@7.2.0, glob@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" + integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^7.1.3, glob@^7.1.4: + version "7.1.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^8.0.1: + version "8.0.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-8.0.3.tgz#415c6eb2deed9e502c68fa44a272e6da6eeca42e" + integrity sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^5.0.1" + once "^1.3.0" + +globals@^13.15.0: + version "13.15.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.15.0.tgz#38113218c907d2f7e98658af246cef8b77e90bac" + integrity sha512-bpzcOlgDhMG070Av0Vy5Owklpv1I6+j96GhUI7Rh7IzDCKLzboflLrrfqMu8NquDbiR4EOQk7XzJwqVJxicxog== + dependencies: + type-fest "^0.20.2" + +globby@^11.0.4: + version "11.0.4" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.4.tgz#2cbaff77c2f2a62e71e9b2813a67b97a3a3001a5" + integrity sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.1.1" + ignore "^5.1.4" + merge2 "^1.3.0" + slash "^3.0.0" + +globby@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" + integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.2.9" + ignore "^5.2.0" + merge2 "^1.4.1" + slash "^3.0.0" + +google-protobuf@^3.9.2: + version "3.20.1" + resolved "https://registry.yarnpkg.com/google-protobuf/-/google-protobuf-3.20.1.tgz#1b255c2b59bcda7c399df46c65206aa3c7a0ce8b" + integrity sha512-XMf1+O32FjYIV3CYu6Tuh5PNbfNEU5Xu22X+Xkdb/DUexFlCzhvv7d5Iirm4AOwn8lv4al1YvIhzGrg2j9Zfzw== + +got@11.8.5: + version "11.8.5" + resolved "https://registry.yarnpkg.com/got/-/got-11.8.5.tgz#ce77d045136de56e8f024bebb82ea349bc730046" + integrity sha512-o0Je4NvQObAuZPHLFoRSkdG2lTgtcynqymzg2Vupdx6PorhaT5MCbIyXG6d4D94kk8ZG57QeosgdiqfJWhEhlQ== + dependencies: + "@sindresorhus/is" "^4.0.0" + "@szmarczak/http-timer" "^4.0.5" + "@types/cacheable-request" "^6.0.1" + "@types/responselike" "^1.0.0" + cacheable-lookup "^5.0.3" + cacheable-request "^7.0.2" + decompress-response "^6.0.0" + http2-wrapper "^1.0.0-beta.5.2" + lowercase-keys "^2.0.0" + p-cancelable "^2.0.0" + responselike "^2.0.0" + +got@12.3.1: + version "12.3.1" + resolved "https://registry.yarnpkg.com/got/-/got-12.3.1.tgz#79d6ebc0cb8358c424165698ddb828be56e74684" + integrity sha512-tS6+JMhBh4iXMSXF6KkIsRxmloPln31QHDlcb6Ec3bzxjjFJFr/8aXdpyuLmVc9I4i2HyBHYw1QU5K1ruUdpkw== + dependencies: + "@sindresorhus/is" "^5.2.0" + "@szmarczak/http-timer" "^5.0.1" + "@types/cacheable-request" "^6.0.2" + "@types/responselike" "^1.0.0" + cacheable-lookup "^6.0.4" + cacheable-request "^7.0.2" + decompress-response "^6.0.0" + form-data-encoder "^2.0.1" + get-stream "^6.0.1" + http2-wrapper "^2.1.10" + lowercase-keys "^3.0.0" + p-cancelable "^3.0.0" + responselike "^2.0.0" + +graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" + integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== + +graceful-fs@^4.2.0, graceful-fs@^4.2.2: + version "4.2.8" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a" + integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== + +graceful-fs@^4.2.6: + version "4.2.6" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee" + integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ== + +grapheme-splitter@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" + integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== + +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + integrity sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q== + +har-validator@~5.1.3: + version "5.1.5" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd" + integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w== + dependencies: + ajv "^6.12.3" + har-schema "^2.0.0" + +has-bigints@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" + integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA== + +has-bigints@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" + integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-property-descriptors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz#610708600606d36961ed04c196193b6a607fa861" + integrity sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ== + dependencies: + get-intrinsic "^1.1.1" + +has-symbols@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" + integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== + +has-symbols@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423" + integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw== + +has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +has-tostringtag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25" + integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ== + dependencies: + has-symbols "^1.0.2" + +has-unicode@^2.0.0, has-unicode@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= + +has@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== + dependencies: + function-bind "^1.1.1" + +he@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +highlight.js@^10.7.1: + version "10.7.2" + resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.2.tgz#89319b861edc66c48854ed1e6da21ea89f847360" + integrity sha512-oFLl873u4usRM9K63j4ME9u3etNF0PLiJhSQ8rdfuL51Wn3zkD6drf9ZW0dOzjnZI22YYG24z30JcmfCZjMgYg== + +hpagent@0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/hpagent/-/hpagent-0.1.2.tgz#cab39c66d4df2d4377dbd212295d878deb9bdaa9" + integrity sha512-ePqFXHtSQWAFXYmj+JtOTHr84iNrII4/QRlAAPPE+zqnKy4xJo7Ie1Y4kC7AdB+LxLxSTTzBMASsEcy0q8YyvQ== + +hpagent@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/hpagent/-/hpagent-0.1.1.tgz#66f67f16e5c7a8b59a068e40c2658c2c749ad5e2" + integrity sha512-IxJWQiY0vmEjetHdoE9HZjD4Cx+mYTr25tR7JCxXaiI3QxW0YqYyM11KyZbHufoa/piWhMb2+D3FGpMgmA2cFQ== + +html-encoding-sniffer@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" + integrity sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA== + dependencies: + whatwg-encoding "^2.0.0" + +html-entities@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.2.tgz#760b404685cb1d794e4f4b744332e3b00dcfe488" + integrity sha512-c3Ab/url5ksaT0WyleslpBEthOzWhrjQbg75y7XUsfSzi3Dgzt0l8w5e7DylRn15MTlMMD58dTfzddNS2kcAjQ== + +htmlparser2@^3.9.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" + integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== + dependencies: + domelementtype "^1.3.1" + domhandler "^2.3.0" + domutils "^1.5.1" + entities "^1.1.1" + inherits "^2.0.1" + readable-stream "^3.1.1" + +htmlparser2@^6.0.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" + integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A== + dependencies: + domelementtype "^2.0.1" + domhandler "^4.0.0" + domutils "^2.5.2" + entities "^2.0.0" + +http-assert@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.4.1.tgz#c5f725d677aa7e873ef736199b89686cceb37878" + integrity sha512-rdw7q6GTlibqVVbXr0CKelfV5iY8G2HqEUkhSk297BMbSpSL8crXC+9rjKoMcZZEsksX30le6f/4ul4E28gegw== + dependencies: + deep-equal "~1.0.1" + http-errors "~1.7.2" + +http-cache-semantics@^4.0.0, http-cache-semantics@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" + integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== + +http-errors@1.7.3, http-errors@^1.6.3, http-errors@^1.7.3, http-errors@~1.7.2: + version "1.7.3" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06" + integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw== + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.1.1" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.0" + +http-errors@~1.6.2: + version "1.6.3" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" + integrity sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0= + dependencies: + depd "~1.1.2" + inherits "2.0.3" + setprototypeof "1.1.0" + statuses ">= 1.4.0 < 2" + +http-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" + integrity sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w== + dependencies: + "@tootallnate/once" "2" + agent-base "6" + debug "4" + +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + integrity sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ== + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +http2-wrapper@^1.0.0-beta.5.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz#b8f55e0c1f25d4ebd08b3b0c2c079f9590800b3d" + integrity sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg== + dependencies: + quick-lru "^5.1.1" + resolve-alpn "^1.0.0" + +http2-wrapper@^2.1.10: + version "2.1.10" + resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-2.1.10.tgz#307cd0cee2564723692ad34c2d570d12f10e83be" + integrity sha512-QHgsdYkieKp+6JbXP25P+tepqiHYd+FVnDwXpxi/BlUcoIB0nsmTOymTNvETuTO+pDuwcSklPE72VR3DqV+Haw== + dependencies: + quick-lru "^5.1.1" + resolve-alpn "^1.2.0" + +http_ece@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/http_ece/-/http_ece-1.1.0.tgz#74780c6eb32d8ddfe9e36a83abcd81fe0cd4fb75" + integrity sha512-bptAfCDdPJxOs5zYSe7Y3lpr772s1G346R4Td5LgRUeCwIGpCGDUTJxRrhTNcAXbx37spge0kWEIH7QAYWNTlA== + dependencies: + urlsafe-base64 "~1.0.0" + +https-proxy-agent@^2.2.1: + version "2.2.4" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz#4ee7a737abd92678a293d9b34a1af4d0d08c787b" + integrity sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg== + dependencies: + agent-base "^4.3.0" + debug "^3.1.0" + +https-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" + integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA== + dependencies: + agent-base "6" + debug "4" + +https-proxy-agent@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + +human-signals@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-3.0.1.tgz#c740920859dafa50e5a3222da9d3bf4bb0e5eef5" + integrity sha512-rQLskxnM/5OCldHo+wNXbpVgDn5A17CUoKX+7Sokwaknlq7CdSnphy0W39GU8dw59XiCXmFXDg4fRuckQRKewQ== + +humanize-ms@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" + integrity sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0= + dependencies: + ms "^2.0.0" + +humanize-number@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/humanize-number/-/humanize-number-0.0.2.tgz#11c0af6a471643633588588048f1799541489c18" + integrity sha1-EcCvakcWQ2M1iFiASPF5lUFInBg= + +iconv-lite@0.4.24, iconv-lite@^0.4.4: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +iconv-lite@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +iconv-lite@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.2.tgz#ce13d1875b0c3a674bd6a04b7f76b01b1b6ded01" + integrity sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +ieee754@1.1.13, ieee754@^1.1.4: + version "1.1.13" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" + integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== + +ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + +ignore@^5.1.4: + version "5.1.8" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" + integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== + +ignore@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a" + integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ== + +import-fresh@^3.0.0, import-fresh@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66" + integrity sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= + +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + +infer-owner@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/infer-owner/-/infer-owner-1.0.4.tgz#c4cefcaa8e51051c2a40ba2ce8a3d27295af9467" + integrity sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A== + +inflation@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.0.0.tgz#8b417e47c28f925a45133d914ca1fd389107f30f" + integrity sha1-i0F+R8KPklpFEz2RTKH9OJEH8w8= + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +inherits@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= + +ini@^1.3.4, ini@~1.3.0: + version "1.3.7" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84" + integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ== + +install-artifact-from-github@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/install-artifact-from-github/-/install-artifact-from-github-1.3.1.tgz#eefaad9af35d632e5d912ad1569c1de38c3c2462" + integrity sha512-3l3Bymg2eKDsN5wQuMfgGEj2x6l5MCAv0zPL6rxHESufFVlEAKW/6oY9F1aGgvY/EgWm5+eWGRjINveL4X7Hgg== + +internal-slot@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c" + integrity sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA== + dependencies: + get-intrinsic "^1.1.0" + has "^1.0.3" + side-channel "^1.0.4" + +ioredis@4.28.5, ioredis@^4.28.5: + version "4.28.5" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-4.28.5.tgz#5c149e6a8d76a7f8fa8a504ffc85b7d5b6797f9f" + integrity sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A== + dependencies: + cluster-key-slot "^1.1.0" + debug "^4.3.1" + denque "^1.1.0" + lodash.defaults "^4.2.0" + lodash.flatten "^4.4.0" + lodash.isarguments "^3.1.0" + p-map "^2.1.0" + redis-commands "1.7.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.1.0" + +iota-array@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/iota-array/-/iota-array-1.0.0.tgz#81ef57fe5d05814cd58c2483632a99c30a0e8087" + integrity sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA== + +ip-address@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-7.1.0.tgz#4a9c699e75b51cbeb18b38de8ed216efa1a490c5" + integrity sha512-V9pWC/VJf2lsXqP7IWJ+pe3P1/HCYGBMZrrnT62niLGjAfCbeiwXMUxaeHvnVlz19O27pvXP4azs+Pj/A0x+SQ== + dependencies: + jsbn "1.1.0" + sprintf-js "1.1.2" + +ip-cidr@3.0.10: + version "3.0.10" + resolved "https://registry.yarnpkg.com/ip-cidr/-/ip-cidr-3.0.10.tgz#e1a039705196d84b43858f81a243fd70def9cefc" + integrity sha512-PXSsrRYirsuaCI1qBVyVXRLUIpNzxm76eHd3UvN5NXTMUG85GWGZpr6P+70mimc5e7Nfh/tShmjk0oSywErMWg== + dependencies: + ip-address "^7.1.0" + jsbn "^1.1.0" + +ip-regex@^4.0.0, ip-regex@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-4.3.0.tgz#687275ab0f57fa76978ff8f4dddc8a23d5990db5" + integrity sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q== + +ip@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" + integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= + +ipaddr.js@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.1.tgz#eca256a7a877e917aeb368b0a7497ddf42ef81c0" + integrity sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng== + +is-arguments@^1.0.4: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" + integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-arrayish@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" + integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== + +is-bigint@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" + integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg== + dependencies: + has-bigints "^1.0.1" + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-boolean-object@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719" + integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-buffer@^1.0.2, is-buffer@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + +is-callable@^1.1.3, is-callable@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945" + integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w== + +is-callable@^1.1.4: + version "1.1.5" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab" + integrity sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q== + +is-core-module@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.2.0.tgz#97037ef3d52224d85163f5597b2b63d9afed981a" + integrity sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ== + dependencies: + has "^1.0.3" + +is-core-module@^2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.1.tgz#f59fdfca701d5879d0a6b100a40aa1560ce27211" + integrity sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA== + dependencies: + has "^1.0.3" + +is-date-object@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e" + integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g== + +is-expression@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-expression/-/is-expression-4.0.0.tgz#c33155962abf21d0afd2552514d67d2ec16fd2ab" + integrity sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A== + dependencies: + acorn "^7.1.1" + object-assign "^4.1.1" + +is-extendable@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= + +is-fullwidth-code-point@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" + integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= + dependencies: + number-is-nan "^1.0.0" + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-generator-function@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.7.tgz#d2132e529bb0000a7f80794d4bdf5cd5e5813522" + integrity sha512-YZc5EwyO4f2kWCax7oegfuSr9mFz1ZvieNYBEjmukLxgXfBUbxAWGVF7GZf0zidYtoBl3WvC07YK0wT76a+Rtw== + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-ip@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-ip/-/is-ip-3.1.0.tgz#2ae5ddfafaf05cb8008a62093cf29734f657c5d8" + integrity sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q== + dependencies: + ip-regex "^4.0.0" + +is-lambda@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" + integrity sha1-PZh3iZ5qU+/AFgUEzeFfgubwYdU= + +is-negative-zero@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24" + integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w== + +is-negative-zero@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150" + integrity sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA== + +is-number-object@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.6.tgz#6a7aaf838c7f0686a50b4553f7e54a96494e89f0" + integrity sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g== + dependencies: + has-tostringtag "^1.0.0" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-plain-obj@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + +is-plain-object@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" + integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== + +is-potential-custom-element-name@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== + +is-promise@^2.0.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" + integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== + +is-regex@^1.0.3: + version "1.0.5" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.5.tgz#39d589a358bf18967f726967120b8fc1aed74eae" + integrity sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ== + dependencies: + has "^1.0.3" + +is-regex@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" + integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-shared-array-buffer@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz#97b0c85fbdacb59c9c446fe653b82cf2b5b7cfe6" + integrity sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA== + +is-shared-array-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz#8f259c573b60b6a32d4058a1a07430c0a7344c79" + integrity sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA== + dependencies: + call-bind "^1.0.2" + +is-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-3.0.0.tgz#e6bfd7aa6bef69f4f472ce9bb681e3e57b4319ac" + integrity sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA== + +is-string@^1.0.5, is-string@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" + integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg== + dependencies: + has-tostringtag "^1.0.0" + +is-svg@4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/is-svg/-/is-svg-4.3.2.tgz#a119e9932e1af53f6be1969d1790d6cc5fd947d3" + integrity sha512-mM90duy00JGMyjqIVHu9gNTjywdZV+8qNasX8cm/EEYZ53PHDgajvbBwNVvty5dwSAxLUD3p3bdo+7sR/UMrpw== + dependencies: + fast-xml-parser "^3.19.0" + +is-symbol@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937" + integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ== + dependencies: + has-symbols "^1.0.1" + +is-symbol@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" + integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg== + dependencies: + has-symbols "^1.0.2" + +is-typed-array@^1.1.3, is-typed-array@^1.1.9: + version "1.1.9" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.9.tgz#246d77d2871e7d9f5aeb1d54b9f52c71329ece67" + integrity sha512-kfrlnTTn8pZkfpJMUgYD7YZ3qzeJgWUn8XfVYBARc4wnmNOmLbmuuaAs3q5fvB0UJOn6yHAKaGTPM7d6ezoD/A== + dependencies: + available-typed-arrays "^1.0.5" + call-bind "^1.0.2" + es-abstract "^1.20.0" + for-each "^0.3.3" + has-tostringtag "^1.0.0" + +is-typedarray@^1.0.0, is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== + +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + +is-weakref@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.1.tgz#842dba4ec17fa9ac9850df2d6efbc1737274f2a2" + integrity sha512-b2jKc2pQZjaeFYWEf7ScFj+Be1I+PXmlu572Q8coTXZ+LD/QQZ7ShPMst8h16riVgyXTQwUsFEl74mDvc/3MHQ== + dependencies: + call-bind "^1.0.0" + +is-weakref@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" + integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ== + dependencies: + call-bind "^1.0.2" + +is-whitespace@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/is-whitespace/-/is-whitespace-0.3.0.tgz#1639ecb1be036aec69a54cbb401cfbed7114ab7f" + integrity sha1-Fjnssb4DauxppUy7QBz77XEUq38= + +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= + +isarray@^1.0.0, isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + +isstream@~0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g== + +jake@^10.8.5: + version "10.8.5" + resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.5.tgz#f2183d2c59382cb274226034543b9c03b8164c46" + integrity sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw== + dependencies: + async "^3.2.3" + chalk "^4.0.2" + filelist "^1.0.1" + minimatch "^3.0.4" + +jmespath@0.16.0: + version "0.16.0" + resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.16.0.tgz#b15b0a85dfd4d930d43e69ed605943c802785076" + integrity sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw== + +jpeg-js@^0.3.2: + version "0.3.7" + resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.3.7.tgz#471a89d06011640592d314158608690172b1028d" + integrity sha512-9IXdWudL61npZjvLuVe/ktHiA41iE8qFyLB+4VDTblEsWBzeg8WQTlktdUK4CdncUqtUgUg0bbOmTE2bKBKaBQ== + +jpeg-js@^0.4.1: + version "0.4.4" + resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.4.4.tgz#a9f1c6f1f9f0fa80cdb3484ed9635054d28936aa" + integrity sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg== + +js-beautify@^1.6.12: + version "1.11.0" + resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.11.0.tgz#afb873dc47d58986360093dcb69951e8bcd5ded2" + integrity sha512-a26B+Cx7USQGSWnz9YxgJNMmML/QG2nqIaL7VVYPCXbqiKz8PN0waSNvroMtvAK6tY7g/wPdNWGEP+JTNIBr6A== + dependencies: + config-chain "^1.1.12" + editorconfig "^0.15.3" + glob "^7.1.3" + mkdirp "~1.0.3" + nopt "^4.0.3" + +js-levenshtein@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d" + integrity sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g== + +js-stringify@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/js-stringify/-/js-stringify-1.0.2.tgz#1736fddfd9724f28a3682adc6230ae7e4e9679db" + integrity sha1-Fzb939lyTyijaCrcYjCufk6Weds= + +js-yaml@4.1.0, js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +jsbn@1.1.0, jsbn@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040" + integrity sha1-sBMHyym2GKHtJux56RH4A8TaAEA= + +jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= + +jschardet@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/jschardet/-/jschardet-3.0.0.tgz#898d2332e45ebabbdb6bf2feece9feea9a99e882" + integrity sha512-lJH6tJ77V8Nzd5QWRkFYCLc13a3vADkh3r/Fi8HupZGWk2OVVDfnZP8V/VgQgZ+lzW0kG2UGb5hFgt3V3ndotQ== + +jsdom@20.0.0: + version "20.0.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-20.0.0.tgz#882825ac9cc5e5bbee704ba16143e1fa78361ebf" + integrity sha512-x4a6CKCgx00uCmP+QakBDFXwjAJ69IkkIWHmtmjd3wvXPcdOS44hfX2vqkOQrVrq8l9DhNNADZRXaCEWvgXtVA== + dependencies: + abab "^2.0.6" + acorn "^8.7.1" + acorn-globals "^6.0.0" + cssom "^0.5.0" + cssstyle "^2.3.0" + data-urls "^3.0.2" + decimal.js "^10.3.1" + domexception "^4.0.0" + escodegen "^2.0.0" + form-data "^4.0.0" + html-encoding-sniffer "^3.0.0" + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.1" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.0" + parse5 "^7.0.0" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^4.0.0" + w3c-hr-time "^1.0.2" + w3c-xmlserializer "^3.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^2.0.0" + whatwg-mimetype "^3.0.0" + whatwg-url "^11.0.0" + ws "^8.8.0" + xml-name-validator "^4.0.0" + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +json-schema@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" + integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= + +json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== + +json5-loader@4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/json5-loader/-/json5-loader-4.0.1.tgz#6d17a1181e8f3c3d9204dca2a4ce4627306c8498" + integrity sha512-c9viNZlZTz0MTIcf/4qvek5Dz1/PU3DNCB4PwUhlEZIV3qb1bSD6vQQymlV17/Wm6ncra1aCvmIPsuRj+KfEEg== + dependencies: + json5 "^2.1.3" + loader-utils "^2.0.0" + schema-utils "^3.0.0" + +json5@2.2.1, json5@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c" + integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA== + +json5@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" + integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow== + dependencies: + minimist "^1.2.0" + +json5@^2.1.2, json5@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" + integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA== + dependencies: + minimist "^1.2.5" + +jsonfile@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" + integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= + optionalDependencies: + graceful-fs "^4.1.6" + +jsonfile@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-5.0.0.tgz#e6b718f73da420d612823996fdf14a03f6ff6922" + integrity sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w== + dependencies: + universalify "^0.1.2" + optionalDependencies: + graceful-fs "^4.1.6" + +jsonld@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/jsonld/-/jsonld-6.0.0.tgz#560a8a871dce72aba5d4c6b08356438d863d62fb" + integrity sha512-1SkN2RXhMCTCSkX+bzHvr9ycM2HTmjWyV41hn2xG7k6BqlCgRjw0zHmuqfphjBRPqi1gKMIqgBCe/0RZMcWrAA== + dependencies: + "@digitalbazaar/http-client" "^3.2.0" + canonicalize "^1.0.1" + lru-cache "^6.0.0" + rdf-canonize "^3.0.0" + +jsprim@^1.2.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.2.tgz#712c65533a15c878ba59e9ed5f0e26d5b77c5feb" + integrity sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw== + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.4.0" + verror "1.10.0" + +jsrsasign@10.5.27: + version "10.5.27" + resolved "https://registry.yarnpkg.com/jsrsasign/-/jsrsasign-10.5.27.tgz#481defb1206aa48cd740c3fce8ff546efb5bb45e" + integrity sha512-1F4LmDeJZHYwoVvB44jEo2uZL3XuwYNzXCDOu53Ui6vqofGQ/gCYDmaxfVZtN0TGd92UKXr/BONcfrPonUIcQQ== + +jstransformer@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/jstransformer/-/jstransformer-1.0.0.tgz#ed8bf0921e2f3f1ed4d5c1a44f68709ed24722c3" + integrity sha1-7Yvwkh4vPx7U1cGkT2hwntJHIsM= + dependencies: + is-promise "^2.0.0" + promise "^7.0.1" + +jwa@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc" + integrity sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4" + integrity sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg== + dependencies: + jwa "^2.0.0" + safe-buffer "^5.0.1" + +keygrip@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226" + integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ== + dependencies: + tsscmp "1.0.6" + +keyv@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.0.3.tgz#4f3aa98de254803cafcd2896734108daa35e4254" + integrity sha512-zdGa2TOpSZPq5mU6iowDARnMBZgtCqJ11dJROFi6tg6kTn4nuUdU09lFyLFSaHrWqpIJ+EBq4E8/Dc0Vx5vLdA== + dependencies: + json-buffer "3.0.1" + +kind-of@^3.0.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= + dependencies: + is-buffer "^1.1.5" + +koa-bodyparser@4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/koa-bodyparser/-/koa-bodyparser-4.3.0.tgz#274c778555ff48fa221ee7f36a9fbdbace22759a" + integrity sha512-uyV8G29KAGwZc4q/0WUAjH+Tsmuv9ImfBUF2oZVyZtaeo0husInagyn/JH85xMSxM0hEk/mbCII5ubLDuqW/Rw== + dependencies: + co-body "^6.0.0" + copy-to "^2.0.1" + +koa-compose@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877" + integrity sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw== + +koa-convert@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-2.0.0.tgz#86a0c44d81d40551bae22fee6709904573eea4f5" + integrity sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA== + dependencies: + co "^4.6.0" + koa-compose "^4.1.0" + +koa-favicon@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/koa-favicon/-/koa-favicon-2.1.0.tgz#c430cc594614fb494adcb5ee1196a2f7f53ea442" + integrity sha512-LvukcooYjxKtnZq0RXdBup+JDhaHwLgnLlDHB/xvjwQEjbc4rbp/0WkmOzpOvaHujc+fIwPear0dpKX1V+dHVg== + dependencies: + mz "^2.7.0" + +koa-json-body@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/koa-json-body/-/koa-json-body-5.3.0.tgz#64aad3f400adfb81df54b63f7a5eb38bad62d980" + integrity sha1-ZKrT9ACt+4HfVLY/el6zi61i2YA= + dependencies: + co-body "^5.0.0" + +koa-logger@3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/koa-logger/-/koa-logger-3.2.1.tgz#ab9db879526db3837cc9ce4fd983c025b1689f22" + integrity sha512-MjlznhLLKy9+kG8nAXKJLM0/ClsQp/Or2vI3a5rbSQmgl8IJBQO0KI5FA70BvW+hqjtxjp49SpH2E7okS6NmHg== + dependencies: + bytes "^3.1.0" + chalk "^2.4.2" + humanize-number "0.0.2" + passthrough-counter "^1.0.0" + +koa-mount@4.0.0, koa-mount@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/koa-mount/-/koa-mount-4.0.0.tgz#e0265e58198e1a14ef889514c607254ff386329c" + integrity sha512-rm71jaA/P+6HeCpoRhmCv8KVBIi0tfGuO/dMKicbQnQW/YJntJ6MnnspkodoA4QstMVEZArsCphmd0bJEtoMjQ== + dependencies: + debug "^4.0.1" + koa-compose "^4.1.0" + +koa-router@^10.0.0: + version "10.1.1" + resolved "https://registry.yarnpkg.com/koa-router/-/koa-router-10.1.1.tgz#20809f82648518b84726cd445037813cd99f17ff" + integrity sha512-z/OzxVjf5NyuNO3t9nJpx7e1oR3FSBAauiwXtMQu4ppcnuNZzTaQ4p21P8A6r2Es8uJJM339oc4oVW+qX7SqnQ== + dependencies: + debug "^4.1.1" + http-errors "^1.7.3" + koa-compose "^4.1.0" + methods "^1.1.2" + path-to-regexp "^6.1.0" + +koa-send@5.0.1, koa-send@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/koa-send/-/koa-send-5.0.1.tgz#39dceebfafb395d0d60beaffba3a70b4f543fe79" + integrity sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ== + dependencies: + debug "^4.1.1" + http-errors "^1.7.3" + resolve-path "^1.4.0" + +koa-slow@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/koa-slow/-/koa-slow-2.1.0.tgz#39007ca628c620f2b307b90dbf423d7a0c9be971" + integrity sha1-OQB8pijGIPKzB7kNv0I9egyb6XE= + dependencies: + lodash.isregexp "3.0.5" + q "1.4.1" + +koa-static@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/koa-static/-/koa-static-5.0.0.tgz#5e92fc96b537ad5219f425319c95b64772776943" + integrity sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ== + dependencies: + debug "^3.1.0" + koa-send "^5.0.0" + +koa-views@*: + version "7.0.1" + resolved "https://registry.yarnpkg.com/koa-views/-/koa-views-7.0.1.tgz#0c8f8e65d5cd2e08249430cb83dc361e49a17a5a" + integrity sha512-yS8751DXHXXDbdl/oUZd0PsgnxR0MLiguu77Eqrgu6yawE9Hi99wNKiVENb0Kfgsmvq/8px7YCI+USgxaTB1LA== + dependencies: + "@types/koa" "^2.13.1" + consolidate "^0.16.0" + debug "^4.1.0" + get-paths "0.0.7" + koa-send "^5.0.0" + mz "^2.4.0" + pretty "^2.0.0" + resolve-path "^1.4.0" + +koa-views@7.0.2, koa-views@^7.0.1: + version "7.0.2" + resolved "https://registry.yarnpkg.com/koa-views/-/koa-views-7.0.2.tgz#c96fd9e2143ef00c29dc5160c5ed639891aa723d" + integrity sha512-dvx3mdVeSVuIPEaKAoGbxLcenudvhl821xxyuRbcoA+bOJ2dvN8wlGjkLu0ZFMlkCscXZV6lzxy28rafeazI/w== + dependencies: + consolidate "^0.16.0" + debug "^4.1.0" + get-paths "0.0.7" + koa-send "^5.0.0" + mz "^2.4.0" + pretty "^2.0.0" + resolve-path "^1.4.0" + +koa@2.13.4, koa@^2.13.1: + version "2.13.4" + resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.4.tgz#ee5b0cb39e0b8069c38d115139c774833d32462e" + integrity sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g== + dependencies: + accepts "^1.3.5" + cache-content-type "^1.0.0" + content-disposition "~0.5.2" + content-type "^1.0.4" + cookies "~0.8.0" + debug "^4.3.2" + delegates "^1.0.0" + depd "^2.0.0" + destroy "^1.0.4" + encodeurl "^1.0.2" + escape-html "^1.0.3" + fresh "~0.5.2" + http-assert "^1.3.0" + http-errors "^1.6.3" + is-generator-function "^1.0.7" + koa-compose "^4.1.0" + koa-convert "^2.0.0" + on-finished "^2.3.0" + only "~0.0.2" + parseurl "^1.3.2" + statuses "^1.5.0" + type-is "^1.6.16" + vary "^1.1.2" + +ky-universal@^0.10.1: + version "0.10.1" + resolved "https://registry.yarnpkg.com/ky-universal/-/ky-universal-0.10.1.tgz#778881e098f6e3c52a87b382d9acca54d22bb0d3" + integrity sha512-r8909k+ELKZAxhVA5c440x22hqw5XcMRwLRbgpPQk4JHy3/ddJnvzcnSo5Ww3HdKdNeS3Y8dBgcIYyVahMa46g== + dependencies: + abort-controller "^3.0.0" + node-fetch "^3.2.2" + +ky@^0.30.0: + version "0.30.0" + resolved "https://registry.yarnpkg.com/ky/-/ky-0.30.0.tgz#a3d293e4f6c4604a9a4694eceb6ce30e73d27d64" + integrity sha512-X/u76z4JtDVq10u1JA5UQfatPxgPaVDMYTrgHyiTpGN2z4TMEJkIHsoSBBSg9SWZEIXTKsi9kHgiQ9o3Y/4yog== + +lazystream@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.1.tgz#494c831062f1f9408251ec44db1cba29242a2638" + integrity sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw== + dependencies: + readable-stream "^2.0.5" + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +levn@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" + integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= + dependencies: + prelude-ls "~1.1.2" + type-check "~0.3.2" + +listenercount@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/listenercount/-/listenercount-1.0.1.tgz#84c8a72ab59c4725321480c975e6508342e70937" + integrity sha1-hMinKrWcRyUyFIDJdeZQg0LnCTc= + +loader-utils@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.0.tgz#e4cace5b816d425a166b5f097e10cd12b36064b0" + integrity sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ== + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^2.1.2" + +locate-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" + integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4= + dependencies: + p-locate "^2.0.0" + path-exists "^3.0.0" + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.assignin@^4.0.9: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.assignin/-/lodash.assignin-4.2.0.tgz#ba8df5fb841eb0a3e8044232b0e263a8dc6a28a2" + integrity sha1-uo31+4QesKPoBEIysOJjqNxqKKI= + +lodash.bind@^4.1.4: + version "4.2.1" + resolved "https://registry.yarnpkg.com/lodash.bind/-/lodash.bind-4.2.1.tgz#7ae3017e939622ac31b7d7d7dcb1b34db1690d35" + integrity sha1-euMBfpOWIqwxt9fX3LGzTbFpDTU= + +lodash.defaults@^4.0.1, lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw= + +lodash.difference@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.difference/-/lodash.difference-4.5.0.tgz#9ccb4e505d486b91651345772885a2df27fd017c" + integrity sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw= + +lodash.filter@^4.4.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.filter/-/lodash.filter-4.6.0.tgz#668b1d4981603ae1cc5a6fa760143e480b4c4ace" + integrity sha1-ZosdSYFgOuHMWm+nYBQ+SAtMSs4= + +lodash.flatten@^4.2.0, lodash.flatten@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" + integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8= + +lodash.foreach@^4.3.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53" + integrity sha1-Gmo16s5AEoDH8G3d7DUWWrJ+PlM= + +lodash.isarguments@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" + integrity sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo= + +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= + +lodash.isregexp@3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/lodash.isregexp/-/lodash.isregexp-3.0.5.tgz#e0f596242f2fa228a840086b6c8ad82e4b71fd2d" + integrity sha1-4PWWJC8voiioQAhrbIrYLktx/S0= + +lodash.map@^4.4.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.map/-/lodash.map-4.6.0.tgz#771ec7839e3473d9c4cde28b19394c3562f4f6d3" + integrity sha1-dx7Hg540c9nEzeKLGTlMNWL09tM= + +lodash.merge@^4.4.0, lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash.pick@^4.2.1: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3" + integrity sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM= + +lodash.reduce@^4.4.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.reduce/-/lodash.reduce-4.6.0.tgz#f1ab6b839299ad48f784abbf476596f03b914d3b" + integrity sha1-8atrg5KZrUj3hKu/R2WW8DuRTTs= + +lodash.reject@^4.4.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.reject/-/lodash.reject-4.6.0.tgz#80d6492dc1470864bbf583533b651f42a9f52415" + integrity sha1-gNZJLcFHCGS79YNTO2UfQqn1JBU= + +lodash.some@^4.4.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.some/-/lodash.some-4.6.0.tgz#1bb9f314ef6b8baded13b549169b2a945eb68e4d" + integrity sha1-G7nzFO9ri63tE7VJFpsqlF62jk0= + +lodash.union@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88" + integrity sha1-SLtQiECfFvGCFmZkHETdGqrjzYg= + +lodash@^4.17.11, lodash@^4.17.19, lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +log-symbols@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + +long@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" + integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== + +lowercase-keys@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" + integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== + +lowercase-keys@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-3.0.0.tgz#c5e7d442e37ead247ae9db117a9d0a467c89d4f2" + integrity sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ== + +lru-cache@^4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" + integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== + dependencies: + pseudomap "^1.0.2" + yallist "^2.1.2" + +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + +lru-cache@^7.7.1: + version "7.12.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.12.0.tgz#be2649a992c8a9116efda5c487538dcf715f3476" + integrity sha512-OIP3DwzRZDfLg9B9VP/huWBlpvbkmbfiBy8xmsXp4RPmE4A3MhwNozc5ZJ3fWnSg8fDcdlE/neRTPG2ycEKliw== + +luxon@^1.28.0: + version "1.28.0" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-1.28.0.tgz#e7f96daad3938c06a62de0fb027115d251251fbf" + integrity sha512-TfTiyvZhwBYM/7QdAVDh+7dBTBA29v4ik0Ce9zda3Mnf8on1S5KJI8P2jKFZ8+5C0jhmr0KwJEO/Wdpm0VeWJQ== + +mailcheck@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/mailcheck/-/mailcheck-1.1.1.tgz#d87cf6ba0b64ba512199dbf93f1489f479591e34" + integrity sha1-2Hz2ugtkulEhmdv5PxSJ9HlZHjQ= + +make-dir@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" + integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + dependencies: + semver "^6.0.0" + +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + +make-fetch-happen@^10.0.3: + version "10.1.8" + resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-10.1.8.tgz#3b6e93dd8d8fdb76c0d7bf32e617f37c3108435a" + integrity sha512-0ASJbG12Au6+N5I84W+8FhGS6iM8MyzvZady+zaQAu+6IOaESFzCLLD0AR1sAFF3Jufi8bxm586ABN6hWd3k7g== + dependencies: + agentkeepalive "^4.2.1" + cacache "^16.1.0" + http-cache-semantics "^4.1.0" + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.0" + is-lambda "^1.0.1" + lru-cache "^7.7.1" + minipass "^3.1.6" + minipass-collect "^1.0.2" + minipass-fetch "^2.0.3" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + negotiator "^0.6.3" + promise-retry "^2.0.1" + socks-proxy-agent "^7.0.0" + ssri "^9.0.0" + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +merge2@^1.3.0, merge2@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +methods@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= + +mfm-js@0.23.0: + version "0.23.0" + resolved "https://registry.yarnpkg.com/mfm-js/-/mfm-js-0.23.0.tgz#1d1477761aa8259ddcac2e6882df53ed9ca5b82b" + integrity sha512-2Oe/YicoaP1EU2y9JB5729/PQLZK/7aAVomeJkp1h4XGP2//NMDC+DHkBbSO71U3GG086SAZM0JBB/hdPPSEXg== + dependencies: + twemoji-parser "14.0.0" + +micromatch@^4.0.0, micromatch@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" + integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q== + dependencies: + braces "^3.0.1" + picomatch "^2.0.5" + +micromatch@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" + integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== + dependencies: + braces "^3.0.2" + picomatch "^2.3.1" + +mime-db@1.44.0: + version "1.44.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" + integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@2.1.35, mime-types@^2.0.1, mime-types@~2.1.19: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime-types@^2.1.12, mime-types@^2.1.18, mime-types@~2.1.24: + version "2.1.27" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" + integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w== + dependencies: + mime-db "1.44.0" + +mimic-fn@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc" + integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw== + +mimic-response@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" + integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== + +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + +minimalistic-assert@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + +minimatch@5.0.1, minimatch@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.0.1.tgz#fb9022f7528125187c92bd9e9b6366be1cf3415b" + integrity sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^3.0.4, minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== + +minipass-collect@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617" + integrity sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA== + dependencies: + minipass "^3.0.0" + +minipass-fetch@^2.0.3: + version "2.1.0" + resolved "https://registry.yarnpkg.com/minipass-fetch/-/minipass-fetch-2.1.0.tgz#ca1754a5f857a3be99a9271277246ac0b44c3ff8" + integrity sha512-H9U4UVBGXEyyWJnqYDCLp1PwD8XIkJ4akNHp1aGVI+2Ym7wQMlxDKi4IB4JbmyU+pl9pEs/cVrK6cOuvmbK4Sg== + dependencies: + minipass "^3.1.6" + minipass-sized "^1.0.3" + minizlib "^2.1.2" + optionalDependencies: + encoding "^0.1.13" + +minipass-flush@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/minipass-flush/-/minipass-flush-1.0.5.tgz#82e7135d7e89a50ffe64610a787953c4c4cbb373" + integrity sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw== + dependencies: + minipass "^3.0.0" + +minipass-pipeline@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz#68472f79711c084657c067c5c6ad93cddea8214c" + integrity sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A== + dependencies: + minipass "^3.0.0" + +minipass-sized@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/minipass-sized/-/minipass-sized-1.0.3.tgz#70ee5a7c5052070afacfbc22977ea79def353b70" + integrity sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g== + dependencies: + minipass "^3.0.0" + +minipass@^2.6.0, minipass@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6" + integrity sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg== + dependencies: + safe-buffer "^5.1.2" + yallist "^3.0.0" + +minipass@^3.0.0, minipass@^3.1.1: + version "3.1.6" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.6.tgz#3b8150aa688a711a1521af5e8779c1d3bb4f45ee" + integrity sha512-rty5kpw9/z8SX9dmxblFA6edItUmwJgMeYDZRrwlIVN27i8gysGbznJwUggw2V/FVqFSDdWy040ZPS811DYAqQ== + dependencies: + yallist "^4.0.0" + +minipass@^3.1.6: + version "3.3.4" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.4.tgz#ca99f95dd77c43c7a76bf51e6d200025eee0ffae" + integrity sha512-I9WPbWHCGu8W+6k1ZiGpPu0GkoKBeorkfKNuAFBNS1HNFJvke82sxvI5bzcCNpWPorkOO5QQ+zomzzwRxejXiw== + dependencies: + yallist "^4.0.0" + +minizlib@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.3.3.tgz#2290de96818a34c29551c8a8d301216bd65a861d" + integrity sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q== + dependencies: + minipass "^2.9.0" + +minizlib@^2.1.1, minizlib@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" + integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== + dependencies: + minipass "^3.0.0" + yallist "^4.0.0" + +misskey-js@0.0.14: + version "0.0.14" + resolved "https://registry.yarnpkg.com/misskey-js/-/misskey-js-0.0.14.tgz#1a616bdfbe81c6ee6900219eaf425bb5c714dd4d" + integrity sha512-bvLx6U3OwQwqHfp/WKwIVwdvNYAAPk0+YblXyxmSG3dwlzCgBRRLcB8o6bNruUDyJgh3t73pLDcOz3myxcUmww== + dependencies: + autobind-decorator "^2.4.0" + eventemitter3 "^4.0.7" + reconnecting-websocket "^4.4.0" + +mkdirp-classic@^0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.2.tgz#54c441ce4c96cd7790e10b41a87aa51068ecab2b" + integrity sha512-ejdnDQcR75gwknmMw/tx02AuRs8jCtqFoFqDZMjiNxsu85sRIJVXDKHuLYvUUPRBUtV2FpSZa9bL1BUa3BdR2g== + +mkdirp-classic@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + +"mkdirp@>=0.5 0", mkdirp@^0.5.4: + version "0.5.5" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" + integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== + dependencies: + minimist "^1.2.5" + +mkdirp@^0.5.5: + version "0.5.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + +mkdirp@^1.0.3, mkdirp@^1.0.4, mkdirp@~1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + +mocha@10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.0.0.tgz#205447d8993ec755335c4b13deba3d3a13c4def9" + integrity sha512-0Wl+elVUD43Y0BqPZBzZt8Tnkw9CMUdNYnUsTfOM1vuhJVZL+kiesFYsqwBkEEuEixaiPe5ZQdqDgX2jddhmoA== + dependencies: + "@ungap/promise-all-settled" "1.1.2" + ansi-colors "4.1.1" + browser-stdout "1.3.1" + chokidar "3.5.3" + debug "4.3.4" + diff "5.0.0" + escape-string-regexp "4.0.0" + find-up "5.0.0" + glob "7.2.0" + he "1.2.0" + js-yaml "4.1.0" + log-symbols "4.1.0" + minimatch "5.0.1" + ms "2.1.3" + nanoid "3.3.3" + serialize-javascript "6.0.0" + strip-json-comments "3.1.1" + supports-color "8.1.1" + workerpool "6.2.1" + yargs "16.2.0" + yargs-parser "20.2.4" + yargs-unparser "2.0.0" + +moment@^2.22.2: + version "2.29.4" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" + integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@2.1.3, ms@^2.0.0, ms@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +ms@3.0.0-canary.1: + version "3.0.0-canary.1" + resolved "https://registry.yarnpkg.com/ms/-/ms-3.0.0-canary.1.tgz#c7b34fbce381492fd0b345d1cf56e14d67b77b80" + integrity sha512-kh8ARjh8rMN7Du2igDRO9QJnqCb2xYTJxyQYK7vJJS4TvLLmsbyhiKpSW+t+y26gyOyMd0riphX0GeWKU3ky5g== + +msgpackr-extract@^1.0.14: + version "1.0.16" + resolved "https://registry.yarnpkg.com/msgpackr-extract/-/msgpackr-extract-1.0.16.tgz#701c4f6e6f25c100ae84557092274e8fffeefe45" + integrity sha512-fxdRfQUxPrL/TizyfYfMn09dK58e+d65bRD/fcaVH4052vj30QOzzqxcQIS7B0NsqlypEQ/6Du3QmP2DhWFfCA== + dependencies: + nan "^2.14.2" + node-gyp-build "^4.2.3" + +msgpackr@^1.5.2: + version "1.5.4" + resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.5.4.tgz#2b6ea6cb7d79c0ad98fc76c68163c48eda50cf0d" + integrity sha512-Z7w5Jg+2Q9z9gJxeM68d7tSuWZZGnFIRhZnyqcZCa/1dKkhOCNvR1TUV3zzJ3+vj78vlwKRzUgVDlW4jiSOeDA== + optionalDependencies: + msgpackr-extract "^1.0.14" + +multer@1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.4.tgz#e2bc6cac0df57a8832b858d7418ccaa8ebaf7d8c" + integrity sha512-2wY2+xD4udX612aMqMcB8Ws2Voq6NIUPEtD1be6m411T4uDH/VtL9i//xvcyFlTVfRdaBsk7hV5tgrGQqhuBiw== + dependencies: + append-field "^1.0.0" + busboy "^0.2.11" + concat-stream "^1.5.2" + mkdirp "^0.5.4" + object-assign "^4.1.1" + on-finished "^2.3.0" + type-is "^1.6.4" + xtend "^4.0.0" + +multi-integer-range@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/multi-integer-range/-/multi-integer-range-3.0.0.tgz#d8ec2744d08758f2acf81653d2fe038a95cf7595" + integrity sha512-uQzynjVJ8F7x5wjaK0g4Ybhy2TvO/pk96+YHyS5g1W4GuUEV6HMebZ8HcRwWgKIRCUT2MLbM5uCKwYcAqkS+8Q== + +mylas@^2.1.9: + version "2.1.9" + resolved "https://registry.yarnpkg.com/mylas/-/mylas-2.1.9.tgz#8329626f95c0ce522ca7d3c192eca6221d172cdc" + integrity sha512-pa+cQvmhoM8zzgitPYZErmDt9EdTNVnXsH1XFjMeM4TyG4FFcgxrvK1+jwabVFwUOEDaSWuXBMjg43kqt/Ydlg== + +mz@^2.4.0, mz@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" + integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== + dependencies: + any-promise "^1.0.0" + object-assign "^4.0.1" + thenify-all "^1.0.0" + +nan@^2.14.2: + version "2.15.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee" + integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ== + +nan@^2.16.0: + version "2.16.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.16.0.tgz#664f43e45460fb98faf00edca0bb0d7b8dce7916" + integrity sha512-UdAqHyFngu7TfQKsCBgAA6pWDkT8MAO7d0jyOecVhN5354xbLqdn8mV9Tat9gepAupm0bt2DbeaSC8vS52MuFA== + +nanoid@3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.3.tgz#fd8e8b7aa761fe807dba2d1b98fb7241bb724a25" + integrity sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w== + +nanoid@^3.1.30: + version "3.3.1" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.1.tgz#6347a18cac88af88f58af0b3594b723d5e99bb35" + integrity sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw== + +napi-build-utils@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" + integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= + +ndarray-ops@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/ndarray-ops/-/ndarray-ops-1.2.2.tgz#59e88d2c32a7eebcb1bc690fae141579557a614e" + integrity sha512-BppWAFRjMYF7N/r6Ie51q6D4fs0iiGmeXIACKY66fLpnwIui3Wc3CXiD/30mgLbDjPpSLrsqcp3Z62+IcHZsDw== + dependencies: + cwise-compiler "^1.0.0" + +ndarray-pack@^1.1.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ndarray-pack/-/ndarray-pack-1.2.1.tgz#8caebeaaa24d5ecf70ff86020637977da8ee585a" + integrity sha512-51cECUJMT0rUZNQa09EoKsnFeDL4x2dHRT0VR5U2H5ZgEcm95ZDWcMA5JShroXjHOejmAD/fg8+H+OvUnVXz2g== + dependencies: + cwise-compiler "^1.1.2" + ndarray "^1.0.13" + +ndarray@1.0.18: + version "1.0.18" + resolved "https://registry.yarnpkg.com/ndarray/-/ndarray-1.0.18.tgz#b60d3a73224ec555d0faa79711e502448fd3f793" + integrity sha512-jUz6G+CIsEsqs2VlB1EvaQSAA0Jkf8YKm7eFBleKyhiQjYWzTxXqHzWEOm3jFoGCpxGh4DnPUYHB4ECWE+n9SQ== + dependencies: + iota-array "^1.0.0" + is-buffer "^1.0.2" + +ndarray@^1.0.13: + version "1.0.19" + resolved "https://registry.yarnpkg.com/ndarray/-/ndarray-1.0.19.tgz#6785b5f5dfa58b83e31ae5b2a058cfd1ab3f694e" + integrity sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ== + dependencies: + iota-array "^1.0.0" + is-buffer "^1.0.2" + +needle@^2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/needle/-/needle-2.5.2.tgz#cf1a8fce382b5a280108bba90a14993c00e4010a" + integrity sha512-LbRIwS9BfkPvNwNHlsA41Q29kL2L/6VaOJ0qisM5lLWsTV3nP15abO5ITL6L81zqFhzjRKDAYjpcBcwM0AVvLQ== + dependencies: + debug "^3.2.6" + iconv-lite "^0.4.4" + sax "^1.2.4" + +negotiator@0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" + integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== + +negotiator@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +nested-property@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/nested-property/-/nested-property-4.0.0.tgz#a67b5a31991e701e03cdbaa6453bc5b1011bb88d" + integrity sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA== + +netmask@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/netmask/-/netmask-2.0.2.tgz#8b01a07644065d536383835823bc52004ebac5e7" + integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg== + +next-tick@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" + integrity sha1-yobR/ogoFpsBICCOPchCS524NCw= + +node-abi@^3.3.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.5.0.tgz#26e8b7b251c3260a5ac5ba5aef3b4345a0229248" + integrity sha512-LtHvNIBgOy5mO8mPEUtkCW/YCRWYEKshIvqhe1GHHyXEHEB5mgICyYnAcl4qan3uFeRROErKGzatFHPf6kDxWw== + dependencies: + semver "^7.3.5" + +node-addon-api@^4.2.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" + integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== + +node-bitmap@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/node-bitmap/-/node-bitmap-0.0.1.tgz#180eac7003e0c707618ef31368f62f84b2a69091" + integrity sha512-Jx5lPaaLdIaOsj2mVLWMWulXF6GQVdyLvNSxmiYCvZ8Ma2hfKX0POoR2kgKOqz+oFsRreq0yYZjQ2wjE9VNzCA== + +node-domexception@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + +node-fetch@*: + version "3.2.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.2.0.tgz#59390db4e489184fa35d4b74caf5510e8dfbaf3b" + integrity sha512-8xeimMwMItMw8hRrOl3C9/xzU49HV/yE6ORew/l+dxWimO5A4Ra8ld2rerlJvc/O7et5Z1zrWsPX43v1QBjCxw== + dependencies: + data-uri-to-buffer "^4.0.0" + fetch-blob "^3.1.4" + formdata-polyfill "^4.0.10" + +node-fetch@3.2.10: + version "3.2.10" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.2.10.tgz#e8347f94b54ae18b57c9c049ef641cef398a85c8" + integrity sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA== + dependencies: + data-uri-to-buffer "^4.0.0" + fetch-blob "^3.1.4" + formdata-polyfill "^4.0.10" + +node-fetch@^2.6.1, node-fetch@^2.6.7, node-fetch@~2.6.1: + version "2.6.7" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== + dependencies: + whatwg-url "^5.0.0" + +node-fetch@^3.2.2: + version "3.2.6" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.2.6.tgz#6d4627181697a9d9674aae0d61548e0d629b31b9" + integrity sha512-LAy/HZnLADOVkVPubaxHDft29booGglPFDr2Hw0J1AercRh01UiVFm++KMDnJeH9sHgNB4hsXPii7Sgym/sTbw== + dependencies: + data-uri-to-buffer "^4.0.0" + fetch-blob "^3.1.4" + formdata-polyfill "^4.0.10" + +node-gyp-build@^4.2.3: + version "4.3.0" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.3.0.tgz#9f256b03e5826150be39c764bf51e993946d71a3" + integrity sha512-iWjXZvmboq0ja1pUGULQBexmxq8CV4xBhX7VDOTbL7ZR4FOowwY/VOtRxBN/yKxmdGoIp4j5ysNT4u3S2pDQ3Q== + +node-gyp-build@~3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-3.7.0.tgz#daa77a4f547b9aed3e2aac779eaf151afd60ec8d" + integrity sha512-L/Eg02Epx6Si2NXmedx+Okg+4UHqmaf3TNcxd50SF9NQGcJaON3AtU++kax69XV7YWz4tUspqZSAsVofhFKG2w== + +node-gyp@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-9.0.0.tgz#e1da2067427f3eb5bb56820cb62bc6b1e4bd2089" + integrity sha512-Ma6p4s+XCTPxCuAMrOA/IJRmVy16R8Sdhtwl4PrCr7IBlj4cPawF0vg/l7nOT1jPbuNS7lIRJpBSvVsXwEZuzw== + dependencies: + env-paths "^2.2.0" + glob "^7.1.4" + graceful-fs "^4.2.6" + make-fetch-happen "^10.0.3" + nopt "^5.0.0" + npmlog "^6.0.0" + rimraf "^3.0.2" + semver "^7.3.5" + tar "^6.1.2" + which "^2.0.2" + +nodemailer@6.7.8: + version "6.7.8" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.8.tgz#9f1af9911314960c0b889079e1754e8d9e3f740a" + integrity sha512-2zaTFGqZixVmTxpJRCFC+Vk5eGRd/fYtvIR+dl5u9QXLTQWGIf48x/JXvo58g9sa0bU6To04XUv554Paykum3g== + +nofilter@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/nofilter/-/nofilter-2.0.3.tgz#f5460f3cb33147005883e3f5d4476239501fa187" + integrity sha512-FbuXC+lK+GU2+63D1kC1ETiZo+Z7SIi7B+mxKTCH1byrh6WFvfBCN/wpherFz0a0bjGd7EKTst/cz0yLeNngug== + dependencies: + "@cto.af/textdecoder" "^0.0.0" + +nofilter@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/nofilter/-/nofilter-3.1.0.tgz#c757ba68801d41ff930ba2ec55bab52ca184aa66" + integrity sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g== + +nopt@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.3.tgz#a375cad9d02fd921278d954c2254d5aa57e15e48" + integrity sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg== + dependencies: + abbrev "1" + osenv "^0.1.4" + +nopt@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" + integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== + dependencies: + abbrev "1" + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +normalize-url@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" + integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== + +npm-run-path@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-5.1.0.tgz#bc62f7f3f6952d9894bd08944ba011a6ee7b7e00" + integrity sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q== + dependencies: + path-key "^4.0.0" + +npmlog@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" + integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== + dependencies: + are-we-there-yet "~1.1.2" + console-control-strings "~1.1.0" + gauge "~2.7.3" + set-blocking "~2.0.0" + +npmlog@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" + integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw== + dependencies: + are-we-there-yet "^2.0.0" + console-control-strings "^1.1.0" + gauge "^3.0.0" + set-blocking "^2.0.0" + +npmlog@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-6.0.0.tgz#ba9ef39413c3d936ea91553db7be49c34ad0520c" + integrity sha512-03ppFRGlsyUaQFbGC2C8QWJN/C/K7PsfyD9aQdhVKAQIH4sQBc8WASqFBP7O+Ut4d2oo5LoeoboB3cGdBZSp6Q== + dependencies: + are-we-there-yet "^2.0.0" + console-control-strings "^1.1.0" + gauge "^4.0.0" + set-blocking "^2.0.0" + +nsfwjs@2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/nsfwjs/-/nsfwjs-2.4.2.tgz#dd8656705f79f53d789245eaf317d6b6818a0032" + integrity sha512-i4Pp2yt59qPQgeZFyg3wXFBX52uSeu/hkDoqdZfe+sILRxNBUu0VDogj7Lmqak0GlrXviS/wLiVeIx40IDUu7A== + dependencies: + "@nsfw-filter/gif-frames" "1.0.2" + +nth-check@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c" + integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg== + dependencies: + boolbase "~1.0.0" + +number-is-nan@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" + integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= + +nwsapi@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7" + integrity sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ== + +oauth-sign@~0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" + integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== + +oauth@0.9.15: + version "0.9.15" + resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1" + integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE= + +object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + +object-inspect@^1.11.0, object-inspect@^1.9.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.11.0.tgz#9dceb146cedd4148a0d9e51ab88d34cf509922b1" + integrity sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg== + +object-inspect@^1.12.0: + version "1.12.2" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" + integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== + +object-keys@^1.0.12, object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940" + integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + has-symbols "^1.0.1" + object-keys "^1.1.1" + +object.values@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.5.tgz#959f63e3ce9ef108720333082131e4a459b716ac" + integrity sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.1" + +omggif@^1.0.5: + version "1.0.10" + resolved "https://registry.yarnpkg.com/omggif/-/omggif-1.0.10.tgz#ddaaf90d4a42f532e9e7cb3a95ecdd47f17c7b19" + integrity sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw== + +on-finished@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" + integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= + dependencies: + ee-first "1.1.1" + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + dependencies: + wrappy "1" + +onetime@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-6.0.0.tgz#7c24c18ed1fd2e9bca4bd26806a33613c77d34b4" + integrity sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ== + dependencies: + mimic-fn "^4.0.0" + +only@~0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4" + integrity sha1-Kv3oTQPlC5qO3EROMGEKcCle37Q= + +opentype.js@^0.4.3: + version "0.4.11" + resolved "https://registry.yarnpkg.com/opentype.js/-/opentype.js-0.4.11.tgz#281a2390639cc15931c955d8d63c14a7c7772b41" + integrity sha1-KBojkGOcwVkxyVXY1jwUp8d3K0E= + +optionator@^0.8.1: + version "0.8.3" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" + integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== + dependencies: + deep-is "~0.1.3" + fast-levenshtein "~2.0.6" + levn "~0.3.0" + prelude-ls "~1.1.2" + type-check "~0.3.2" + word-wrap "~1.2.3" + +optionator@^0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499" + integrity sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.3" + +os-homedir@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" + integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= + +os-tmpdir@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= + +os-utils@0.0.14: + version "0.0.14" + resolved "https://registry.yarnpkg.com/os-utils/-/os-utils-0.0.14.tgz#29e511697b1982b8c627722175fe39797ef64156" + integrity sha1-KeURaXsZgrjGJ3Ihdf45eX72QVY= + +osenv@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410" + integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g== + dependencies: + os-homedir "^1.0.0" + os-tmpdir "^1.0.0" + +p-cancelable@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.0.0.tgz#4a3740f5bdaf5ed5d7c3e34882c6fb5d6b266a6e" + integrity sha512-wvPXDmbMmu2ksjkB4Z3nZWTSkJEb9lqVdMaCKpZUGJG9TMiNp9XcbG3fn9fPKjem04fJMJnXoyFPk2FmgiaiNg== + +p-cancelable@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-3.0.0.tgz#63826694b54d61ca1c20ebcb6d3ecf5e14cd8050" + integrity sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw== + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= + +p-limit@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" + integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q== + dependencies: + p-try "^1.0.0" + +p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-limit@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.0.2.tgz#1664e010af3cadc681baafd3e2a437be7b0fb5fe" + integrity sha512-iwqZSOoWIW+Ew4kAGUlN16J4M7OB3ysMLSZtnhmqx7njIHFPlxWBX8xo3lVTyFVq6mI/lL9qt2IsN1sHwaxJkg== + dependencies: + p-try "^2.0.0" + +p-locate@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" + integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM= + dependencies: + p-limit "^1.1.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-map@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" + integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== + +p-map@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b" + integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== + dependencies: + aggregate-error "^3.0.0" + +p-timeout@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" + integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== + dependencies: + p-finally "^1.0.0" + +p-try@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" + integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M= + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +packet-reader@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/packet-reader/-/packet-reader-1.0.0.tgz#9238e5480dedabacfe1fe3f2771063f164157d74" + integrity sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ== + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-data-uri@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/parse-data-uri/-/parse-data-uri-0.2.0.tgz#bf04d851dd5c87b0ab238e5d01ace494b604b4c9" + integrity sha512-uOtts8NqDcaCt1rIsO3VFDRsAfgE4c6osG4d9z3l4dCBlxYFzni6Di/oNU270SDrjkfZuUvLZx1rxMyqh46Y9w== + dependencies: + data-uri-to-buffer "0.0.3" + +parse-srcset@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" + integrity sha1-8r0iH2zJcKk42IVWq8WJyqqiveE= + +parse5-htmlparser2-tree-adapter@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6" + integrity sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA== + dependencies: + parse5 "^6.0.1" + +parse5@7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.1.tgz#4649f940ccfb95d8754f37f73078ea20afe0c746" + integrity sha512-kwpuwzB+px5WUg9pyK0IcK/shltJN5/OVhQagxhCQNtT9Y9QRZqNY2e1cmbu/paRh5LMnz/oVTVLBpjFmMZhSg== + dependencies: + entities "^4.4.0" + +parse5@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" + integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== + +parse5@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" + integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== + +parse5@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.0.0.tgz#51f74a5257f5fcc536389e8c2d0b3802e1bfa91a" + integrity sha512-y/t8IXSPWTuRZqXc0ajH/UwDj4mnqLEbSttNbThcFhGrZuOyoyvNBO85PBp2jQa55wY9d07PBNjsK8ZP3K5U6g== + dependencies: + entities "^4.3.0" + +parseurl@^1.3.2: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +passthrough-counter@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/passthrough-counter/-/passthrough-counter-1.0.0.tgz#1967d9e66da572b5c023c787db112a387ab166fa" + integrity sha1-GWfZ5m2lcrXAI8eH2xEqOHqxZvo= + +path-exists@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" + integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@1.0.1, path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-key@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-4.0.0.tgz#295588dc3aee64154f877adb9d780b81c554bf18" + integrity sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ== + +path-parse@^1.0.6, path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-to-regexp@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.1.0.tgz#0b18f88b7a0ce0bfae6a25990c909ab86f512427" + integrity sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw== + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + +peek-readable@^5.0.0-alpha.5: + version "5.0.0-alpha.5" + resolved "https://registry.yarnpkg.com/peek-readable/-/peek-readable-5.0.0-alpha.5.tgz#ace5dfedf7bc33f17c9b5170b9d54f69a4fba79b" + integrity sha512-pJohF/tDwV3ntnT5+EkUo4E700q/j/OCDuPxtM+5/kFGjyOai/sK4/We4Cy1MB2OiTQliWU5DxPvYIKQAdPqAA== + +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== + +pg-connection-string@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.5.0.tgz#538cadd0f7e603fc09a12590f3b8a452c2c0cf34" + integrity sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ== + +pg-int8@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" + integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== + +pg-pool@^3.5.2: + version "3.5.2" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.5.2.tgz#ed1bed1fb8d79f1c6fd5fb1c99e990fbf9ddf178" + integrity sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w== + +pg-protocol@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.5.0.tgz#b5dd452257314565e2d54ab3c132adc46565a6a0" + integrity sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ== + +pg-types@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" + integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== + dependencies: + pg-int8 "1.0.1" + postgres-array "~2.0.0" + postgres-bytea "~1.0.0" + postgres-date "~1.0.4" + postgres-interval "^1.1.0" + +pg@8.8.0: + version "8.8.0" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.8.0.tgz#a77f41f9d9ede7009abfca54667c775a240da686" + integrity sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw== + dependencies: + buffer-writer "2.0.0" + packet-reader "1.0.0" + pg-connection-string "^2.5.0" + pg-pool "^3.5.2" + pg-protocol "^1.5.0" + pg-types "^2.1.0" + pgpass "1.x" + +pgpass@1.x: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.2.tgz#2a7bb41b6065b67907e91da1b07c1847c877b306" + integrity sha1-Knu0G2BltnkH6R2hsHwYR8h3swY= + dependencies: + split "^1.0.0" + +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== + +picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.0.7, picomatch@^2.2.1: + version "2.2.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" + integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== + +picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pify@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" + integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== + +plimit-lit@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/plimit-lit/-/plimit-lit-1.2.6.tgz#8c1336f26a042b6e9f1acc665be5eee4c2a55fb3" + integrity sha512-EuVnKyDeFgr58aidKf2G7DI41r23bxphlvBKAZ8e8dT9of0Ez2g9w6JbJGUP1YBNC2yG9+ZCCbjLj4yS1P5Gzw== + dependencies: + queue-lit "^1.2.7" + +pluralize@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" + integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== + +pngjs-nozlib@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/pngjs-nozlib/-/pngjs-nozlib-1.0.0.tgz#9e64d602cfe9cce4d9d5997d0687429a73f0b7d7" + integrity sha512-N1PggqLp9xDqwAoKvGohmZ3m4/N9xpY0nDZivFqQLcpLHmliHnCp9BuNCsOeqHWMuEEgFjpEaq9dZq6RZyy0fA== + +pngjs@^3.3.1, pngjs@^3.3.3: + version "3.4.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f" + integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w== + +pngjs@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" + integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== + +postcss@^8.3.11: + version "8.3.11" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.3.11.tgz#c3beca7ea811cd5e1c4a3ec6d2e7599ef1f8f858" + integrity sha512-hCmlUAIlUiav8Xdqw3Io4LcpA1DOt7h3LSTAC4G6JGHFFaWzI6qvFt9oilvl8BmkbBRX1IhM90ZAmpk68zccQA== + dependencies: + nanoid "^3.1.30" + picocolors "^1.0.0" + source-map-js "^0.6.2" + +postgres-array@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" + integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== + +postgres-bytea@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35" + integrity sha1-AntTPAqokOJtFy1Hz5zOzFIazTU= + +postgres-date@~1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.5.tgz#710b27de5f27d550f6e80b5d34f7ba189213c2ee" + integrity sha512-pdau6GRPERdAYUQwkBnGKxEfPyhVZXG/JiS44iZWiNdSOWE09N2lUgN6yshuq6fVSon4Pm0VMXd1srUUkLe9iA== + +postgres-interval@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" + integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== + dependencies: + xtend "^4.0.0" + +prebuild-install@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.0.tgz#991b6ac16c81591ba40a6d5de93fb33673ac1370" + integrity sha512-CNcMgI1xBypOyGqjp3wOc8AAo1nMhZS3Cwd3iHIxOdAUbb+YxdNuM4Z5iIrZ8RLvOsf3F3bl7b7xGq6DjQoNYA== + dependencies: + detect-libc "^2.0.0" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^1.0.1" + node-abi "^3.3.0" + npmlog "^4.0.1" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^4.0.0" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prelude-ls@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" + integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= + +pretty@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pretty/-/pretty-2.0.0.tgz#adbc7960b7bbfe289a557dc5f737619a220d06a5" + integrity sha1-rbx5YLe7/iiaVX3F9zdhmiINBqU= + dependencies: + condense-newlines "^0.2.1" + extend-shallow "^2.0.1" + js-beautify "^1.6.12" + +printj@~1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/printj/-/printj-1.1.2.tgz#d90deb2975a8b9f600fb3a1c94e3f4c53c78a222" + integrity sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ== + +private-ip@2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/private-ip/-/private-ip-2.3.3.tgz#1e80ff8443e5ac78f555631aec3ea6ff027fa6aa" + integrity sha512-5zyFfekIVUOTVbL92hc8LJOtE/gyGHeREHkJ2yTyByP8Q2YZVoBqLg3EfYLeF0oVvGqtaEX2t2Qovja0/gStXw== + dependencies: + ip-regex "^4.3.0" + ipaddr.js "^2.0.1" + is-ip "^3.1.0" + netmask "^2.0.2" + +private-ip@2.3.4: + version "2.3.4" + resolved "https://registry.yarnpkg.com/private-ip/-/private-ip-2.3.4.tgz#e2944f2a7a0142ec6640efda323af4b96307524e" + integrity sha512-ts/YFVwfBeLq61f9+KsOhXW6RH0wvY0gU50R6QZYzgFhggyyLK6WDFeYdjfi/HMnBm2hecLvsR3PB3JcRxDk+A== + dependencies: + ip-regex "^4.3.0" + ipaddr.js "^2.0.1" + is-ip "^3.1.0" + netmask "^2.0.2" + +probe-image-size@7.2.3: + version "7.2.3" + resolved "https://registry.yarnpkg.com/probe-image-size/-/probe-image-size-7.2.3.tgz#d49c64be540ec8edea538f6f585f65a9b3ab4309" + integrity sha512-HubhG4Rb2UH8YtV4ba0Vp5bQ7L78RTONYu/ujmCu5nBI8wGv24s4E9xSKBi0N1MowRpxk76pFCpJtW0KPzOK0w== + dependencies: + lodash.merge "^4.6.2" + needle "^2.5.2" + stream-parser "~0.3.1" + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +progress@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" + integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== + +promise-inflight@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" + integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= + +promise-limit@2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/promise-limit/-/promise-limit-2.7.0.tgz#eb5737c33342a030eaeaecea9b3d3a93cb592b26" + integrity sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw== + +promise-retry@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/promise-retry/-/promise-retry-2.0.1.tgz#ff747a13620ab57ba688f5fc67855410c370da22" + integrity sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g== + dependencies: + err-code "^2.0.2" + retry "^0.12.0" + +promise@^7.0.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== + dependencies: + asap "~2.0.3" + +proto-list@~1.2.1: + version "1.2.4" + resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" + integrity sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk= + +pseudomap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" + integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= + +psl@^1.1.28, psl@^1.1.33: + version "1.8.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" + integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== + +pug-attrs@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pug-attrs/-/pug-attrs-3.0.0.tgz#b10451e0348165e31fad1cc23ebddd9dc7347c41" + integrity sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA== + dependencies: + constantinople "^4.0.1" + js-stringify "^1.0.2" + pug-runtime "^3.0.0" + +pug-code-gen@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/pug-code-gen/-/pug-code-gen-3.0.2.tgz#ad190f4943133bf186b60b80de483100e132e2ce" + integrity sha512-nJMhW16MbiGRiyR4miDTQMRWDgKplnHyeLvioEJYbk1RsPI3FuA3saEP8uwnTb2nTJEKBU90NFVWJBk4OU5qyg== + dependencies: + constantinople "^4.0.1" + doctypes "^1.1.0" + js-stringify "^1.0.2" + pug-attrs "^3.0.0" + pug-error "^2.0.0" + pug-runtime "^3.0.0" + void-elements "^3.1.0" + with "^7.0.0" + +pug-error@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pug-error/-/pug-error-2.0.0.tgz#5c62173cb09c34de2a2ce04f17b8adfec74d8ca5" + integrity sha512-sjiUsi9M4RAGHktC1drQfCr5C5eriu24Lfbt4s+7SykztEOwVZtbFk1RRq0tzLxcMxMYTBR+zMQaG07J/btayQ== + +pug-filters@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/pug-filters/-/pug-filters-4.0.0.tgz#d3e49af5ba8472e9b7a66d980e707ce9d2cc9b5e" + integrity sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A== + dependencies: + constantinople "^4.0.1" + jstransformer "1.0.0" + pug-error "^2.0.0" + pug-walk "^2.0.0" + resolve "^1.15.1" + +pug-lexer@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/pug-lexer/-/pug-lexer-5.0.1.tgz#ae44628c5bef9b190b665683b288ca9024b8b0d5" + integrity sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w== + dependencies: + character-parser "^2.2.0" + is-expression "^4.0.0" + pug-error "^2.0.0" + +pug-linker@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/pug-linker/-/pug-linker-4.0.0.tgz#12cbc0594fc5a3e06b9fc59e6f93c146962a7708" + integrity sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw== + dependencies: + pug-error "^2.0.0" + pug-walk "^2.0.0" + +pug-load@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pug-load/-/pug-load-3.0.0.tgz#9fd9cda52202b08adb11d25681fb9f34bd41b662" + integrity sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ== + dependencies: + object-assign "^4.1.1" + pug-walk "^2.0.0" + +pug-parser@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/pug-parser/-/pug-parser-6.0.0.tgz#a8fdc035863a95b2c1dc5ebf4ecf80b4e76a1260" + integrity sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw== + dependencies: + pug-error "^2.0.0" + token-stream "1.0.0" + +pug-runtime@^3.0.0, pug-runtime@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/pug-runtime/-/pug-runtime-3.0.1.tgz#f636976204723f35a8c5f6fad6acda2a191b83d7" + integrity sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg== + +pug-strip-comments@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz#f94b07fd6b495523330f490a7f554b4ff876303e" + integrity sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ== + dependencies: + pug-error "^2.0.0" + +pug-walk@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pug-walk/-/pug-walk-2.0.0.tgz#417aabc29232bb4499b5b5069a2b2d2a24d5f5fe" + integrity sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ== + +pug@3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/pug/-/pug-3.0.2.tgz#f35c7107343454e43bc27ae0ff76c731b78ea535" + integrity sha512-bp0I/hiK1D1vChHh6EfDxtndHji55XP/ZJKwsRqrz6lRia6ZC2OZbdAymlxdVFwd1L70ebrVJw4/eZ79skrIaw== + dependencies: + pug-code-gen "^3.0.2" + pug-filters "^4.0.0" + pug-lexer "^5.0.1" + pug-linker "^4.0.0" + pug-load "^3.0.0" + pug-parser "^6.0.0" + pug-runtime "^3.0.1" + pug-strip-comments "^2.0.0" + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +punycode@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" + integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= + +punycode@2.1.1, punycode@^2.1.0, punycode@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" + integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== + +pureimage@0.3.14: + version "0.3.14" + resolved "https://registry.yarnpkg.com/pureimage/-/pureimage-0.3.14.tgz#e5fde69c7999d5114667926bda620ba462f72823" + integrity sha512-MoXNFWnJaaxMCqfB97Gyw73rI4MEY075VW/WJ+Z+F/ZgQP7HH8kdcIf8Meif15sdCXhTFlMTSHQxSIrSWkQILw== + dependencies: + jpeg-js "^0.4.1" + opentype.js "^0.4.3" + pngjs "^3.3.1" + +q@1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/q/-/q-1.4.1.tgz#55705bcd93c5f3673530c2c2cbc0c2b3addc286e" + integrity sha1-VXBbzZPF82c1MMLCy8DCs63cKG4= + +qrcode@1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.1.tgz#0103f97317409f7bc91772ef30793a54cd59f0cb" + integrity sha512-nS8NJ1Z3md8uTjKtP+SGGhfqmTCs5flU/xR623oI0JX+Wepz9R8UrRVCTBTJm3qGw3rH6jJ6MUHjkDx15cxSSg== + dependencies: + dijkstrajs "^1.0.1" + encode-utf8 "^1.0.3" + pngjs "^5.0.0" + yargs "^15.3.1" + +qs@^6.4.0, qs@^6.5.2: + version "6.9.3" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.3.tgz#bfadcd296c2d549f1dffa560619132c977f5008e" + integrity sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw== + +qs@~6.5.2: + version "6.5.3" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad" + integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== + +querystring@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" + integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= + +queue-lit@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/queue-lit/-/queue-lit-1.2.7.tgz#69081656c9e7b81f09770bb2de6aa007f1a90763" + integrity sha512-K/rTdggORRcmf3+c89ijPlgJ/ldGP4oBj6Sm7VcTup4B2clf03Jo8QaXTnMst4EEQwkUbOZFN4frKocq2I85gw== + +quick-lru@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" + integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== + +random-seed@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/random-seed/-/random-seed-0.3.0.tgz#d945f2e1f38f49e8d58913431b8bf6bb937556cd" + integrity sha1-2UXy4fOPSejViRNDG4v2u5N1Vs0= + dependencies: + json-stringify-safe "^5.0.1" + +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +rangestr@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/rangestr/-/rangestr-0.0.1.tgz#f72ff9246f10f2a7d7c16e14616f617be2c2635a" + integrity sha1-9y/5JG8Q8qfXwW4UYW9he+LCY1o= + +ratelimiter@3.4.1: + version "3.4.1" + resolved "https://registry.yarnpkg.com/ratelimiter/-/ratelimiter-3.4.1.tgz#fa69e94937413382a926aaa17aaeaa6263af4659" + integrity sha512-5FJbRW/Jkkdk29ksedAfWFkQkhbUrMx3QJGwMKAypeIiQf4yrLW+gtPKZiaWt4zPrtw1uGufOjGO7UGM6VllsQ== + +raw-body@^2.2.0, raw-body@^2.3.3: + version "2.4.1" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c" + integrity sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA== + dependencies: + bytes "3.1.0" + http-errors "1.7.3" + iconv-lite "0.4.24" + unpipe "1.0.0" + +rc@^1.2.7: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + +rdf-canonize@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/rdf-canonize/-/rdf-canonize-3.0.0.tgz#f5bade563e5e58f5cc5881afcba3c43839e8c747" + integrity sha512-LXRkhab1QaPJnhUIt1gtXXKswQCZ9zpflsSZFczG7mCLAkMvVjdqCGk9VXCUss0aOUeEyV2jtFxGcdX8DSkj9w== + dependencies: + setimmediate "^1.0.5" + +re2@1.17.7: + version "1.17.7" + resolved "https://registry.yarnpkg.com/re2/-/re2-1.17.7.tgz#e14cab85a177a5534c7215c322d1b043c55aa1e9" + integrity sha512-X8GSuiBoVWwcjuppqSjsIkRxNUKDdjhkO9SBekQbZ2ksqWUReCy7DQPWOVpoTnpdtdz5PIpTTxTFzvJv5UMfjA== + dependencies: + install-artifact-from-github "^1.3.1" + nan "^2.16.0" + node-gyp "^9.0.0" + +readable-stream@1.1.x, readable-stream@~1.1.9: + version "1.1.14" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" + integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk= + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + +readable-stream@^2.0.0, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.2.2, readable-stream@~2.3.6: + version "2.3.7" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" + integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readable-web-to-node-stream@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz#5d52bb5df7b54861fd48d015e93a2cb87b3ee0bb" + integrity sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw== + dependencies: + readable-stream "^3.6.0" + +readdir-glob@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/readdir-glob/-/readdir-glob-1.1.1.tgz#f0e10bb7bf7bfa7e0add8baffdc54c3f7dbee6c4" + integrity sha512-91/k1EzZwDx6HbERR+zucygRFfiPl2zkIYZtv3Jjr6Mn7SkKcVct8aVO+sSRiGMc6fLf72du3d92/uY63YPdEA== + dependencies: + minimatch "^3.0.4" + +readdirp@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.3.0.tgz#984458d13a1e42e2e9f5841b129e162f369aff17" + integrity sha512-zz0pAkSPOXXm1viEwygWIPSPkcBYjW1xU5j/JBh5t9bGCJwa6f9+BJa6VaB2g+b55yVrmXzqkyLf4xaWYM0IkQ== + dependencies: + picomatch "^2.0.7" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +reconnecting-websocket@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz#3b0e5b96ef119e78a03135865b8bb0af1b948783" + integrity sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng== + +redis-commands@1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.7.0.tgz#15a6fea2d58281e27b1cd1acfb4b293e278c3a89" + integrity sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ== + +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha1-62LSrbFeTq9GEMBK/hUpOEJQq60= + +redis-info@^3.0.8: + version "3.1.0" + resolved "https://registry.yarnpkg.com/redis-info/-/redis-info-3.1.0.tgz#5e349c8720e82d27ac84c73136dce0931e10469a" + integrity sha512-ER4L9Sh/vm63DkIE0bkSjxluQlioBiBgf5w1UuldaW/3vPcecdljVDisZhmnCMvsxHNiARTTDDHGg9cGwTfrKg== + dependencies: + lodash "^4.17.11" + +redis-lock@0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/redis-lock/-/redis-lock-0.1.4.tgz#e83590bee22b5f01cdb65bfbd88d988045356272" + integrity sha512-7/+zu86XVQfJVx1nHTzux5reglDiyUCDwmW7TSlvVezfhH2YLc/Rc8NE0ejQG+8/0lwKzm29/u/4+ogKeLosiA== + +redis-parser@3.0.0, redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ= + dependencies: + redis-errors "^1.0.0" + +redis@*: + version "4.0.2" + resolved "https://registry.yarnpkg.com/redis/-/redis-4.0.2.tgz#096cf716842731a24f34c7c3a996c143e2b133bb" + integrity sha512-Ip1DJ/lwuvtJz9AZ6pl1Bv33fWzk5d3iQpGzsXpi04ErkT4fq0pfGOm4k/p9DHmPGieEIOWvJ9xmIeQMooLybg== + dependencies: + "@node-redis/bloom" "^1.0.0" + "@node-redis/client" "^1.0.2" + "@node-redis/json" "^1.0.2" + "@node-redis/search" "^1.0.2" + "@node-redis/time-series" "^1.0.1" + +reflect-metadata@0.1.13, reflect-metadata@^0.1.13: + version "0.1.13" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" + integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg== + +regenerator-runtime@^0.13.5: + version "0.13.9" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" + integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA== + +regexp.prototype.flags@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac" + integrity sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + functions-have-names "^1.2.2" + +regexpp@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" + integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== + +rename@1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/rename/-/rename-1.0.4.tgz#a0f25078fa4195e650f73050c7c12ccf689f430b" + integrity sha1-oPJQePpBleZQ9zBQx8Esz2ifQws= + dependencies: + debug "^2.5.2" + +request@^2.44.0: + version "2.88.2" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" + integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.3" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.5.0" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + +require-all@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/require-all/-/require-all-3.0.0.tgz#473d49704be310115ce124f77383b1ebd8671312" + integrity sha1-Rz1JcEvjEBFc4ST3c4Ox69hnExI= + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= + +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + +resolve-alpn@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.0.0.tgz#745ad60b3d6aff4b4a48e01b8c0bdc70959e0e8c" + integrity sha512-rTuiIEqFmGxne4IovivKSDzld2lWW9QCjqv80SYjPgf+gS35eaCAjaP54CCwGAwBtnCsvNLYtqxe1Nw+i6JEmA== + +resolve-alpn@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9" + integrity sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g== + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-path@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/resolve-path/-/resolve-path-1.4.0.tgz#c4bda9f5efb2fce65247873ab36bb4d834fe16f7" + integrity sha1-xL2p9e+y/OZSR4c6s2u02DT+Fvc= + dependencies: + http-errors "~1.6.2" + path-is-absolute "1.0.1" + +resolve@^1.15.1, resolve@^1.20.0: + version "1.20.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" + integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== + dependencies: + is-core-module "^2.2.0" + path-parse "^1.0.6" + +resolve@^1.22.0: + version "1.22.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198" + integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw== + dependencies: + is-core-module "^2.8.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +responselike@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/responselike/-/responselike-2.0.0.tgz#26391bcc3174f750f9a79eacc40a12a5c42d7723" + integrity sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw== + dependencies: + lowercase-keys "^2.0.0" + +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= + +reusify@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" + integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + +rimraf@2, rimraf@^2.6.2: + version "2.7.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" + integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== + dependencies: + glob "^7.1.3" + +rimraf@^3.0.0, rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +rndstr@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/rndstr/-/rndstr-1.0.0.tgz#77e66fa8f9b4836853fdd91e50719591bb67d349" + integrity sha1-d+ZvqPm0g2hT/dkeUHGVkbtn00k= + dependencies: + rangestr "0.0.1" + seedrandom "2.4.2" + +rss-parser@3.12.0: + version "3.12.0" + resolved "https://registry.yarnpkg.com/rss-parser/-/rss-parser-3.12.0.tgz#b8888699ea46304a74363fbd8144671b2997984c" + integrity sha512-aqD3E8iavcCdkhVxNDIdg1nkBI17jgqF+9OqPS1orwNaOgySdpvq6B+DoONLhzjzwV8mWg37sb60e4bmLK117A== + dependencies: + entities "^2.0.3" + xml2js "^0.4.19" + +run-parallel@^1.1.9: + version "1.1.9" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.1.9.tgz#c9dd3a7cf9f4b2c4b6244e173a6ed866e61dd679" + integrity sha512-DEqnSRTDw/Tc3FXf49zedI638Z9onwUotBMiUFKmrO2sdFKIbXamXGQ3Axd4qgphxKB4kw/qP1w5kTxnfU1B9Q== + +s-age@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/s-age/-/s-age-1.1.2.tgz#c0cf15233ccc93f41de92ea42c36d957977d1ea2" + integrity sha512-aSN2TlF39WLoZA/6cgYSJZhKt63kJ4EaadejPWjWY9/h4rksIqvfWY3gfd+3uAegSM1IXsA9aWeEhJtkxkFQtA== + +safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-buffer@5.2.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" + integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== + +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +sanitize-html@2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.7.1.tgz#a6c2c1a88054a79eeacfac9b0a43f1b393476901" + integrity sha512-oOpe8l4J8CaBk++2haoN5yNI5beekjuHv3JRPKUx/7h40Rdr85pemn4NkvUB3TcBP7yjat574sPlcMAyv4UQig== + dependencies: + deepmerge "^4.2.2" + escape-string-regexp "^4.0.0" + htmlparser2 "^6.0.0" + is-plain-object "^5.0.0" + parse-srcset "^1.0.2" + postcss "^8.3.11" + +sax@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" + integrity sha1-e45lYZCyKOgaZq6nSEgNgozS03o= + +sax@>=0.6.0, sax@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" + integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== + +saxes@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== + dependencies: + xmlchars "^2.2.0" + +schema-utils@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.0.0.tgz#67502f6aa2b66a2d4032b4279a2944978a0913ef" + integrity sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA== + dependencies: + "@types/json-schema" "^7.0.6" + ajv "^6.12.5" + ajv-keywords "^3.5.2" + +secure-json-parse@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.1.0.tgz#ae76f5624256b5c497af887090a5d9e156c9fb20" + integrity sha512-GckO+MS/wT4UogDyoI/H/S1L0MCcKS1XX/vp48wfmU7Nw4woBmb8mIpu4zPBQjKlRT88/bt9xdoV4111jPpNJA== + +seedrandom@2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/seedrandom/-/seedrandom-2.4.2.tgz#18d78c41287d13aff8eadb29e235938b248aa9ff" + integrity sha1-GNeMQSh9E6/46tsp4jWTiySKqf8= + +seedrandom@3.0.5, seedrandom@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/seedrandom/-/seedrandom-3.0.5.tgz#54edc85c95222525b0c7a6f6b3543d8e0b3aa0a7" + integrity sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg== + +semver@7.3.7, semver@^7.3.7: + version "7.3.7" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" + integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== + dependencies: + lru-cache "^6.0.0" + +semver@^5.6.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + +semver@^6.0.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + +semver@^7.3.2, semver@^7.3.4: + version "7.3.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.4.tgz#27aaa7d2e4ca76452f98d3add093a72c943edc97" + integrity sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw== + dependencies: + lru-cache "^6.0.0" + +semver@^7.3.5: + version "7.3.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" + integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== + dependencies: + lru-cache "^6.0.0" + +serialize-javascript@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8" + integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag== + dependencies: + randombytes "^2.1.0" + +set-blocking@^2.0.0, set-blocking@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= + +setimmediate@^1.0.5, setimmediate@~1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= + +setprototypeof@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" + integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ== + +setprototypeof@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" + integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== + +sha.js@^2.4.11: + version "2.4.11" + resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" + integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +sharp@0.29.3: + version "0.29.3" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.29.3.tgz#0da183d626094c974516a48fab9b3e4ba92eb5c2" + integrity sha512-fKWUuOw77E4nhpyzCCJR1ayrttHoFHBT2U/kR/qEMRhvPEcluG4BKj324+SCO1e84+knXHwhJ1HHJGnUt4ElGA== + dependencies: + color "^4.0.1" + detect-libc "^1.0.3" + node-addon-api "^4.2.0" + prebuild-install "^7.0.0" + semver "^7.3.5" + simple-get "^4.0.0" + tar-fs "^2.1.1" + tunnel-agent "^0.6.0" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +side-channel@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" + integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== + dependencies: + call-bind "^1.0.0" + get-intrinsic "^1.0.2" + object-inspect "^1.9.0" + +sigmund@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" + integrity sha1-P/IfGYytIXX587eBhT/ZTQ0ZtZA= + +signal-exit@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" + integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== + +signal-exit@^3.0.7: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543" + integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== + dependencies: + decompress-response "^6.0.0" + once "^1.3.1" + simple-concat "^1.0.0" + +simple-swizzle@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" + integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo= + dependencies: + is-arrayish "^0.3.1" + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +smart-buffer@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" + integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== + +socks-proxy-agent@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz#dc069ecf34436621acb41e3efa66ca1b5fed15b6" + integrity sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww== + dependencies: + agent-base "^6.0.2" + debug "^4.3.3" + socks "^2.6.2" + +socks@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.6.2.tgz#ec042d7960073d40d94268ff3bb727dc685f111a" + integrity sha512-zDZhHhZRY9PxRruRMR7kMhnf3I8hDs4S3f9RecfnGxvcBHQcKcIH/oUcEWffsfl1XxdYlA7nnlGbbTvPz9D8gA== + dependencies: + ip "^1.1.5" + smart-buffer "^4.2.0" + +source-map-js@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e" + integrity sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug== + +source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +speakeasy@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/speakeasy/-/speakeasy-2.0.0.tgz#85c91a071b09a5cb8642590d983566165f57613a" + integrity sha1-hckaBxsJpcuGQlkNmDVmFl9XYTo= + dependencies: + base32.js "0.0.1" + +split@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/split/-/split-1.0.1.tgz#605bd9be303aa59fb35f9229fbea0ddec9ea07d9" + integrity sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg== + dependencies: + through "2" + +sprintf-js@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.2.tgz#da1765262bf8c0f571749f2ad6c26300207ae673" + integrity sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +sshpk@^1.14.1: + version "1.16.1" + resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" + integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + bcrypt-pbkdf "^1.0.0" + dashdash "^1.12.0" + ecc-jsbn "~0.1.1" + getpass "^0.1.1" + jsbn "~0.1.0" + safer-buffer "^2.0.2" + tweetnacl "~0.14.0" + +sshpk@^1.7.0: + version "1.17.0" + resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.17.0.tgz#578082d92d4fe612b13007496e543fa0fbcbe4c5" + integrity sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ== + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + bcrypt-pbkdf "^1.0.0" + dashdash "^1.12.0" + ecc-jsbn "~0.1.1" + getpass "^0.1.1" + jsbn "~0.1.0" + safer-buffer "^2.0.2" + tweetnacl "~0.14.0" + +ssri@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-9.0.1.tgz#544d4c357a8d7b71a19700074b6883fcb4eae057" + integrity sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q== + dependencies: + minipass "^3.1.1" + +standard-as-callback@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45" + integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== + +"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" + integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= + +stream-parser@~0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/stream-parser/-/stream-parser-0.3.1.tgz#1618548694420021a1182ff0af1911c129761773" + integrity sha1-FhhUhpRCACGhGC/wrxkRwSl2F3M= + dependencies: + debug "2" + +streamsearch@0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" + integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= + +strict-event-emitter-types@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz#05e15549cb4da1694478a53543e4e2f4abcf277f" + integrity sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA== + +string-width@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" + integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= + dependencies: + code-point-at "^1.0.0" + is-fullwidth-code-point "^1.0.0" + strip-ansi "^3.0.0" + +"string-width@^1.0.2 || 2": + version "2.1.1" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5" + integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.0" + +string.prototype.trimend@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz#e75ae90c2942c63504686c18b287b4a0b1a45f80" + integrity sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + +string.prototype.trimend@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.5.tgz#914a65baaab25fbdd4ee291ca7dde57e869cb8d0" + integrity sha512-I7RGvmjV4pJ7O3kdf+LXFpVfdNOxtCW/2C8f6jNiW4+PQchwxkCDzlk1/7p+Wl4bqFIZeF47qAHXLuHHWKAxog== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.4" + es-abstract "^1.19.5" + +string.prototype.trimstart@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz#b36399af4ab2999b4c9c648bd7a3fb2bb26feeed" + integrity sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + +string.prototype.trimstart@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.5.tgz#5466d93ba58cfa2134839f81d7f42437e8c01fef" + integrity sha512-THx16TJCGlsN0o6dl2o6ncWUsdgnLRSA23rRE5pyGBw/mLr3Ej/R2LaqCtgP8VNMGZsvMWnf9ooZPyY2bHvUFg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.4" + es-abstract "^1.19.5" + +string_decoder@^1.1.1, string_decoder@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ= + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +stringz@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/stringz/-/stringz-2.1.0.tgz#5896b4713eac31157556040fb90258fb02c1630c" + integrity sha512-KlywLT+MZ+v0IRepfMxRtnSvDCMc3nR1qqCs3m/qIbSOWkNZYT8XHQA31rS3TnKp0c5xjZu3M4GY/2aRKSi/6A== + dependencies: + char-regex "^1.0.2" + +strip-ansi@^3.0.0, strip-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= + dependencies: + ansi-regex "^2.0.0" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= + dependencies: + ansi-regex "^3.0.0" + +strip-ansi@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" + integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== + dependencies: + ansi-regex "^5.0.0" + +strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= + +strip-final-newline@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz#52894c313fbff318835280aed60ff71ebf12b8fd" + integrity sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw== + +strip-json-comments@3.1.1, strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= + +strtok3@^7.0.0-alpha.9: + version "7.0.0-alpha.9" + resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-7.0.0-alpha.9.tgz#a4ad5889e4fb5cea3514298435c6d7e84e595752" + integrity sha512-G8WxjBFjTZ77toVElv1i7k3jCXNkBB14FVaZ/6LIOka/WGo4La5XHLrU7neFVLdKbXESZf4BejVKZu5maOmocA== + dependencies: + "@tokenizer/token" "^0.3.0" + peek-readable "^5.0.0-alpha.5" + +summaly@2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/summaly/-/summaly-2.7.0.tgz#ccccec0477938edea13cb34412a33e705398c0c4" + integrity sha512-pEz9LL8Gp0oPIQfn6TrnBCcv/HkFE14hxhH3W6LPGdopXlPXjRcMlDMJaO+VupUNMOGaMjCsjq7+0rWnu8sp7w== + dependencies: + cheerio "0.22.0" + debug "4.3.3" + escape-regexp "0.0.1" + got "11.8.5" + html-entities "2.3.2" + iconv-lite "0.6.3" + jschardet "3.0.0" + koa "2.13.4" + private-ip "2.3.3" + require-all "3.0.0" + trace-redirect "1.0.6" + +supports-color@8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +symbol-tree@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" + integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== + +syslog-pro@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/syslog-pro/-/syslog-pro-1.0.0.tgz#e46bfd39f58937352645091e84a3b903f39e12ea" + integrity sha512-7SNMJKtQBJlwBUp1jxFT7bXya71cnINXPCYJ2AVhlQE4MKL7o2QiPdAXbMdWRiLeykQ2rx+7TNrnoGzvzhO+eA== + dependencies: + moment "^2.22.2" + +systeminformation@5.12.6: + version "5.12.6" + resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-5.12.6.tgz#b75d7aaf9f5da32439fc633d2be9eb741691d200" + integrity sha512-FkCvT5BOuH1OE3+8lFM25oXIYJ0CM8kq4Wgvz2jyBTrsOIgha/6gdJXgbF4rv+g0j/5wJqQLDKan7kc/p7uIvw== + +tapable@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.0.tgz#5c373d281d9c672848213d0e037d1c4165ab426b" + integrity sha512-FBk4IesMV1rBxX2tfiK8RAmogtWn53puLOQlvO8XuwlgxcYbP4mVPS9Ph4aeamSyyVjOl24aYWAuc8U5kCVwMw== + +tar-fs@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.0.1.tgz#e44086c1c60d31a4f0cf893b1c4e155dabfae9e2" + integrity sha512-6tzWDMeroL87uF/+lin46k+Q+46rAJ0SyPGz7OW7wTgblI273hsBqk2C1j0/xNadNLKDTUL9BukSjB7cwgmlPA== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.0.0" + +tar-fs@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" + integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.1.4" + +tar-stream@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.1.2.tgz#6d5ef1a7e5783a95ff70b69b97455a5968dc1325" + integrity sha512-UaF6FoJ32WqALZGOIAApXx+OdxhekNMChu6axLJR85zMMjXKWFGjbIRe+J6P4UnRGg9rAwWvbTT0oI7hD/Un7Q== + dependencies: + bl "^4.0.1" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + +tar-stream@^2.1.4, tar-stream@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + +tar@^4.4.6: + version "4.4.19" + resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.19.tgz#2e4d7263df26f2b914dee10c825ab132123742f3" + integrity sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA== + dependencies: + chownr "^1.1.4" + fs-minipass "^1.2.7" + minipass "^2.9.0" + minizlib "^1.3.3" + mkdirp "^0.5.5" + safe-buffer "^5.2.1" + yallist "^3.1.1" + +tar@^6.1.11, tar@^6.1.2: + version "6.1.11" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621" + integrity sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA== + dependencies: + chownr "^2.0.0" + fs-minipass "^2.0.0" + minipass "^3.0.0" + minizlib "^2.1.1" + mkdirp "^1.0.3" + yallist "^4.0.0" + +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= + +thenify-all@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" + integrity sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY= + dependencies: + thenify ">= 3.1.0 < 4" + +"thenify@>= 3.1.0 < 4": + version "3.3.1" + resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f" + integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== + dependencies: + any-promise "^1.0.0" + +through@2, through@^2.3.4: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== + +through@2.3.4: + version "2.3.4" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.4.tgz#495e40e8d8a8eaebc7c275ea88c2b8fc14c56455" + integrity sha512-DwbmSAcABsMazNkLOJJSLRC3gfh4cPxUxJCn9npmvbcI6undhgoJ2ShvEOgZrW8BH62Gyr9jKboGbfFcmY5VsQ== + +tinycolor2@1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803" + integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA== + +tmp@0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" + integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ== + dependencies: + rimraf "^3.0.0" + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +toidentifier@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" + integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== + +token-stream@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/token-stream/-/token-stream-1.0.0.tgz#cc200eab2613f4166d27ff9afc7ca56d49df6eb4" + integrity sha1-zCAOqyYT9BZtJ/+a/HylbUnfbrQ= + +token-types@^5.0.0-alpha.2: + version "5.0.0-alpha.2" + resolved "https://registry.yarnpkg.com/token-types/-/token-types-5.0.0-alpha.2.tgz#e43d63b2a8223a593d1c782a5149bec18f1abf97" + integrity sha512-EsG9UxAW4M6VATrEEjhPFTKEUi1OiJqTUMIZOGBN49fGxYjZB36k0p7to3HZSmWRoHm1QfZgrg3e02fpqAt5fQ== + dependencies: + "@tokenizer/token" "^0.3.0" + ieee754 "^1.2.1" + +tough-cookie@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" + integrity sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.1.2" + +tough-cookie@~2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" + integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== + dependencies: + psl "^1.1.28" + punycode "^2.1.1" + +tr46@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9" + integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA== + dependencies: + punycode "^2.1.1" + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= + +trace-redirect@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/trace-redirect/-/trace-redirect-1.0.6.tgz#ac629b5bf8247d30dde5a35fe9811b811075b504" + integrity sha512-UUfa1DjjU5flcjMdaFIiIEGDTyu2y/IiMjOX4uGXa7meKBS4vD4f2Uy/tken9Qkd4Jsm4sRsfZcIIPqrRVF3Mg== + +"traverse@>=0.3.0 <0.4": + version "0.3.9" + resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9" + integrity sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk= + +ts-loader@9.3.1: + version "9.3.1" + resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.3.1.tgz#fe25cca56e3e71c1087fe48dc67f4df8c59b22d4" + integrity sha512-OkyShkcZTsTwyS3Kt7a4rsT/t2qvEVQuKCTg4LJmpj9fhFR7ukGdZwV6Qq3tRUkqcXtfGpPR7+hFKHCG/0d3Lw== + dependencies: + chalk "^4.1.0" + enhanced-resolve "^5.0.0" + micromatch "^4.0.0" + semver "^7.3.4" + +ts-node@10.9.1: + version "10.9.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b" + integrity sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + +tsc-alias@1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/tsc-alias/-/tsc-alias-1.7.0.tgz#733482751133a25b97608ee424f8a1f085fcaaef" + integrity sha512-n/K6g8S7Ec7Y/A2Z77Ikp2Uv1S1ERtT63ni69XV4W1YPT4rnNmz8ItgIiJYvKfFnKfqcZQ81UPjoKpMTxaC/rg== + dependencies: + chokidar "^3.5.3" + commander "^9.0.0" + globby "^11.0.4" + mylas "^2.1.9" + normalize-path "^3.0.0" + plimit-lit "^1.2.6" + +tsconfig-paths@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-4.1.0.tgz#f8ef7d467f08ae3a695335bf1ece088c5538d2c1" + integrity sha512-AHx4Euop/dXFC+Vx589alFba8QItjF+8hf8LtmuiCwHyI4rHXQtOOENaM8kvYf5fR0dRChy3wzWIZ9WbB7FWow== + dependencies: + json5 "^2.2.1" + minimist "^1.2.6" + strip-bom "^3.0.0" + +tsconfig-paths@^3.14.1: + version "3.14.1" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a" + integrity sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ== + dependencies: + "@types/json5" "^0.0.29" + json5 "^1.0.1" + minimist "^1.2.6" + strip-bom "^3.0.0" + +tslib@^1.8.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35" + integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA== + +tslib@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" + integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== + +tsscmp@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" + integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== + +tsutils@^3.21.0: + version "3.21.0" + resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" + integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== + dependencies: + tslib "^1.8.1" + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= + dependencies: + safe-buffer "^5.0.1" + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= + +twemoji-parser@14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/twemoji-parser/-/twemoji-parser-14.0.0.tgz#13dabcb6d3a261d9efbf58a1666b182033bf2b62" + integrity sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA== + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +type-check@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" + integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= + dependencies: + prelude-ls "~1.1.2" + +type-detect@4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + +type-fest@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + +type-is@^1.6.14, type-is@^1.6.16, type-is@^1.6.4: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +type@^1.0.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/type/-/type-1.2.0.tgz#848dd7698dafa3e54a6c479e759c4bc3f18847a0" + integrity sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg== + +type@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/type/-/type-2.0.0.tgz#5f16ff6ef2eb44f260494dae271033b29c09a9c3" + integrity sha512-KBt58xCHry4Cejnc2ISQAF7QY+ORngsWfxezO68+12hKV6lQY8P/psIkcbjeHWn7MqcgciWJyCCevFMJdIXpow== + +typedarray-to-buffer@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" + integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== + dependencies: + is-typedarray "^1.0.0" + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= + +typeorm@0.3.9: + version "0.3.9" + resolved "https://registry.yarnpkg.com/typeorm/-/typeorm-0.3.9.tgz#ad0f525d81c081fd11006f97030f47a55978ac81" + integrity sha512-xNcE44D4hn74n7pjuMog9hRgep+BiO3IBpjEaQZ8fb56zsDz7xHT1GAeWwmGuuU+4nDEELp2mIqgSCR+zxR7Jw== + dependencies: + "@sqltools/formatter" "^1.2.2" + app-root-path "^3.0.0" + buffer "^6.0.3" + chalk "^4.1.0" + cli-highlight "^2.1.11" + date-fns "^2.28.0" + debug "^4.3.3" + dotenv "^16.0.0" + glob "^7.2.0" + js-yaml "^4.1.0" + mkdirp "^1.0.4" + reflect-metadata "^0.1.13" + sha.js "^2.4.11" + tslib "^2.3.1" + uuid "^8.3.2" + xml2js "^0.4.23" + yargs "^17.3.1" + +typescript@4.8.3: + version "4.8.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.3.tgz#d59344522c4bc464a65a730ac695007fdb66dd88" + integrity sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig== + +ulid@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/ulid/-/ulid-2.3.0.tgz#93063522771a9774121a84d126ecd3eb9804071f" + integrity sha512-keqHubrlpvT6G2wH0OEfSW4mquYRcbe/J8NMmveoQOjUqmo+hXtO+ORCpWhdbZ7k72UtY61BL7haGxW6enBnjw== + +unbox-primitive@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471" + integrity sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw== + dependencies: + function-bind "^1.1.1" + has-bigints "^1.0.1" + has-symbols "^1.0.2" + which-boxed-primitive "^1.0.2" + +unbox-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" + integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw== + dependencies: + call-bind "^1.0.2" + has-bigints "^1.0.2" + has-symbols "^1.0.3" + which-boxed-primitive "^1.0.2" + +undici@^5.2.0: + version "5.8.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-5.8.0.tgz#dec9a8ccd90e5a1d81d43c0eab6503146d649a4f" + integrity sha512-1F7Vtcez5w/LwH2G2tGnFIihuWUlc58YidwLiCv+jR2Z50x0tNXpRRw7eOIJ+GvqCqIkg9SB7NWAJ/T9TLfv8Q== + +uniq@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" + integrity sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA== + +unique-filename@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" + integrity sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ== + dependencies: + unique-slug "^2.0.0" + +unique-slug@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.2.tgz#baabce91083fc64e945b0f3ad613e264f7cd4e6c" + integrity sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w== + dependencies: + imurmurhash "^0.1.4" + +universalify@^0.1.0, universalify@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" + integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== + +unpipe@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= + +unzipper@0.10.11: + version "0.10.11" + resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.11.tgz#0b4991446472cbdb92ee7403909f26c2419c782e" + integrity sha512-+BrAq2oFqWod5IESRjL3S8baohbevGcVA+teAIOYWM3pDVdseogqbzhhvvmiyQrUNKFUnDMtELW3X8ykbyDCJw== + dependencies: + big-integer "^1.6.17" + binary "~0.3.0" + bluebird "~3.4.1" + buffer-indexof-polyfill "~1.0.0" + duplexer2 "~0.1.4" + fstream "^1.0.12" + graceful-fs "^4.2.2" + listenercount "~1.0.1" + readable-stream "~2.3.6" + setimmediate "~1.0.4" + +uri-js@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" + integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ== + dependencies: + punycode "^2.1.0" + +url@0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64" + integrity sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ= + dependencies: + punycode "1.3.2" + querystring "0.2.0" + +urlsafe-base64@^1.0.0, urlsafe-base64@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/urlsafe-base64/-/urlsafe-base64-1.0.0.tgz#23f89069a6c62f46cf3a1d3b00169cefb90be0c6" + integrity sha1-I/iQaabGL0bPOh07ABac77kL4MY= + +utf-8-validate@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/utf-8-validate/-/utf-8-validate-5.0.2.tgz#63cfbccd85dc1f2b66cf7a1d0eebc08ed056bfb3" + integrity sha512-SwV++i2gTD5qh2XqaPzBnNX88N6HdyhQrNNRykvcS0QKvItV9u3vPEJr+X5Hhfb1JC0r0e1alL0iB09rY8+nmw== + dependencies: + node-gyp-build "~3.7.0" + +util-deprecate@^1.0.1, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + +util@^0.12.4: + version "0.12.4" + resolved "https://registry.yarnpkg.com/util/-/util-0.12.4.tgz#66121a31420df8f01ca0c464be15dfa1d1850253" + integrity sha512-bxZ9qtSlGUWSOy9Qa9Xgk11kSslpuZwaxCg4sNIDj6FLucDab2JxnHwyNTCpHMtK1MjoQiWQ6DiUMZYbSrO+Sw== + dependencies: + inherits "^2.0.3" + is-arguments "^1.0.4" + is-generator-function "^1.0.7" + is-typed-array "^1.1.3" + safe-buffer "^5.1.2" + which-typed-array "^1.1.2" + +uuid@7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" + integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== + +uuid@8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.0.0.tgz#bc6ccf91b5ff0ac07bbcdbf1c7c4e150db4dbb6c" + integrity sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw== + +uuid@9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" + integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== + +uuid@^3.3.2: + version "3.4.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" + integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== + +uuid@^8.3.0, uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + +vary@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= + +verror@1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" + integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + +void-elements@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09" + integrity sha1-YU9/v42AHwu18GYfWy9XhXUOTwk= + +w3c-hr-time@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" + integrity sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ== + dependencies: + browser-process-hrtime "^1.0.0" + +w3c-xmlserializer@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-3.0.0.tgz#06cdc3eefb7e4d0b20a560a5a3aeb0d2d9a65923" + integrity sha512-3WFqGEgSXIyGhOmAFtlicJNMjEps8b1MG31NCA0/vOF9+nKMUW1ckhi9cnNHmf88Rzw5V+dwIwsm2C7X8k9aQg== + dependencies: + xml-name-validator "^4.0.0" + +web-push@3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/web-push/-/web-push-3.5.0.tgz#4576533746052eda3bd50414b54a1b0a21eeaeae" + integrity sha512-JC0V9hzKTqlDYJ+LTZUXtW7B175qwwaqzbbMSWDxHWxZvd3xY0C2rcotMGDavub2nAAFw+sXTsqR65/KY2A5AQ== + dependencies: + asn1.js "^5.3.0" + http_ece "1.1.0" + https-proxy-agent "^5.0.0" + jws "^4.0.0" + minimist "^1.2.5" + urlsafe-base64 "^1.0.0" + +web-streams-polyfill@^3.0.3: + version "3.2.0" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.0.tgz#a6b74026b38e4885869fb5c589e90b95ccfc7965" + integrity sha512-EqPmREeOzttaLRm5HS7io98goBgZ7IVz79aDvqjD0kYXLtFZTc0T/U6wHTPKyIjb+MdN7DFIIX6hgdBEpWmfPA== + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE= + +webidl-conversions@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" + integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== + +websocket@1.0.34: + version "1.0.34" + resolved "https://registry.yarnpkg.com/websocket/-/websocket-1.0.34.tgz#2bdc2602c08bf2c82253b730655c0ef7dcab3111" + integrity sha512-PRDso2sGwF6kM75QykIesBijKSVceR6jL2G8NGYyq2XrItNC2P5/qL5XeR056GhA+Ly7JMFvJb9I312mJfmqnQ== + dependencies: + bufferutil "^4.0.1" + debug "^2.2.0" + es5-ext "^0.10.50" + typedarray-to-buffer "^3.1.5" + utf-8-validate "^5.0.2" + yaeti "^0.0.6" + +whatwg-encoding@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz#e7635f597fd87020858626805a2729fa7698ac53" + integrity sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg== + dependencies: + iconv-lite "0.6.3" + +whatwg-mimetype@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" + integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== + +whatwg-url@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018" + integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ== + dependencies: + tr46 "^3.0.0" + webidl-conversions "^7.0.0" + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha1-lmRU6HZUYuN2RNNib2dCzotwll0= + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +which-boxed-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" + integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== + dependencies: + is-bigint "^1.0.1" + is-boolean-object "^1.1.0" + is-number-object "^1.0.4" + is-string "^1.0.5" + is-symbol "^1.0.3" + +which-module@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" + integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= + +which-typed-array@^1.1.2: + version "1.1.8" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.8.tgz#0cfd53401a6f334d90ed1125754a42ed663eb01f" + integrity sha512-Jn4e5PItbcAHyLoRDwvPj1ypu27DJbtdYXUa5zsinrUx77Uvfb0cXwwnGMTn7cjUfhhqgVQnVJCwF+7cgU7tpw== + dependencies: + available-typed-arrays "^1.0.5" + call-bind "^1.0.2" + es-abstract "^1.20.0" + for-each "^0.3.3" + has-tostringtag "^1.0.0" + is-typed-array "^1.1.9" + +which@^1.1.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + +which@^2.0.1, which@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +wide-align@^1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" + integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== + dependencies: + string-width "^1.0.2 || 2" + +wide-align@^1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" + integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== + dependencies: + string-width "^1.0.2 || 2 || 3 || 4" + +with@^7.0.0: + version "7.0.2" + resolved "https://registry.yarnpkg.com/with/-/with-7.0.2.tgz#ccee3ad542d25538a7a7a80aad212b9828495bac" + integrity sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w== + dependencies: + "@babel/parser" "^7.9.6" + "@babel/types" "^7.9.6" + assert-never "^1.2.1" + babel-walk "3.0.0-canary-5" + +word-wrap@^1.2.3, word-wrap@~1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" + integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + +workerpool@6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" + integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== + +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +ws@8.8.1: + version "8.8.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.8.1.tgz#5dbad0feb7ade8ecc99b830c1d77c913d4955ff0" + integrity sha512-bGy2JzvzkPowEJV++hF07hAD6niYSr0JzBNo/J29WsB57A2r7Wlc1UFcTR9IzrPvuNVO4B8LGqF8qcpsVOhJCA== + +ws@^8.8.0: + version "8.8.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.8.0.tgz#8e71c75e2f6348dbf8d78005107297056cb77769" + integrity sha512-JDAgSYQ1ksuwqfChJusw1LSJ8BizJ2e/vVu5Lxjq3YvNJNlROv1ui4i+c/kUUrPheBvQl4c5UbERhTwKa6QBJQ== + +xev@3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/xev/-/xev-3.0.2.tgz#3f4080bd8bed0d3479c674050e3696da98d22a4d" + integrity sha512-8kxuH95iMXzHZj+fwqfA4UrPcYOy6bGIgfWzo9Ji23JoEc30ge/Z++Ubkiuy8c0+M64nXmmxrmJ7C8wnuBhluw== + +xml-js@^1.6.11: + version "1.6.11" + resolved "https://registry.yarnpkg.com/xml-js/-/xml-js-1.6.11.tgz#927d2f6947f7f1c19a316dd8eea3614e8b18f8e9" + integrity sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g== + dependencies: + sax "^1.2.4" + +xml-name-validator@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835" + integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw== + +xml2js@0.4.19: + version "0.4.19" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7" + integrity sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q== + dependencies: + sax ">=0.6.0" + xmlbuilder "~9.0.1" + +xml2js@^0.4.19, xml2js@^0.4.23: + version "0.4.23" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" + integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug== + dependencies: + sax ">=0.6.0" + xmlbuilder "~11.0.0" + +xmlbuilder@~11.0.0: + version "11.0.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" + integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== + +xmlbuilder@~9.0.1: + version "9.0.7" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d" + integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0= + +xmlchars@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" + integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +y18n@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.1.tgz#8db2b83c31c5d75099bb890b23f3094891e247d4" + integrity sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ== + +y18n@^5.0.5: + version "5.0.5" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.5.tgz#8769ec08d03b1ea2df2500acef561743bbb9ab18" + integrity sha512-hsRUr4FFrvhhRH12wOdfs38Gy7k2FFzB9qgN9v3aLykRq0dRcdcpz5C9FxdS2NuhOrI/628b/KSTJ3rwHysYSg== + +yaeti@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/yaeti/-/yaeti-0.0.6.tgz#f26f484d72684cf42bedfb76970aa1608fbf9577" + integrity sha1-8m9ITXJoTPQr7ft2lwqhYI+/lXc= + +yallist@4.0.0, yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + +yallist@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" + integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= + +yallist@^3.0.0, yallist@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + +yaml-ast-parser@0.0.43: + version "0.0.43" + resolved "https://registry.yarnpkg.com/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz#e8a23e6fb4c38076ab92995c5dca33f3d3d7c9bb" + integrity sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A== + +yargs-parser@20.2.4, yargs-parser@^20.2.2: + version "20.2.4" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" + integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== + +yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-parser@^21.0.0: + version "21.0.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.0.1.tgz#0267f286c877a4f0f728fceb6f8a3e4cb95c6e35" + integrity sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg== + +yargs-unparser@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" + integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== + dependencies: + camelcase "^6.0.0" + decamelize "^4.0.0" + flat "^5.0.2" + is-plain-obj "^2.1.0" + +yargs@16.2.0, yargs@^16.0.0, yargs@^16.0.3: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + +yargs@^15.3.1: + version "15.4.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" + integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== + dependencies: + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^4.2.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^18.1.2" + +yargs@^17.3.1: + version "17.4.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.4.0.tgz#9fc9efc96bd3aa2c1240446af28499f0e7593d00" + integrity sha512-WJudfrk81yWFSOkZYpAZx4Nt7V4xp7S/uJkX0CnxovMCt1wCE8LNftPpNuF9X/u9gN5nsD7ycYtRcDf2pL3UiA== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.0.0" + +ylru@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.2.1.tgz#f576b63341547989c1de7ba288760923b27fe84f" + integrity sha512-faQrqNMzcPCHGVC2aaOINk13K+aaBDUPjGWl0teOXywElLjyVAB6Oe2jj62jHYtwsU49jXhScYbvPENK+6zAvQ== + +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== + +zip-stream@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-4.1.0.tgz#51dd326571544e36aa3f756430b313576dc8fc79" + integrity sha512-zshzwQW7gG7hjpBlgeQP9RuyPGNxvJdzR8SUM3QhxCnLjWN2E7j3dOvpeDcQoETfHx0urRS7EtmVToql7YpU4A== + dependencies: + archiver-utils "^2.1.0" + compress-commons "^4.1.0" + readable-stream "^3.6.0" diff --git a/packages/client/.eslintrc.js b/packages/client/.eslintrc.js new file mode 100644 index 0000000000..a5a4fd0f43 --- /dev/null +++ b/packages/client/.eslintrc.js @@ -0,0 +1,84 @@ +module.exports = { + root: true, + env: { + 'node': false, + }, + parser: 'vue-eslint-parser', + parserOptions: { + 'parser': '@typescript-eslint/parser', + tsconfigRootDir: __dirname, + project: ['./tsconfig.json'], + extraFileExtensions: ['.vue'], + }, + extends: [ + '../shared/.eslintrc.js', + 'plugin:vue/vue3-recommended', + ], + rules: { + '@typescript-eslint/no-empty-interface': [ + 'error', + { + 'allowSingleExtends': true, + }, + ], + // window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため + // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため + 'id-denylist': ['error', 'window', 'e'], + 'no-shadow': ['warn'], + 'vue/attributes-order': ['error', { + 'alphabetical': false, + }], + 'vue/no-use-v-if-with-v-for': ['error', { + 'allowUsingIterationVar': false, + }], + 'vue/no-ref-as-operand': 'error', + 'vue/no-multi-spaces': ['error', { + 'ignoreProperties': false, + }], + 'vue/no-v-html': 'error', + 'vue/order-in-components': 'error', + 'vue/html-indent': ['warn', 'tab', { + 'attribute': 1, + 'baseIndent': 0, + 'closeBracket': 0, + 'alignAttributesVertically': true, + 'ignores': [], + }], + 'vue/html-closing-bracket-spacing': ['warn', { + 'startTag': 'never', + 'endTag': 'never', + 'selfClosingTag': 'never', + }], + 'vue/multi-word-component-names': 'warn', + 'vue/require-v-for-key': 'warn', + 'vue/no-unused-components': 'warn', + 'vue/valid-v-for': 'warn', + 'vue/return-in-computed-property': 'warn', + 'vue/no-setup-props-destructure': 'warn', + 'vue/max-attributes-per-line': 'off', + 'vue/html-self-closing': 'off', + 'vue/singleline-html-element-content-newline': 'off', + }, + globals: { + // Node.js + 'module': false, + 'require': false, + '__dirname': false, + + // Vue + '$$': false, + '$ref': false, + '$shallowRef': false, + '$computed': false, + + // Misskey + '_DEV_': false, + '_LANGS_': false, + '_VERSION_': false, + '_ENV_': false, + '_PERF_PREFIX_': false, + '_DATA_TRANSFER_DRIVE_FILE_': false, + '_DATA_TRANSFER_DRIVE_FOLDER_': false, + '_DATA_TRANSFER_DECK_COLUMN_': false, + }, +}; diff --git a/packages/client/.npmrc b/packages/client/.npmrc new file mode 100644 index 0000000000..6b5f38e890 --- /dev/null +++ b/packages/client/.npmrc @@ -0,0 +1,2 @@ +save-exact = true +package-lock = false diff --git a/packages/client/.vscode/settings.json b/packages/client/.vscode/settings.json new file mode 100644 index 0000000000..4b0903b763 --- /dev/null +++ b/packages/client/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "typescript.tsdk": "node_modules\\typescript\\lib", + "path-intellisense.mappings": { + "@": "${workspaceRoot}/packages/client/src/" + }, + "eslint.validate": [ + "javascript", + "javascriptreact", + "vue" + ] +} diff --git a/packages/client/.yarnrc b/packages/client/.yarnrc new file mode 100644 index 0000000000..788570fcd5 --- /dev/null +++ b/packages/client/.yarnrc @@ -0,0 +1 @@ +network-timeout 600000 diff --git a/packages/client/@types/global.d.ts b/packages/client/@types/global.d.ts new file mode 100644 index 0000000000..c757482900 --- /dev/null +++ b/packages/client/@types/global.d.ts @@ -0,0 +1,10 @@ +type FIXME = any; + +declare const _LANGS_: string[][]; +declare const _VERSION_: string; +declare const _ENV_: string; +declare const _DEV_: boolean; +declare const _PERF_PREFIX_: string; +declare const _DATA_TRANSFER_DRIVE_FILE_: string; +declare const _DATA_TRANSFER_DRIVE_FOLDER_: string; +declare const _DATA_TRANSFER_DECK_COLUMN_: string; diff --git a/packages/client/@types/theme.d.ts b/packages/client/@types/theme.d.ts new file mode 100644 index 0000000000..67f724a9aa --- /dev/null +++ b/packages/client/@types/theme.d.ts @@ -0,0 +1,7 @@ +declare module '@/themes/*.json5' { + import { Theme } from "@/scripts/theme"; + + const theme: Theme; + + export default theme; +} diff --git a/packages/client/@types/vue.d.ts b/packages/client/@types/vue.d.ts new file mode 100644 index 0000000000..f6b66228f6 --- /dev/null +++ b/packages/client/@types/vue.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + const component: DefineComponent<{}, {}, any>; + export default component; +} diff --git a/packages/client/assets/about-icon.png b/packages/client/assets/about-icon.png new file mode 100644 index 0000000000..afc1f0c728 Binary files /dev/null and b/packages/client/assets/about-icon.png differ diff --git a/packages/frontend/assets/fedi.jpg b/packages/client/assets/fedi.jpg similarity index 100% rename from packages/frontend/assets/fedi.jpg rename to packages/client/assets/fedi.jpg diff --git a/packages/frontend/assets/label-red.svg b/packages/client/assets/label-red.svg similarity index 98% rename from packages/frontend/assets/label-red.svg rename to packages/client/assets/label-red.svg index c89d3f5f3a..45996aa9ce 100644 --- a/packages/frontend/assets/label-red.svg +++ b/packages/client/assets/label-red.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/packages/frontend/assets/label.svg b/packages/client/assets/label.svg similarity index 98% rename from packages/frontend/assets/label.svg rename to packages/client/assets/label.svg index 997335f505..b1f85f3c07 100644 --- a/packages/frontend/assets/label.svg +++ b/packages/client/assets/label.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/packages/backend/test/resources/misskey.svg b/packages/client/assets/misskey.svg similarity index 100% rename from packages/backend/test/resources/misskey.svg rename to packages/client/assets/misskey.svg diff --git a/packages/frontend/assets/remove.png b/packages/client/assets/remove.png similarity index 100% rename from packages/frontend/assets/remove.png rename to packages/client/assets/remove.png diff --git a/packages/frontend/assets/sounds/aisha/1.mp3 b/packages/client/assets/sounds/aisha/1.mp3 similarity index 100% rename from packages/frontend/assets/sounds/aisha/1.mp3 rename to packages/client/assets/sounds/aisha/1.mp3 diff --git a/packages/frontend/assets/sounds/aisha/2.mp3 b/packages/client/assets/sounds/aisha/2.mp3 similarity index 100% rename from packages/frontend/assets/sounds/aisha/2.mp3 rename to packages/client/assets/sounds/aisha/2.mp3 diff --git a/packages/frontend/assets/sounds/aisha/3.mp3 b/packages/client/assets/sounds/aisha/3.mp3 similarity index 100% rename from packages/frontend/assets/sounds/aisha/3.mp3 rename to packages/client/assets/sounds/aisha/3.mp3 diff --git a/packages/frontend/assets/sounds/noizenecio/kick_gaba1.mp3 b/packages/client/assets/sounds/noizenecio/kick_gaba.mp3 similarity index 100% rename from packages/frontend/assets/sounds/noizenecio/kick_gaba1.mp3 rename to packages/client/assets/sounds/noizenecio/kick_gaba.mp3 diff --git a/packages/frontend/assets/sounds/noizenecio/kick_gaba2.mp3 b/packages/client/assets/sounds/noizenecio/kick_gaba2.mp3 similarity index 100% rename from packages/frontend/assets/sounds/noizenecio/kick_gaba2.mp3 rename to packages/client/assets/sounds/noizenecio/kick_gaba2.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/down.mp3 b/packages/client/assets/sounds/syuilo/down.mp3 similarity index 100% rename from packages/frontend/assets/sounds/syuilo/down.mp3 rename to packages/client/assets/sounds/syuilo/down.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/kick.mp3 b/packages/client/assets/sounds/syuilo/kick.mp3 similarity index 100% rename from packages/frontend/assets/sounds/syuilo/kick.mp3 rename to packages/client/assets/sounds/syuilo/kick.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/pirori-square-wet.mp3 b/packages/client/assets/sounds/syuilo/pirori-square-wet.mp3 similarity index 100% rename from packages/frontend/assets/sounds/syuilo/pirori-square-wet.mp3 rename to packages/client/assets/sounds/syuilo/pirori-square-wet.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/pirori-wet.mp3 b/packages/client/assets/sounds/syuilo/pirori-wet.mp3 similarity index 100% rename from packages/frontend/assets/sounds/syuilo/pirori-wet.mp3 rename to packages/client/assets/sounds/syuilo/pirori-wet.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/pirori.mp3 b/packages/client/assets/sounds/syuilo/pirori.mp3 similarity index 100% rename from packages/frontend/assets/sounds/syuilo/pirori.mp3 rename to packages/client/assets/sounds/syuilo/pirori.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/poi1.mp3 b/packages/client/assets/sounds/syuilo/poi1.mp3 similarity index 100% rename from packages/frontend/assets/sounds/syuilo/poi1.mp3 rename to packages/client/assets/sounds/syuilo/poi1.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/poi2.mp3 b/packages/client/assets/sounds/syuilo/poi2.mp3 similarity index 100% rename from packages/frontend/assets/sounds/syuilo/poi2.mp3 rename to packages/client/assets/sounds/syuilo/poi2.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/pope1.mp3 b/packages/client/assets/sounds/syuilo/pope1.mp3 similarity index 100% rename from packages/frontend/assets/sounds/syuilo/pope1.mp3 rename to packages/client/assets/sounds/syuilo/pope1.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/pope2.mp3 b/packages/client/assets/sounds/syuilo/pope2.mp3 similarity index 100% rename from packages/frontend/assets/sounds/syuilo/pope2.mp3 rename to packages/client/assets/sounds/syuilo/pope2.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/popo.mp3 b/packages/client/assets/sounds/syuilo/popo.mp3 similarity index 100% rename from packages/frontend/assets/sounds/syuilo/popo.mp3 rename to packages/client/assets/sounds/syuilo/popo.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/queue-jammed.mp3 b/packages/client/assets/sounds/syuilo/queue-jammed.mp3 similarity index 100% rename from packages/frontend/assets/sounds/syuilo/queue-jammed.mp3 rename to packages/client/assets/sounds/syuilo/queue-jammed.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/reverved.mp3 b/packages/client/assets/sounds/syuilo/reverved.mp3 similarity index 100% rename from packages/frontend/assets/sounds/syuilo/reverved.mp3 rename to packages/client/assets/sounds/syuilo/reverved.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/ryukyu.mp3 b/packages/client/assets/sounds/syuilo/ryukyu.mp3 similarity index 100% rename from packages/frontend/assets/sounds/syuilo/ryukyu.mp3 rename to packages/client/assets/sounds/syuilo/ryukyu.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/snare.mp3 b/packages/client/assets/sounds/syuilo/snare.mp3 similarity index 100% rename from packages/frontend/assets/sounds/syuilo/snare.mp3 rename to packages/client/assets/sounds/syuilo/snare.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/square-pico.mp3 b/packages/client/assets/sounds/syuilo/square-pico.mp3 similarity index 100% rename from packages/frontend/assets/sounds/syuilo/square-pico.mp3 rename to packages/client/assets/sounds/syuilo/square-pico.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/triple.mp3 b/packages/client/assets/sounds/syuilo/triple.mp3 similarity index 100% rename from packages/frontend/assets/sounds/syuilo/triple.mp3 rename to packages/client/assets/sounds/syuilo/triple.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/up.mp3 b/packages/client/assets/sounds/syuilo/up.mp3 similarity index 100% rename from packages/frontend/assets/sounds/syuilo/up.mp3 rename to packages/client/assets/sounds/syuilo/up.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/waon.mp3 b/packages/client/assets/sounds/syuilo/waon.mp3 similarity index 100% rename from packages/frontend/assets/sounds/syuilo/waon.mp3 rename to packages/client/assets/sounds/syuilo/waon.mp3 diff --git a/packages/frontend/assets/tagcanvas.min.js b/packages/client/assets/tagcanvas.min.js similarity index 100% rename from packages/frontend/assets/tagcanvas.min.js rename to packages/client/assets/tagcanvas.min.js diff --git a/packages/frontend/assets/unread.svg b/packages/client/assets/unread.svg similarity index 98% rename from packages/frontend/assets/unread.svg rename to packages/client/assets/unread.svg index 8bd4156e51..8c3cc9f475 100644 --- a/packages/frontend/assets/unread.svg +++ b/packages/client/assets/unread.svg @@ -1,7 +1,7 @@ - - - - - - + + + + + + diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000000..c90c7f9667 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,89 @@ +{ + "private": true, + "scripts": { + "watch": "vite build --watch --mode development", + "build": "vite build", + "lint": "eslint --quiet \"src/**/*.{ts,vue}\"" + }, + "resolutions": { + "chokidar": "^3.3.1", + "lodash": "^4.17.21" + }, + "dependencies": { + "@discordapp/twemoji": "14.0.2", + "@fortawesome/fontawesome-free": "6.1.2", + "@rollup/plugin-alias": "3.1.9", + "@rollup/plugin-json": "4.1.0", + "@syuilo/aiscript": "0.11.1", + "@vitejs/plugin-vue": "3.1.0", + "@vue/compiler-sfc": "3.2.39", + "autobind-decorator": "2.4.0", + "autosize": "5.0.1", + "blurhash": "1.1.5", + "broadcast-channel": "4.14.0", + "browser-image-resizer": "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.2", + "chart.js": "3.9.1", + "chartjs-adapter-date-fns": "2.0.0", + "chartjs-plugin-gradient": "0.5.1", + "chartjs-plugin-zoom": "1.2.1", + "compare-versions": "5.0.1", + "cropperjs": "2.0.0-beta", + "date-fns": "2.29.2", + "escape-regexp": "0.0.1", + "eventemitter3": "4.0.7", + "idb-keyval": "6.2.0", + "insert-text-at-cursor": "0.3.0", + "json5": "2.2.1", + "katex": "0.15.6", + "matter-js": "0.18.0", + "mfm-js": "0.23.0", + "misskey-js": "0.0.14", + "photoswipe": "5.3.2", + "prismjs": "1.29.0", + "punycode": "2.1.1", + "querystring": "0.2.1", + "rndstr": "1.0.0", + "s-age": "1.1.2", + "sass": "1.54.9", + "seedrandom": "3.0.5", + "strict-event-emitter-types": "2.0.0", + "stringz": "2.1.0", + "syuilo-password-strength": "0.0.1", + "textarea-caret": "3.1.0", + "three": "0.144.0", + "throttle-debounce": "5.0.0", + "tinycolor2": "1.4.2", + "tsc-alias": "1.7.0", + "tsconfig-paths": "4.1.0", + "twemoji-parser": "14.0.0", + "typescript": "4.8.3", + "uuid": "9.0.0", + "vanilla-tilt": "1.7.2", + "vite": "3.1.0", + "vue": "3.2.39", + "vue-prism-editor": "2.0.0-alpha.2", + "vuedraggable": "4.0.1" + }, + "devDependencies": { + "@types/escape-regexp": "0.0.1", + "@types/glob": "8.0.0", + "@types/gulp": "4.0.9", + "@types/gulp-rename": "2.0.1", + "@types/katex": "0.14.0", + "@types/matter-js": "0.18.1", + "@types/punycode": "2.1.0", + "@types/seedrandom": "3.0.2", + "@types/throttle-debounce": "5.0.0", + "@types/tinycolor2": "1.4.3", + "@types/uuid": "8.3.4", + "@typescript-eslint/eslint-plugin": "5.36.2", + "@typescript-eslint/parser": "5.36.2", + "cross-env": "7.0.3", + "cypress": "10.7.0", + "eslint": "8.23.0", + "eslint-plugin-import": "2.26.0", + "eslint-plugin-vue": "9.4.0", + "rollup": "2.79.0", + "start-server-and-test": "1.14.0" + } +} diff --git a/packages/client/src/account.ts b/packages/client/src/account.ts new file mode 100644 index 0000000000..10257b841f --- /dev/null +++ b/packages/client/src/account.ts @@ -0,0 +1,232 @@ +import { defineAsyncComponent, reactive } from 'vue'; +import * as misskey from 'misskey-js'; +import { showSuspendedDialog } from './scripts/show-suspended-dialog'; +import { i18n } from './i18n'; +import { del, get, set } from '@/scripts/idb-proxy'; +import { apiUrl } from '@/config'; +import { waiting, api, popup, popupMenu, success, alert } from '@/os'; +import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; + +// TODO: 他のタブと永続化されたstateを同期 + +type Account = misskey.entities.MeDetailed; + +const accountData = localStorage.getItem('account'); + +// TODO: 外部からはreadonlyに +export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null; + +export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator); +export const iAmAdmin = $i != null && $i.isAdmin; + +export async function signout() { + waiting(); + localStorage.removeItem('account'); + + await removeAccount($i.id); + + const accounts = await getAccounts(); + + //#region Remove service worker registration + try { + if (navigator.serviceWorker.controller) { + const registration = await navigator.serviceWorker.ready; + const push = await registration.pushManager.getSubscription(); + if (push) { + await fetch(`${apiUrl}/sw/unregister`, { + method: 'POST', + body: JSON.stringify({ + i: $i.token, + endpoint: push.endpoint, + }), + }); + } + } + + if (accounts.length === 0) { + await navigator.serviceWorker.getRegistrations() + .then(registrations => { + return Promise.all(registrations.map(registration => registration.unregister())); + }); + } + } catch (err) {} + //#endregion + + document.cookie = 'igi=; path=/'; + + if (accounts.length > 0) login(accounts[0].token); + else unisonReload('/'); +} + +export async function getAccounts(): Promise<{ id: Account['id'], token: Account['token'] }[]> { + return (await get('accounts')) || []; +} + +export async function addAccount(id: Account['id'], token: Account['token']) { + const accounts = await getAccounts(); + if (!accounts.some(x => x.id === id)) { + await set('accounts', accounts.concat([{ id, token }])); + } +} + +export async function removeAccount(id: Account['id']) { + const accounts = await getAccounts(); + accounts.splice(accounts.findIndex(x => x.id === id), 1); + + if (accounts.length > 0) await set('accounts', accounts); + else await del('accounts'); +} + +function fetchAccount(token: string): Promise { + return new Promise((done, fail) => { + // Fetch user + fetch(`${apiUrl}/i`, { + method: 'POST', + body: JSON.stringify({ + i: token, + }), + }) + .then(res => res.json()) + .then(res => { + if (res.error) { + if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { + showSuspendedDialog().then(() => { + signout(); + }); + } else { + alert({ + type: 'error', + title: i18n.ts.failedToFetchAccountInformation, + text: JSON.stringify(res.error), + }); + } + } else { + res.token = token; + done(res); + } + }) + .catch(fail); + }); +} + +export function updateAccount(accountData) { + for (const [key, value] of Object.entries(accountData)) { + $i[key] = value; + } + localStorage.setItem('account', JSON.stringify($i)); +} + +export function refreshAccount() { + return fetchAccount($i.token).then(updateAccount); +} + +export async function login(token: Account['token'], redirect?: string) { + waiting(); + if (_DEV_) console.log('logging as token ', token); + const me = await fetchAccount(token); + localStorage.setItem('account', JSON.stringify(me)); + document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う + await addAccount(me.id, token); + + if (redirect) { + // 他のタブは再読み込みするだけ + reloadChannel.postMessage(null); + // このページはredirectで指定された先に移動 + location.href = redirect; + return; + } + + unisonReload(); +} + +export async function openAccountMenu(opts: { + includeCurrentAccount?: boolean; + withExtraOperation: boolean; + active?: misskey.entities.UserDetailed['id']; + onChoose?: (account: misskey.entities.UserDetailed) => void; +}, ev: MouseEvent) { + function showSigninDialog() { + popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { + done: res => { + addAccount(res.id, res.i); + success(); + }, + }, 'closed'); + } + + function createAccount() { + popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { + done: res => { + addAccount(res.id, res.i); + switchAccountWithToken(res.i); + }, + }, 'closed'); + } + + async function switchAccount(account: misskey.entities.UserDetailed) { + const storedAccounts = await getAccounts(); + const token = storedAccounts.find(x => x.id === account.id).token; + switchAccountWithToken(token); + } + + function switchAccountWithToken(token: string) { + login(token); + } + + const storedAccounts = await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id)); + const accountsPromise = api('users/show', { userIds: storedAccounts.map(x => x.id) }); + + function createItem(account: misskey.entities.UserDetailed) { + return { + type: 'user', + user: account, + active: opts.active != null ? opts.active === account.id : false, + action: () => { + if (opts.onChoose) { + opts.onChoose(account); + } else { + switchAccount(account); + } + }, + }; + } + + const accountItemPromises = storedAccounts.map(a => new Promise(res => { + accountsPromise.then(accounts => { + const account = accounts.find(x => x.id === a.id); + if (account == null) return res(null); + res(createItem(account)); + }); + })); + + if (opts.withExtraOperation) { + popupMenu([...[{ + type: 'link', + text: i18n.ts.profile, + to: `/@${ $i.username }`, + avatar: $i, + }, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { + type: 'parent', + icon: 'fas fa-plus', + text: i18n.ts.addAccount, + children: [{ + text: i18n.ts.existingAccount, + action: () => { showSigninDialog(); }, + }, { + text: i18n.ts.createAccount, + action: () => { createAccount(); }, + }], + }, { + type: 'link', + icon: 'fas fa-users', + text: i18n.ts.manageAccounts, + to: '/settings/accounts', + }]], ev.currentTarget ?? ev.target, { + align: 'left', + }); + } else { + popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, { + align: 'left', + }); + } +} diff --git a/packages/client/src/components/MkAbuseReport.vue b/packages/client/src/components/MkAbuseReport.vue new file mode 100644 index 0000000000..9a3464b640 --- /dev/null +++ b/packages/client/src/components/MkAbuseReport.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/packages/client/src/components/MkAbuseReportWindow.vue b/packages/client/src/components/MkAbuseReportWindow.vue new file mode 100644 index 0000000000..1862d0a0e4 --- /dev/null +++ b/packages/client/src/components/MkAbuseReportWindow.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/packages/client/src/components/MkAnalogClock.vue b/packages/client/src/components/MkAnalogClock.vue new file mode 100644 index 0000000000..40ef626aed --- /dev/null +++ b/packages/client/src/components/MkAnalogClock.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/packages/client/src/components/MkAutocomplete.vue b/packages/client/src/components/MkAutocomplete.vue new file mode 100644 index 0000000000..144281e3c3 --- /dev/null +++ b/packages/client/src/components/MkAutocomplete.vue @@ -0,0 +1,474 @@ + + + + + + + diff --git a/packages/client/src/components/MkAvatars.vue b/packages/client/src/components/MkAvatars.vue new file mode 100644 index 0000000000..958e5db0a1 --- /dev/null +++ b/packages/client/src/components/MkAvatars.vue @@ -0,0 +1,24 @@ + + + diff --git a/packages/client/src/components/MkButton.vue b/packages/client/src/components/MkButton.vue new file mode 100644 index 0000000000..a052f8f7a9 --- /dev/null +++ b/packages/client/src/components/MkButton.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/packages/client/src/components/MkCaptcha.vue b/packages/client/src/components/MkCaptcha.vue new file mode 100644 index 0000000000..7360734914 --- /dev/null +++ b/packages/client/src/components/MkCaptcha.vue @@ -0,0 +1,114 @@ + + + diff --git a/packages/client/src/components/MkChannelFollowButton.vue b/packages/client/src/components/MkChannelFollowButton.vue new file mode 100644 index 0000000000..dff02beec0 --- /dev/null +++ b/packages/client/src/components/MkChannelFollowButton.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/packages/client/src/components/MkChannelPreview.vue b/packages/client/src/components/MkChannelPreview.vue new file mode 100644 index 0000000000..dd3794a657 --- /dev/null +++ b/packages/client/src/components/MkChannelPreview.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/packages/frontend/src/components/MkChart.vue b/packages/client/src/components/MkChart.vue similarity index 86% rename from packages/frontend/src/components/MkChart.vue rename to packages/client/src/components/MkChart.vue index adb3c134ae..31e95404fa 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/client/src/components/MkChart.vue @@ -1,13 +1,7 @@ - -